addon: switch play action to resolve_play (yt-dlp combined format)

Realized during M6 packaging that the rustypipe path returns separate
audio + video DASH streams (Opus 251 + AV1 401 on the smoke video). Kodi
can't sync those without an inputstream.adaptive DASH manifest, which
would need server-side manifest generation — M3+ territory.

Stopgap for shippable M3: new sidecar op resolve_play that asks yt-dlp
for -f best[ext=mp4]/best — one combined audio+video URL Kodi plays as
plain HTTP. ~3-5s overhead vs rustypipe but reliable sync.

main.py _play() now calls resolve_play. resolve still exists for
metadata + browse paths (M4 will use it).

Rebuilt aarch64-musl binary, repackaged plugin.video.torttube-0.0.1.zip
(38.7MB, md5 f2c08aed130b1c1bd231a9b6cbfac93c). Live at:
  smb://lucy/downloads/torttube/plugin.video.torttube-0.0.1.zip
This commit is contained in:
Kayos 2026-05-23 08:59:25 -07:00
parent f4ceae3b70
commit 283693525c
3 changed files with 76 additions and 33 deletions

View file

@ -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
stream_url = resp.get("stream_url")
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")
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))

View file

@ -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(),

View file

@ -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<Value, HandlerError> {
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<Value, HandlerError> {
match tier1_rustypipe(id).await {
Ok(v) => {