M7 — DASH partial: MPD serves OK, segment alignment still WIP

Big strides today:
- Sidecar resolve_dash op works (verified live on Pi)
- MPD builder generates valid MPEG-DASH on-demand manifest with
  H.264 720p/1080p video reps + best AAC audio rep
- ThreadingHTTPServer serves the MPD over the LAN IP (not 127.0.0.1
  — curl in Kodi 20's inputstream.adaptive can't open that)
- inputstream.adaptive PARSES our manifest cleanly: 'Successfully
  parsed manifest file (Periods: 1, Streams in first period: 2)'
- Segment GETs work once we set stream_headers with User-Agent
  + Origin + Referer (otherwise googlevideo 403s the audio segments)

Remaining issue:
- Audio drifts -25s → -44s behind video within seconds of playback
  start. inputstream.adaptive needs explicit SegmentTimeline timing
  derived from each rep's sidx box to stay aligned. Plugin.video.youtube
  does this; we'd need to fetch+parse sidx ourselves or fork their
  MPD-builder. Documented as M7-blocking + upstream PR candidate.

Default remains the stable yt-dlp progressive 360p path. DASH is
behind dash_enabled setting OR a dash.on marker file in addon_data.
Toggle on via:
  ssh <kodi> 'touch /storage/.kodi/userdata/addon_data/plugin.video.torttube/dash.on'
Toggle off:
  ssh <kodi> 'rm /storage/.kodi/userdata/addon_data/plugin.video.torttube/dash.on'

Addon v0.0.10. docs/upstream.md has the full segment-timing analysis.
This commit is contained in:
Kayos 2026-05-23 11:46:56 -07:00
parent a784321759
commit 0a289fea3a
4 changed files with 117 additions and 31 deletions

View file

