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:
parent
a784321759
commit
0a289fea3a
4 changed files with 117 additions and 31 deletions
|
|
@ -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?")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"/>
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue