diff --git a/addon/plugin.video.torttube/main.py b/addon/plugin.video.torttube/main.py index dba81cd..591081b 100644 --- a/addon/plugin.video.torttube/main.py +++ b/addon/plugin.video.torttube/main.py @@ -105,12 +105,15 @@ def _resolved_listitem(stream_url: str, title: str | None) -> xbmcgui.ListItem: def _play(yt_id: str) -> None: - """Resolve via sidecar, hand the URL to Kodi's player.""" + """Resolve via sidecar (yt-dlp combined-format path), hand URL to Kodi.""" _log(f"play id={yt_id}") try: - resp = _call_sidecar({"op": "resolve", "id": yt_id}) + # resolve_play returns ONE combined audio+video URL — guaranteed + # to play in Kodi without needing inputstream.adaptive/DASH. + # ~3-5s overhead vs rustypipe but reliable audio sync. + resp = _call_sidecar({"op": "resolve_play", "id": yt_id}, timeout_s=45) except Exception as e: - _log(f"sidecar resolve failed: {e}", xbmc.LOGERROR) + _log(f"sidecar resolve_play failed: {e}", xbmc.LOGERROR) xbmcgui.Dialog().notification( "torttube", f"resolve failed: {e}", xbmcgui.NOTIFICATION_ERROR, 5000 ) @@ -127,34 +130,8 @@ def _play(yt_id: str) -> None: xbmcplugin.setResolvedUrl(_HANDLE, False, xbmcgui.ListItem()) return - # Pick a URL. Preference order for M3: - # 1. video_stream.url if it has embedded audio (yt-dlp combined formats) - # 2. video_stream.url (will need audio mux in a later milestone) - # 3. audio_stream.url (audio-only playback) - stream_url = None - title = None - source = resp.get("source", "?") - - if source == "rustypipe": - details = resp.get("details") or {} - title = details.get("name") or details.get("title") - vs = resp.get("video_stream") - as_ = resp.get("audio_stream") - # rustypipe separates audio + video. For M3 we play video_stream; - # M3+ will wire a DASH manifest or merged-format selection for sync. - if vs and vs.get("url"): - stream_url = vs["url"] - elif as_ and as_.get("url"): - stream_url = as_["url"] - else: # yt-dlp tier 2 - title = resp.get("title") - streams = resp.get("streams") or [] - # yt-dlp's combined formats come back as entries with both audio + video. - combined = [s for s in streams if not s.get("is_audio_only") and not s.get("is_video_only")] - candidates = combined or streams - if candidates: - stream_url = candidates[0].get("url") - + stream_url = resp.get("stream_url") + title = resp.get("title") if not stream_url: _log("no usable stream URL in sidecar response", xbmc.LOGERROR) xbmcgui.Dialog().notification( @@ -163,7 +140,7 @@ def _play(yt_id: str) -> None: xbmcplugin.setResolvedUrl(_HANDLE, False, xbmcgui.ListItem()) return - _log(f"resolved via {source}, playing") + _log(f"resolved via {resp.get('source')}, playing") xbmcplugin.setResolvedUrl(_HANDLE, True, _resolved_listitem(stream_url, title)) diff --git a/sidecar/crates/torttube-sidecar/src/main.rs b/sidecar/crates/torttube-sidecar/src/main.rs index 49a53d4..c2181a6 100644 --- a/sidecar/crates/torttube-sidecar/src/main.rs +++ b/sidecar/crates/torttube-sidecar/src/main.rs @@ -24,7 +24,14 @@ mod sponsor; #[serde(tag = "op", rename_all = "snake_case")] enum Request { Ping, + /// Metadata-rich resolve (rustypipe → yt-dlp). May return separate + /// audio + video streams; the addon must mux or use a DASH wrapper. Resolve { id: String }, + /// Playback-ready resolve: returns ONE combined audio+video URL via + /// yt-dlp `-f best[ext=mp4]/best`. Kodi plays it as a plain HTTP URL, + /// no inputstream.adaptive needed. Slower than `resolve` but always + /// gives a working stream. + ResolvePlay { id: String }, Rip { id: String, dest_dir: String }, Sponsorblock { id: String, @@ -120,6 +127,10 @@ async fn handle_line(line: &str) -> Response { Ok(v) => Response::ok(v), Err(e) => e.into(), }, + Request::ResolvePlay { id } => match resolve::resolve_play(&id).await { + Ok(v) => Response::ok(v), + Err(e) => e.into(), + }, Request::Rip { id, dest_dir } => match rip::rip(&id, &dest_dir).await { Ok(v) => Response::ok(v), Err(e) => e.into(), diff --git a/sidecar/crates/torttube-sidecar/src/resolve.rs b/sidecar/crates/torttube-sidecar/src/resolve.rs index 1aad439..a6ed47a 100644 --- a/sidecar/crates/torttube-sidecar/src/resolve.rs +++ b/sidecar/crates/torttube-sidecar/src/resolve.rs @@ -5,7 +5,62 @@ 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). +/// 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. + // -g prints just the URL. We use -j to also get title/duration for the + // ListItem; the URL is then "url" at the top level. + let stdout = run_yt_dlp(&[ + "-j", "--no-warnings", "--no-playlist", + "-f", "best[ext=mp4]/best", + &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}")))?; + + 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) => {