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

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

View file

@ -22,7 +22,7 @@ import http.server
import json
import os
import re
import socketserver
import socket
import subprocess
import sys
import threading
@ -112,6 +112,20 @@ def _resolved_listitem(stream_url: str, title: str | None) -> xbmcgui.ListItem:
if ".mpd" in stream_url:
li.setProperty("inputstream", "inputstream.adaptive")
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:
li.setProperty("inputstream", "inputstream.adaptive")
li.setProperty("inputstream.adaptive.manifest_type", "hls")
@ -138,24 +152,40 @@ class _MpdHandler(http.server.BaseHTTPRequestHandler):
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."""
def _lan_ip() -> str:
"""Detect this host's LAN IP by opening a UDP socket toward an external
address (no packets actually sent just lets the kernel pick the source IP).
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(
"_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)
lan_ip = _lan_ip()
# Bind to all interfaces so the LAN-IP URL also routes through (otherwise
# 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()
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}")
return url, server
@ -189,16 +219,30 @@ def _build_dash_mpd(
if duration_s <= 0:
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 = [
s
for s in video_streams
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("index_range")
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:
return None
@ -314,14 +358,26 @@ def _play(yt_id: str) -> None:
mpd_bytes: bytes | None = None
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
try:
dash_enabled = ADDON.getSettingBool("dash_enabled")
except Exception:
# Setting might not exist on older configs; treat as off.
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)
_log(f"_try_dash returned mpd_bytes={'<%d bytes>' % len(mpd_bytes) if mpd_bytes else None}")
if mpd_bytes:
details = dash_resp.get("details") or {}
title = details.get("name")