// 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}; /// Playback-ready single-URL resolve. Asks yt-dlp for `best[ext=mp4]/best` — /// a combined audio+video format that Kodi can play as a plain HTTP URL. /// Slower than `resolve()` (~3-5s) but guarantees a working stream. pub(crate) async fn resolve_play(id: &str) -> Result { let url = format!("https://www.youtube.com/watch?v={id}"); // -f best[ext=mp4]/best — prefer mp4 progressive, else any best combined. // We use -j to get the full info dump; the selected format's URL appears // as the top-level "url" field. let stdout = run_yt_dlp(&[ "-j", "--no-warnings", "--no-playlist", "-f", "best[ext=mp4]/best", &url, ]) .await .map_err(|e| classify_yt_dlp_error(&e))?; let dump: Value = serde_json::from_slice(&stdout) .map_err(|e| HandlerError::Extractor(format!("yt-dlp json parse: {e}")))?; let stream_url = dump .get("url") .and_then(Value::as_str) .ok_or_else(|| HandlerError::Extractor("yt-dlp: no top-level url".into()))? .to_string(); tracing::info!(id, "resolve_play ok via yt-dlp combined"); Ok(serde_json::json!({ "source": "yt-dlp", "stream_url": stream_url, "title": dump.get("title"), "duration_s": dump.get("duration"), "channel_name": dump.get("channel"), "channel_id": dump.get("channel_id"), "thumbnail": dump.get("thumbnail"), "format_id": dump.get("format_id"), "ext": dump.get("ext"), })) } /// Metadata-rich resolve. Tries Tier 1 (rustypipe), falls back to Tier 2 (yt-dlp -j). /// Returns the full extractor response including separate audio + video streams. /// Use `resolve_play` instead for direct playback (returns a single combined URL). pub(crate) async fn resolve(id: &str) -> Result { 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 { 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 yt-dlp shell-out error into one of our typed handler errors. /// yt-dlp's stderr is freeform English; we match on substrings, case-insensitive /// via a lowercase copy, but preserve the original message in the returned error. fn classify_yt_dlp_error(e: &anyhow::Error) -> HandlerError { let original = e.to_string(); let lower = original.to_lowercase(); if lower.contains("age") { HandlerError::AgeRestricted } else if lower.contains("private") { HandlerError::PrivateVideo } else if lower.contains("not available") || lower.contains("does not exist") { HandlerError::NotFound } else if lower.contains("geo") || lower.contains("region") { HandlerError::RegionBlocked } else { HandlerError::Extractor(original) } } /// 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 { 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| classify_yt_dlp_error(&e))?; 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 = 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::().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, })) }