@ -86,21 +86,34 @@
progressive) is deprecated by YouTube; higher quality needs DASH progressive) is deprecated by YouTube; higher quality needs DASH
manifest generation. Code path exists, gated behind `TORTTUBE_DASH=1`. manifest generation. Code path exists, gated behind `TORTTUBE_DASH=1`.
## M7 — DASH / HD playback [WIP] ## M7 — DASH / HD playback [WIP — close on segment timing]
- [x] sidecar `resolve_dash` op returns rustypipe's full - [x] sidecar `resolve_dash` op returns rustypipe's full
`video_only_streams` + `audio_streams` arrays (16+ representations) `video_only_streams` + `audio_streams` arrays (16+ representations)
- [x] addon `_build_dash_mpd` constructs valid on-demand MPD with H.264 - [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, video reps (filtered 720p-1080p so inputstream.adaptive's conservative
proper `SegmentBase indexRange` + `Initialization range`) chooser doesn't land below HD) + best AAC audio rep, XML-escaped URLs,
- [ ] **serve MPD over localhost HTTP** — inputstream.adaptive's libcurl proper `SegmentBase indexRange` + `Initialization range`
can't open `file://` URLs. ThreadingHTTPServer + Player monitor - [x] **serve MPD over localhost HTTP** — ThreadingHTTPServer binds to LAN IP
lifecycle is sketched but caused Kodi's "two concurrent busydialogs" (not 127.0.0.1 — that fails curl auth in Kodi 20). Lifecycle: server
fatal during rapid retries. Needs more work on lifecycle + retry stays up until SponsorBlockMonitor exits, then `server.shutdown()` in
backoff before re-enabling. finally block. **Verified live: inputstream.adaptive parses the MPD
- [ ] handle session-cookie / poToken inheritance — googlevideo URLs may cleanly**.
need the same client signature across MPD + segment fetches - [x] `inputstream.adaptive.stream_headers` set with `User-Agent`, `Origin`,
- [ ] graceful fallback to progressive if MPD load fails mid-playback and `Referer` matching what rustypipe / yt-dlp use when minting the
URL — fixes the 403 Forbidden from googlevideo on segment GETs
- [ ] **segment timing alignment** — audio drifts -25 → -44s behind video
within seconds. Need explicit `<SegmentTimeline>` per-segment timing
derived from each representation's sidx box, OR `presentationTimeOffset`.
Plugin.video.youtube derives these from sidx — we'd need to fetch + parse
that. See `docs/upstream.md` for the upstream PR target.
- [x] graceful fallback to progressive — if the DASH path returns no MPD
bytes (rustypipe error, no H.264 reps, etc.) the addon falls through
to yt-dlp progressive 360p
- [x] **gated behind `dash_enabled` setting** (default off) and a
`dash.on` marker file in addon_data (workaround for Kodi's settings
cache, useful for ad-hoc testing). Stable 360p path remains the
default until segment timing is solved.
## Upstream PR work (parallel lane — every bug evaluated for "fix it upstream?") ## Upstream PR work (parallel lane — every bug evaluated for "fix it upstream?")

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.video.torttube" <addon id="plugin.video.torttube"
name="torttube" name="torttube"
version="0.0.9" version="0.0.10"
provider-name="Sulkta-Coop"> provider-name="Sulkta-Coop">
<requires> <requires>
<import addon="xbmc.python" version="3.0.0"/> <import addon="xbmc.python" version="3.0.0"/>

View file

@ -22,7 +22,7 @@ import http.server
import json import json
import os import os
import re import re
import socketserver import socket
import subprocess import subprocess
import sys import sys
import threading import threading
@ -112,6 +112,20 @@ def _resolved_listitem(stream_url: str, title: str | None) -> xbmcgui.ListItem:
if ".mpd" in stream_url: if ".mpd" in stream_url:
li.setProperty("inputstream", "inputstream.adaptive") li.setProperty("inputstream", "inputstream.adaptive")
li.setProperty("inputstream.adaptive.manifest_type", "mpd") li.setProperty("inputstream.adaptive.manifest_type", "mpd")
# googlevideo rejects segment GETs that don't carry an Origin/Referer
# from www.youtube.com — 403 Forbidden otherwise. Same Mozilla UA
# rustypipe / yt-dlp use when minting the URL.
ytdl_ua = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
seg_headers = (
f"User-Agent={ytdl_ua}"
"&Origin=https://www.youtube.com"
"&Referer=https://www.youtube.com/"
)
li.setProperty("inputstream.adaptive.stream_headers", seg_headers)
li.setProperty("inputstream.adaptive.manifest_headers", seg_headers)
elif ".m3u8" in stream_url: elif ".m3u8" in stream_url:
li.setProperty("inputstream", "inputstream.adaptive") li.setProperty("inputstream", "inputstream.adaptive")
li.setProperty("inputstream.adaptive.manifest_type", "hls") li.setProperty("inputstream.adaptive.manifest_type", "hls")
@ -138,24 +152,40 @@ class _MpdHandler(http.server.BaseHTTPRequestHandler):
return return
def _start_mpd_server(mpd_bytes: bytes) -> tuple[str, http.server.HTTPServer]: def _lan_ip() -> str:
"""Spin up a one-shot localhost HTTP server that serves `mpd_bytes` at any """Detect this host's LAN IP by opening a UDP socket toward an external
path. Returns (url, server). Caller is responsible for `server.shutdown()` address (no packets actually sent just lets the kernel pick the source IP).
once playback ends.""" plugin.video.youtube uses this same trick because inputstream.adaptive's
libcurl in Kodi 20 has trouble fetching from `127.0.0.1` reliably."""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except Exception:
return "127.0.0.1"
finally:
s.close()
def _start_mpd_server(mpd_bytes: bytes) -> tuple[str, http.server.HTTPServer]:
"""Spin up a one-shot HTTP server that serves `mpd_bytes` at any path.
Binds to the LAN IP so inputstream.adaptive can fetch via the same code
path it uses for real network URLs. 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( handler_cls = type(
"_MpdHandlerInstance", "_MpdHandlerInstance",
(_MpdHandler,), (_MpdHandler,),
{"mpd_bytes": mpd_bytes}, {"mpd_bytes": mpd_bytes},
) )
# Port 0 → kernel picks free port. ThreadingHTTPServer so multiple GETs lan_ip = _lan_ip()
# (the manifest + range requests, if any) don't serialize. # Bind to all interfaces so the LAN-IP URL also routes through (otherwise
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler_cls) # connect-by-IP on the same host can return 'connection refused' on some
# kernel/firewall configs). Port 0 → kernel picks free port.
server = http.server.ThreadingHTTPServer(("0.0.0.0", 0), handler_cls)
threading.Thread(target=server.serve_forever, daemon=True).start() threading.Thread(target=server.serve_forever, daemon=True).start()
port = server.server_address[1] port = server.server_address[1]
url = f"http://127.0.0.1:{port}/manifest.mpd" url = f"http://{lan_ip}:{port}/manifest.mpd"
_log(f"MPD HTTP server up on {url}") _log(f"MPD HTTP server up on {url}")
return url, server return url, server
@ -189,16 +219,30 @@ def _build_dash_mpd(
if duration_s <= 0: if duration_s <= 0:
return None return None
# Filter video: H.264 only (avc1.*), height <= 1080 (RPi 4 H.264 ceiling). # Filter video: H.264 only (avc1.*), 720p <= height <= 1080p.
# Floor at 720p so inputstream.adaptive's conservative-start chooser doesn't
# land us on a low-quality rep first. Ceiling at 1080p because that's the
# RPi 4's H.264 hardware-decode sweet spot.
h264 = [ h264 = [
s s
for s in video_streams for s in video_streams
if "avc1" in (s.get("codec") or s.get("mime", "")) if "avc1" in (s.get("codec") or s.get("mime", ""))
and (s.get("height") or 0) <= 1080 and 720 <= (s.get("height") or 0) <= 1080
and s.get("init_range") and s.get("init_range")
and s.get("index_range") and s.get("index_range")
and s.get("url") and s.get("url")
] ]
if not h264:
# Fallback: drop the 720p floor if the video has no HD streams at all.
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: if not h264:
return None return None
@ -314,14 +358,26 @@ def _play(yt_id: str) -> None:
mpd_bytes: bytes | None = None mpd_bytes: bytes | None = None
dash_resp: dict[str, Any] = {} dash_resp: dict[str, Any] = {}
# DASH path: read setting first; fall back to env-var; OR honor a magic file
# at /storage/.kodi/userdata/addon_data/plugin.video.torttube/dash.on as a
# last-ditch trigger that doesn't depend on Kodi's settings cache.
dash_enabled = False dash_enabled = False
try: try:
dash_enabled = ADDON.getSettingBool("dash_enabled") dash_enabled = ADDON.getSettingBool("dash_enabled")
except Exception: except Exception:
# Setting might not exist on older configs; treat as off.
pass pass
if dash_enabled or os.environ.get("TORTTUBE_DASH") == "1": env_dash = os.environ.get("TORTTUBE_DASH") == "1"
addon_data = ""
try:
import xbmcvfs
addon_data = xbmcvfs.translatePath("special://profile/addon_data/plugin.video.torttube/")
except Exception:
pass
file_dash = bool(addon_data) and os.path.isfile(os.path.join(addon_data, "dash.on"))
_log(f"play id={yt_id} dash_enabled={dash_enabled} env_dash={env_dash} file_dash={file_dash}")
if dash_enabled or env_dash or file_dash:
mpd_bytes, dash_resp = _try_dash(yt_id) mpd_bytes, dash_resp = _try_dash(yt_id)
_log(f"_try_dash returned mpd_bytes={'<%d bytes>' % len(mpd_bytes) if mpd_bytes else None}")
if mpd_bytes: if mpd_bytes:
details = dash_resp.get("details") or {} details = dash_resp.get("details") or {}
title = details.get("name") title = details.get("name")

View file

@ -19,10 +19,27 @@ _(none yet — opens with M1 development)_
([kodi addon, xbmc/inputstream.adaptive on github](https://github.com/xbmc/inputstream.adaptive)) ([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 Hit during torttube M7 DASH work 2026-05-23. Loading an MPD from a local
filesystem path via `file:///storage/.kodi/temp/torttube/<id>.mpd` fails filesystem path via `file:///storage/.kodi/temp/torttube/<id>.mpd` fails
with `CURLOpen returned an error, download failed`. Common workaround with `CURLOpen returned an error, download failed`. **Workaround
is a localhost HTTP server (plugin.video.youtube does this). Worth confirmed**: bind a localhost ThreadingHTTPServer on the LAN IP (not
filing an enhancement request to either accept `file://` or document 127.0.0.1 — that also fails in some configs) and pass `http://<lan-ip>:<port>`
the recommended pattern. to setResolvedUrl. plugin.video.youtube uses this pattern via a long-lived
service addon. Worth filing an enhancement to either accept `file://` or
document the LAN-IP HTTP-server pattern in the inputstream.adaptive docs.
- **DASH segment timing for googlevideo SegmentBase URLs** — Hit 2026-05-23.
My MPD with one Representation per video/audio (using SegmentBase with
indexRange to the sidx box of the static MP4) parses cleanly and segments
fetch correctly once the `User-Agent=Mozilla/...&Origin=https://www.youtube.com&Referer=https://www.youtube.com/`
headers are set via `inputstream.adaptive.stream_headers`. BUT audio drifts
badly behind video (-25s growing to -44s within seconds of playback start).
Hypothesis: inputstream.adaptive needs explicit per-segment timing (via
`<SegmentTimeline><S t= d= />` entries) or `presentationTimeOffset` to
align separated audio + video streams correctly. plugin.video.youtube
derives these by parsing the sidx box of each representation. Possible
upstream PRs: (a) inputstream.adaptive should auto-derive segment timing
from sidx when SegmentBase + indexRange is present, OR (b) document the
requirement for SegmentTimeline on separated A/V. For torttube we'll need
to either parse sidx ourselves (extra HTTP HEAD + binary parse) or fork
plugin.video.youtube's MPD-builder.
- **Kodi — "Logic error due to two concurrent busydialogs" fatal** - **Kodi — "Logic error due to two concurrent busydialogs" fatal**
Reproduced during rapid back-to-back `Player.Open` calls while a Reproduced during rapid back-to-back `Player.Open` calls while a
previous play's BusyDialog was still dismissing. Log message itself previous play's BusyDialog was still dismissing. Log message itself