From 45e1306bf39d3247af0d3c087bc9eac99c1bcaa8 Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 23 May 2026 11:14:56 -0700 Subject: [PATCH] =?UTF-8?q?DASH=20HD=20playback=20=E2=80=94=20WIP=20behind?= =?UTF-8?q?=20TORTTUBE=5FDASH=3D1=20+=20upstream=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidecar resolve_dash op shipped — returns rustypipe's full video_only_streams + audio_streams (16+ representations for NGGYU, from 360p H.264 through 4K AV1). Addon _build_dash_mpd assembles a valid on-demand MPEG-DASH manifest filtered to H.264 ≤1080p + best AAC audio. Two unblocked-by-WIP issues surfaced during integration: - inputstream.adaptive's libcurl can't open file:// URLs (logged in docs/upstream.md as an enhancement-target). - Rapid Player.Open retries can trigger Kodi's 'two concurrent busydialogs' fatal exit; need lifecycle hardening before re-enabling. Pivoted to localhost HTTP-server serving (ThreadingHTTPServer on a port-0 socket, MPD bytes captured in a per-instance handler subclass). Lifecycle: server.shutdown() runs in a finally block after the SponsorBlockMonitor watcher exits. Works in isolation but Kodi crashed under rapid retry conditions — needs more testing. For v0.0.5: DASH path is gated behind TORTTUBE_DASH=1 env var; default falls through to the stable yt-dlp progressive 360p path that's been verified live. M7 milestone added to track the remaining work; PRs to inputstream.adaptive + Kodi candidates logged in docs/upstream.md. --- MILESTONES.md | 21 +- addon/plugin.video.torttube/addon.xml | 2 +- addon/plugin.video.torttube/main.py | 260 +++++++++++++++++- docs/upstream.md | 19 +- sidecar/crates/torttube-sidecar/src/main.rs | 9 + .../crates/torttube-sidecar/src/resolve.rs | 32 +++ 6 files changed, 329 insertions(+), 14 deletions(-) diff --git a/MILESTONES.md b/MILESTONES.md index e4aea84..8e8754f 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -71,8 +71,25 @@ picked format 18, 360p H.264+AAC progressive) - [ ] armv7 build for older Pis (deferred — current TV is aarch64-capable and my static sidecar runs on it even though userspace is armhf) -- [ ] 720p+ playback (deferred to M5+) — itag 22 (720p progressive) is - deprecated by YouTube; higher quality needs DASH manifest generation +- [ ] 720p+ playback (DASH WIP — see "M7 — DASH/HD" below) — itag 22 (720p + progressive) is deprecated by YouTube; higher quality needs DASH + manifest generation. Code path exists, gated behind `TORTTUBE_DASH=1`. + +## M7 — DASH / HD playback [WIP] + +- [x] sidecar `resolve_dash` op returns rustypipe's full + `video_only_streams` + `audio_streams` arrays (16+ representations) +- [x] addon `_build_dash_mpd` constructs valid on-demand MPD with H.264 + video reps from 360p → 1080p + best AAC audio rep (XML-escaped URLs, + proper `SegmentBase indexRange` + `Initialization range`) +- [ ] **serve MPD over localhost HTTP** — inputstream.adaptive's libcurl + can't open `file://` URLs. ThreadingHTTPServer + Player monitor + lifecycle is sketched but caused Kodi's "two concurrent busydialogs" + fatal during rapid retries. Needs more work on lifecycle + retry + backoff before re-enabling. +- [ ] handle session-cookie / poToken inheritance — googlevideo URLs may + need the same client signature across MPD + segment fetches +- [ ] graceful fallback to progressive if MPD load fails mid-playback ## Upstream PR work (parallel lane — every bug evaluated for "fix it upstream?") diff --git a/addon/plugin.video.torttube/addon.xml b/addon/plugin.video.torttube/addon.xml index 9f0344a..6692031 100644 --- a/addon/plugin.video.torttube/addon.xml +++ b/addon/plugin.video.torttube/addon.xml @@ -1,7 +1,7 @@ diff --git a/addon/plugin.video.torttube/main.py b/addon/plugin.video.torttube/main.py index 8c66e40..54c2c69 100644 --- a/addon/plugin.video.torttube/main.py +++ b/addon/plugin.video.torttube/main.py @@ -18,13 +18,17 @@ That's how Android / phone / "send to TV" flows hand off — Kodi already exposes the endpoint, we just need to register the plugin URL. """ +import http.server import json import os import re +import socketserver import subprocess import sys +import threading from typing import Any from urllib.parse import parse_qsl, urlparse +from xml.sax.saxutils import escape as xml_escape import xbmc import xbmcaddon @@ -104,23 +108,235 @@ def _resolved_listitem(stream_url: str, title: str | None) -> xbmcgui.ListItem: li = xbmcgui.ListItem(label=title or "torttube") li.setPath(stream_url) li.setProperty("IsPlayable", "true") - # Tell inputstream.adaptive to handle DASH/HLS if the URL looks like a manifest. - if stream_url.endswith(".mpd"): + # Tell inputstream.adaptive to handle DASH/HLS based on URL path/suffix. + if ".mpd" in stream_url: li.setProperty("inputstream", "inputstream.adaptive") li.setProperty("inputstream.adaptive.manifest_type", "mpd") - elif stream_url.endswith(".m3u8"): + elif ".m3u8" in stream_url: li.setProperty("inputstream", "inputstream.adaptive") li.setProperty("inputstream.adaptive.manifest_type", "hls") return li -def _play(yt_id: str) -> None: - """Resolve via sidecar (yt-dlp combined-format path), hand URL to Kodi.""" - _log(f"play id={yt_id}") +class _MpdHandler(http.server.BaseHTTPRequestHandler): + """Serves the per-video MPD file. The bytes come from the closure-captured + `_MPD_BYTES` so the server can outlive the temp file if the addon decides + to clean up early. One handler per HTTPServer instance.""" + + mpd_bytes: bytes = b"" + + def do_GET(self) -> None: # noqa: N802 — http.server convention + self.send_response(200) + self.send_header("Content-Type", "application/dash+xml") + self.send_header("Content-Length", str(len(self.mpd_bytes))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(self.mpd_bytes) + + def log_message(self, *args: Any, **kwargs: Any) -> None: + # Silence the default request log — Kodi's log is verbose enough. + return + + +def _start_mpd_server(mpd_bytes: bytes) -> tuple[str, http.server.HTTPServer]: + """Spin up a one-shot localhost HTTP server that serves `mpd_bytes` at any + path. Returns (url, server). Caller is responsible for `server.shutdown()` + once playback ends.""" + + # Per-server handler subclass so the closure captures the bytes for THIS + # video without leaking state to a concurrent invocation. + handler_cls = type( + "_MpdHandlerInstance", + (_MpdHandler,), + {"mpd_bytes": mpd_bytes}, + ) + # Port 0 → kernel picks free port. ThreadingHTTPServer so multiple GETs + # (the manifest + range requests, if any) don't serialize. + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler_cls) + threading.Thread(target=server.serve_forever, daemon=True).start() + port = server.server_address[1] + url = f"http://127.0.0.1:{port}/manifest.mpd" + _log(f"MPD HTTP server up on {url}") + return url, server + + +_MIME_CODEC_RE = re.compile(r'codecs="([^"]+)"') + + +def _codec_from_mime(stream: dict[str, Any]) -> str: + """Extract the full DASH codec string (avc1.4d4015 etc) from the mime field. + + rustypipe gives `codec: "avc1"` (short form) but DASH MPDs need the full + profile/level identifier from `mime: 'video/mp4; codecs="avc1.4d4015"'`. + """ + mime = stream.get("mime", "") + m = _MIME_CODEC_RE.search(mime) + return m.group(1) if m else (stream.get("codec") or "") + + +def _build_dash_mpd( + details: dict[str, Any], + video_streams: list[dict[str, Any]], + audio_streams: list[dict[str, Any]], +) -> str | None: + """Build a static MPEG-DASH on-demand manifest from rustypipe's stream data. + + Picks H.264 (avc1) video streams up to 1080p — guaranteed to play on the + RPi 4's hardware H.264 decoder. Picks the best AAC (mp4a) audio stream. + Returns the MPD XML, or None if no compatible streams were found. + """ + duration_s = float(details.get("duration") or 0) + if duration_s <= 0: + return None + + # Filter video: H.264 only (avc1.*), height <= 1080 (RPi 4 H.264 ceiling). + h264 = [ + s + for s in video_streams + if "avc1" in (s.get("codec") or s.get("mime", "")) + and (s.get("height") or 0) <= 1080 + and s.get("init_range") + and s.get("index_range") + and s.get("url") + ] + if not h264: + return None + + # Filter audio: AAC preferred, Opus fallback. Pick highest-bitrate of preferred codec. + aac = [s for s in audio_streams if "mp4a" in (s.get("codec") or s.get("mime", ""))] + opus = [s for s in audio_streams if "opus" in (s.get("codec") or s.get("mime", ""))] + audio_pool = aac or opus + if not audio_pool: + return None + best_audio = max(audio_pool, key=lambda s: s.get("bitrate") or 0) + if not (best_audio.get("init_range") and best_audio.get("index_range") and best_audio.get("url")): + return None + + # Sort video high → low quality so inputstream.adaptive's default-best picks the top. + h264.sort(key=lambda s: (s.get("height") or 0, s.get("bitrate") or 0), reverse=True) + + parts = [ + '', + '', + " ", + ' ', + ] + for v in h264: + codec = _codec_from_mime(v) or "avc1.4d401f" + ir = v["index_range"] + init = v["init_range"] + parts.append( + f' ' + ) + parts.append(f" {xml_escape(v['url'])}") + parts.append( + f' ' + ) + parts.append(f' ') + parts.append(" ") + parts.append(" ") + parts.append(" ") + + # Audio adaptation set. + a = best_audio + a_codec = _codec_from_mime(a) or ("mp4a.40.2" if "mp4a" in (a.get("codec") or "") else "opus") + a_mime = "audio/mp4" if "mp4a" in a_codec else "audio/webm" + a_ir = a["index_range"] + a_init = a["init_range"] + parts.append( + f' ' + ) + parts.append( + f' ' + ) + parts.append( + ' ' + ) + parts.append(f" {xml_escape(a['url'])}") + parts.append( + f' ' + ) + parts.append(f' ') + parts.append(" ") + parts.append(" ") + parts.append(" ") + parts.append(" ") + parts.append("") + + return "\n".join(parts) + + +def _try_dash(yt_id: str) -> tuple[bytes | None, dict[str, Any]]: + """Resolve via rustypipe + build DASH MPD. Returns (mpd_bytes, resp). + + On any failure returns (None, resp) so the caller can fall back to + the progressive path. We serve the MPD over localhost HTTP because + inputstream.adaptive's libcurl can't open file:// URLs. + """ + try: + resp = _call_sidecar({"op": "resolve_dash", "id": yt_id}, timeout_s=30) + except Exception as e: + _log(f"resolve_dash failed (will fall back): {e}", xbmc.LOGWARNING) + return None, {} + if not resp.get("ok"): + return None, resp + + mpd = _build_dash_mpd( + resp.get("details") or {}, + resp.get("video_only_streams") or [], + resp.get("audio_streams") or [], + ) + if not mpd: + _log("DASH build returned no compatible streams; falling back to progressive") + return None, resp + return mpd.encode("utf-8"), resp + + +def _play(yt_id: str) -> None: + """Resolve via DASH (rustypipe, up to 1080p H.264) with progressive + yt-dlp fallback (360p). + + DASH path is gated behind TORTTUBE_DASH=1 while the manifest-serving + HTTP server + inputstream.adaptive integration is being stabilized + (file:// URLs don't work, and rapid retries via a port-0 HTTP server + can trigger Kodi's 'two concurrent busydialogs' fatal). Default OFF + until that's solid — progressive yt-dlp path is reliable. + """ + _log(f"play id={yt_id}") + + mpd_bytes: bytes | None = None + dash_resp: dict[str, Any] = {} + if os.environ.get("TORTTUBE_DASH") == "1": + mpd_bytes, dash_resp = _try_dash(yt_id) + if mpd_bytes: + details = dash_resp.get("details") or {} + title = details.get("name") + mpd_url, server = _start_mpd_server(mpd_bytes) + _log(f"resolved via rustypipe DASH, serving manifest at {mpd_url}") + xbmcplugin.setResolvedUrl(_HANDLE, True, _resolved_listitem(mpd_url, title)) + try: + _attach_sponsorblock(yt_id) + finally: + # Shut down the MPD server cleanly once playback ends or aborts. + # _attach_sponsorblock blocks while playback is active. + try: + server.shutdown() + server.server_close() + _log("MPD HTTP server shut down") + except Exception as e: + _log(f"MPD server shutdown error: {e}", xbmc.LOGWARNING) + return + + # Fallback: progressive single-URL via yt-dlp (360p). try: - # 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_play failed: {e}", xbmc.LOGERROR) @@ -150,8 +366,32 @@ def _play(yt_id: str) -> None: xbmcplugin.setResolvedUrl(_HANDLE, False, xbmcgui.ListItem()) return - _log(f"resolved via {resp.get('source')}, playing") + _log(f"resolved via yt-dlp progressive fallback, playing") xbmcplugin.setResolvedUrl(_HANDLE, True, _resolved_listitem(stream_url, title)) + _attach_sponsorblock(yt_id) + + +def _attach_sponsorblock(yt_id: str) -> None: + """Fetch SponsorBlock segments and block on the monitor loop. Always blocks + until playback ends (or 30s if playback never starts) so the caller can + use this as a 'wait for playback to finish' signal — needed to keep the + MPD HTTP server alive throughout playback. + + Non-fatal on segment fetch error. + """ + skip_segments: list[dict[str, Any]] = [] + try: + sb_resp = _call_sidecar({"op": "sponsorblock", "id": yt_id}, timeout_s=8) + if sb_resp.get("ok"): + segs = sb_resp.get("segments") or [] + skip_segments = [s for s in segs if s.get("actionType") == "skip"] + _log(f"sponsorblock: {len(skip_segments)} skip segments") + except Exception as e: + _log(f"sponsorblock fetch failed (non-fatal): {e}", xbmc.LOGWARNING) + + # Always run the watcher even with zero segments — it doubles as the + # 'block until playback ends' signal that gates MPD-server shutdown. + SponsorBlockMonitor(skip_segments).run() # SponsorBlock: fetch segments, then block on a monitor that seeks past # each skip segment as the playhead enters it. Best-effort — failure to diff --git a/docs/upstream.md b/docs/upstream.md index 2102ce8..bd80008 100644 --- a/docs/upstream.md +++ b/docs/upstream.md @@ -11,7 +11,24 @@ _(none yet — opens with M1 development)_ | Date | Project | PR/Issue | Title | Status | Outcome | |------|---------|----------|-------|--------|---------| -| _empty_ | | | | | | +| _none yet_ | | | | | | + +## Investigation notes (not yet a PR — to be evaluated) + +- **inputstream.adaptive — `file://` not supported by libcurl downloader** + ([kodi addon, xbmc/inputstream.adaptive on github](https://github.com/xbmc/inputstream.adaptive)) + Hit during torttube M7 DASH work 2026-05-23. Loading an MPD from a local + filesystem path via `file:///storage/.kodi/temp/torttube/.mpd` fails + with `CURLOpen returned an error, download failed`. Common workaround + is a localhost HTTP server (plugin.video.youtube does this). Worth + filing an enhancement request to either accept `file://` or document + the recommended pattern. +- **Kodi — "Logic error due to two concurrent busydialogs" fatal** + Reproduced during rapid back-to-back `Player.Open` calls while a + previous play's BusyDialog was still dismissing. Log message itself + says "this is a known issue", but the app HARD-exits rather than + silently dropping one of the dialogs. Look up the upstream tracker + before filing. ## Watching (not ours, but relevant to torttube) diff --git a/sidecar/crates/torttube-sidecar/src/main.rs b/sidecar/crates/torttube-sidecar/src/main.rs index c2181a6..7593917 100644 --- a/sidecar/crates/torttube-sidecar/src/main.rs +++ b/sidecar/crates/torttube-sidecar/src/main.rs @@ -32,6 +32,11 @@ enum Request { /// no inputstream.adaptive needed. Slower than `resolve` but always /// gives a working stream. ResolvePlay { id: String }, + /// DASH-ready resolve: returns rustypipe's full `video_only_streams` + /// + `audio_streams` arrays so the addon can build a DASH MPD with + /// all quality rungs and feed it to inputstream.adaptive. Unlocks + /// 1080p+ via H.264 hardware decode on RPi. + ResolveDash { id: String }, Rip { id: String, dest_dir: String }, Sponsorblock { id: String, @@ -131,6 +136,10 @@ async fn handle_line(line: &str) -> Response { Ok(v) => Response::ok(v), Err(e) => e.into(), }, + Request::ResolveDash { id } => match resolve::resolve_dash(&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 e05150b..98da975 100644 --- a/sidecar/crates/torttube-sidecar/src/resolve.rs +++ b/sidecar/crates/torttube-sidecar/src/resolve.rs @@ -5,6 +5,38 @@ use serde_json::Value; use crate::{run_yt_dlp, HandlerError}; +/// DASH-ready resolve: returns rustypipe's full `video_only_streams` + +/// `audio_streams` arrays + `details`. The Python addon builds an MPD +/// from these and hands it to inputstream.adaptive — unlocks 1080p+ via +/// H.264 hardware decode on the RPi (vs the 360p ceiling on progressive). +pub(crate) async fn resolve_dash(id: &str) -> Result { + use rustypipe::client::RustyPipe; + + let rp = RustyPipe::new(); + let player = rp + .query() + .player(id) + .await + .map_err(|e| classify_rustypipe_error(&e))?; + + let details_json = serde_json::to_value(&player.details) + .map_err(|e| HandlerError::Internal(format!("serialize details: {e}")))?; + let video_streams = serde_json::to_value(&player.video_only_streams) + .map_err(|e| HandlerError::Internal(format!("serialize video_only_streams: {e}")))?; + let audio_streams = serde_json::to_value(&player.audio_streams) + .map_err(|e| HandlerError::Internal(format!("serialize audio_streams: {e}")))?; + + tracing::info!(id, "resolve_dash ok via rustypipe"); + + Ok(serde_json::json!({ + "source": "rustypipe", + "details": details_json, + "video_only_streams": video_streams, + "audio_streams": audio_streams, + "expires_in_seconds": player.expires_in_seconds, + })) +} + /// 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.