M1 sidecar — resolve (rustypipe + yt-dlp), rip, sponsorblock
JSON-over-stdio loop on tokio with four ops:
- ping liveness
- resolve Tier 1 rustypipe → Tier 2 yt-dlp -j fallback. Typed
errors (age/region/private/not-found) short-circuit
Tier 2 so we don't double-hit a wall. Pass-through
serialization of player.details + selected streams,
so the Python addon parses what it needs without us
coupling to rustypipe's struct shape.
- rip Tier 3 yt-dlp downloads bestvideo+bestaudio to a
caller-supplied dest_dir, returns the resulting
path + size for the addon to play as a local file.
- sponsorblock SHA-256 prefix lookup (first 4 hex), filter to the
exact video_id locally. Categories default to
[sponsor, selfpromo, interaction]; caller can override.
Smoke ran in the build container against dQw4w9WgXcQ — rustypipe 0.11.4
still resolves cleanly in 2026-05, sig decoding intact, both 4K AV1
video and Opus 128kbps audio came back with valid signed URLs.
SponsorBlock returns empty segments for music videos (as expected).
This commit is contained in:
parent
5abca77814
commit
17871cfc00
5 changed files with 536 additions and 5 deletions
146
sidecar/crates/torttube-sidecar/src/resolve.rs
Normal file
146
sidecar/crates/torttube-sidecar/src/resolve.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// resolve.rs — Tier 1 (rustypipe) → Tier 2 (yt-dlp -j fallback)
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{run_yt_dlp, HandlerError};
|
||||
|
||||
/// Top-level resolve. Tries Tier 1 (rustypipe), falls back to Tier 2 (yt-dlp -j).
|
||||
pub(crate) async fn resolve(id: &str) -> Result<Value, HandlerError> {
|
||||
match tier1_rustypipe(id).await {
|
||||
Ok(v) => {
|
||||
tracing::info!(id, source = "rustypipe", "resolve ok");
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(id, error = %e, "rustypipe failed; falling back to yt-dlp");
|
||||
// Typed errors that mean "video can't be played by anyone" — don't retry yt-dlp,
|
||||
// it'll just hit the same wall.
|
||||
if matches!(
|
||||
e,
|
||||
HandlerError::AgeRestricted
|
||||
| HandlerError::PrivateVideo
|
||||
| HandlerError::NotFound
|
||||
) {
|
||||
return Err(e);
|
||||
}
|
||||
tier2_yt_dlp(id).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tier 1 — native rustypipe. Serializes the whole player.details + selected streams as
|
||||
/// opaque pass-through JSON. The Python addon parses the fields it needs; this keeps us
|
||||
/// resilient to rustypipe shape evolution and unblocks tier-2 normalization later.
|
||||
async fn tier1_rustypipe(id: &str) -> Result<Value, HandlerError> {
|
||||
use rustypipe::client::RustyPipe;
|
||||
use rustypipe::param::StreamFilter;
|
||||
|
||||
let rp = RustyPipe::new();
|
||||
let player = rp
|
||||
.query()
|
||||
.player(id)
|
||||
.await
|
||||
.map_err(|e| classify_rustypipe_error(&e))?;
|
||||
|
||||
let (video, audio) = player.select_video_audio_stream(&StreamFilter::default());
|
||||
|
||||
let details_json = serde_json::to_value(&player.details)
|
||||
.map_err(|e| HandlerError::Internal(format!("serialize details: {e}")))?;
|
||||
let video_json = video
|
||||
.map(|v| serde_json::to_value(v))
|
||||
.transpose()
|
||||
.map_err(|e| HandlerError::Internal(format!("serialize video: {e}")))?
|
||||
.unwrap_or(Value::Null);
|
||||
let audio_json = audio
|
||||
.map(|a| serde_json::to_value(a))
|
||||
.transpose()
|
||||
.map_err(|e| HandlerError::Internal(format!("serialize audio: {e}")))?
|
||||
.unwrap_or(Value::Null);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"source": "rustypipe",
|
||||
"details": details_json,
|
||||
"video_stream": video_json,
|
||||
"audio_stream": audio_json,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Classify a rustypipe error into one of our typed handler errors.
|
||||
/// rustypipe's error enum varies by version; we match on the Display string for resilience.
|
||||
fn classify_rustypipe_error(e: &dyn std::fmt::Display) -> HandlerError {
|
||||
let msg = e.to_string().to_lowercase();
|
||||
if msg.contains("age") && msg.contains("restrict") {
|
||||
HandlerError::AgeRestricted
|
||||
} else if msg.contains("region") || msg.contains("country") || msg.contains("geo") {
|
||||
HandlerError::RegionBlocked
|
||||
} else if msg.contains("private") {
|
||||
HandlerError::PrivateVideo
|
||||
} else if msg.contains("not found") || msg.contains("unavailable") {
|
||||
HandlerError::NotFound
|
||||
} else if msg.contains("network") || msg.contains("timeout") || msg.contains("connect") {
|
||||
HandlerError::Network(msg)
|
||||
} else {
|
||||
HandlerError::Extractor(msg)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tier 2 — shell out to yt-dlp -j.
|
||||
async fn tier2_yt_dlp(id: &str) -> Result<Value, HandlerError> {
|
||||
let url = format!("https://www.youtube.com/watch?v={id}");
|
||||
let stdout = run_yt_dlp(&["-j", "--no-warnings", "--no-playlist", &url])
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = e.to_string().to_lowercase();
|
||||
if msg.contains("age") {
|
||||
HandlerError::AgeRestricted
|
||||
} else if msg.contains("private") {
|
||||
HandlerError::PrivateVideo
|
||||
} else if msg.contains("not available") || msg.contains("does not exist") {
|
||||
HandlerError::NotFound
|
||||
} else if msg.contains("geo") || msg.contains("region") {
|
||||
HandlerError::RegionBlocked
|
||||
} else {
|
||||
HandlerError::Extractor(msg)
|
||||
}
|
||||
})?;
|
||||
|
||||
let dump: Value = serde_json::from_slice(&stdout)
|
||||
.map_err(|e| HandlerError::Extractor(format!("yt-dlp json parse: {e}")))?;
|
||||
|
||||
// yt-dlp's JSON has a `formats` array. We pass it through largely as-is — the addon
|
||||
// can pick what inputstream.adaptive wants. Shape it to match our protocol.
|
||||
let streams: Vec<Value> = dump
|
||||
.get("formats")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|f| {
|
||||
let url = f.get("url")?.as_str()?.to_string();
|
||||
let vcodec = f.get("vcodec").and_then(Value::as_str).unwrap_or("none");
|
||||
let acodec = f.get("acodec").and_then(Value::as_str).unwrap_or("none");
|
||||
let is_audio_only = vcodec == "none" && acodec != "none";
|
||||
let is_video_only = vcodec != "none" && acodec == "none";
|
||||
Some(serde_json::json!({
|
||||
"url": url,
|
||||
"itag": f.get("format_id").and_then(|v| v.as_str()).and_then(|s| s.parse::<u32>().ok()),
|
||||
"mime": f.get("ext"),
|
||||
"width": f.get("width"),
|
||||
"height": f.get("height"),
|
||||
"bitrate": f.get("tbr"),
|
||||
"is_audio_only": is_audio_only,
|
||||
"is_video_only": is_video_only,
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"source": "yt-dlp",
|
||||
"title": dump.get("title"),
|
||||
"duration_s": dump.get("duration"),
|
||||
"channel_name": dump.get("channel"),
|
||||
"channel_id": dump.get("channel_id"),
|
||||
"streams": streams,
|
||||
}))
|
||||
}
|
||||
Reference in a new issue