M7 DONE via delegation — pv.youtube plays HD with audio

After hitting the segment-timing wall on our hand-rolled DASH MPD
(audio drifted -25s -> -44s behind video on long content), pivoted to
delegating playback to plugin.video.youtube v7.4.3 which already has
years of sidx-parsed SegmentTimeline + multi-client fallback work.

torttube._play() now:
  1. Tries _delegate_to_pv_youtube(yt_id) — sets a resolved URL of
     'plugin://plugin.video.youtube/play/?video_id=<id>'. Kodi
     chain-resolves to pv.youtube which builds the proper MPD and
     hands inputstream.adaptive a correctly-aligned manifest. Default.
  2. Falls back to our DASH builder (still in code, gated by
     'dash_enabled' setting + dash.on marker) if pv.youtube is absent.
  3. Falls through to yt-dlp progressive 360p as the final safety net.

When delegating, we skip our SponsorBlock monitor — pv.youtube has its
own and would double-skip otherwise.

Cobb-verified live on Livingroom Pi: LTT 'Trump Phone' (which crashed
our DASH with audio sync errors growing to -44s) now plays HD with
audio synced. 'Please sign in' message in log is from the tv_unplugged
Innertube client; pv.youtube falls back to a working client
automatically — no user account required.

Settings: prefer_pv_youtube boolean (default true). Addon v0.0.11.
Reference: https://kodi.wiki/view/Add-on:YouTube
This commit is contained in:
Kayos 2026-05-23 11:58:12 -07:00
parent 0a289fea3a
commit 9ed0aae2d0
5 changed files with 102 additions and 12 deletions

View file

@ -86,7 +86,30 @@
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 — close on segment timing] ## M7 — HD playback [DONE via delegation]
**Strategy pivoted 2026-05-23.** After hitting a wall on segment-timing
alignment in our hand-rolled DASH MPD (audio drifted -25s → -44s behind
video on long-form content), pivoted to delegating playback to the
already-installed `plugin.video.youtube` v7.4.3. They already have
years of sidx-parsed-SegmentTimeline + multi-client fallback work.
Our delegation:
- `_play()` first calls `_delegate_to_pv_youtube(yt_id)` which sets
`plugin://plugin.video.youtube/play/?video_id=<id>` via setResolvedUrl
- Kodi chain-resolves to pv.youtube, which builds the proper MPD
- inputstream.adaptive plays 1080p H.264 cleanly, audio in sync
- Multi-client Innertube fallback handles "Please sign in" rejection
on the `tv_unplugged` client — succeeds on the next client without
needing the user to link an account
Verified live on Livingroom Pi 2026-05-23 — LTT 'Trump Phone' video
played at 1080p with audio, fullscreen.
Settings: `prefer_pv_youtube` (default true). Disable to fall through
to our native DASH/progressive paths.
## M7-rejected — native DASH builder [PARKED]
- [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)

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.10" version="0.0.11"
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

@ -152,6 +152,16 @@ class _MpdHandler(http.server.BaseHTTPRequestHandler):
return return
def _has_setting(setting_id: str) -> bool:
"""Best-effort check that a setting exists in resources/settings.xml.
Returns False if the lookup throws (older builds, missing schema)."""
try:
ADDON.getSetting(setting_id)
return True
except Exception:
return False
def _lan_ip() -> str: def _lan_ip() -> str:
"""Detect this host's LAN IP by opening a UDP socket toward an external """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). address (no packets actually sent just lets the kernel pick the source IP).
@ -295,9 +305,14 @@ def _build_dash_mpd(
parts.append( parts.append(
f' <AdaptationSet mimeType="{a_mime}" startWithSAP="1" segmentAlignment="true">' f' <AdaptationSet mimeType="{a_mime}" startWithSAP="1" segmentAlignment="true">'
) )
# NOTE: NOT setting audioSamplingRate here on purpose. rustypipe doesn't
# expose the sample rate, and hard-coding 44100 caused a ~9% playback-rate
# mismatch (= growing audio-vs-video desync) for content at 48000 Hz.
# inputstream.adaptive reads the actual rate from the audio init segment's
# mdhd box when this attribute is omitted, which is correct for any source.
parts.append( parts.append(
f' <Representation id="{a["itag"]}" mimeType="{a_mime}" codecs="{a_codec}"' f' <Representation id="{a["itag"]}" mimeType="{a_mime}" codecs="{a_codec}"'
f' bandwidth="{a.get("bitrate", 0)}" audioSamplingRate="44100" startWithSAP="1">' f' bandwidth="{a.get("bitrate", 0)}" startWithSAP="1">'
) )
parts.append( parts.append(
' <AudioChannelConfiguration' ' <AudioChannelConfiguration'
@ -344,18 +359,55 @@ def _try_dash(yt_id: str) -> tuple[bytes | None, dict[str, Any]]:
return mpd.encode("utf-8"), resp return mpd.encode("utf-8"), resp
def _play(yt_id: str) -> None: def _delegate_to_pv_youtube(yt_id: str) -> bool:
"""Resolve via DASH (rustypipe, up to 1080p H.264) with progressive """Hand playback off to plugin.video.youtube via its play URL. They have
yt-dlp fallback (360p). the proper SegmentTimeline-aware MPD construction (sidx-parsed) that
unlocks HD without the audio-sync drift our naive MPD has. Returns True
if delegation succeeded (Kodi will chain-resolve)."""
if not _pv_youtube_installed():
return False
target = f"plugin://plugin.video.youtube/play/?video_id={yt_id}"
_log(f"delegating playback to plugin.video.youtube: {target}")
li = xbmcgui.ListItem(label=yt_id)
li.setPath(target)
li.setProperty("IsPlayable", "true")
xbmcplugin.setResolvedUrl(_HANDLE, True, li)
return True
DASH path is gated behind TORTTUBE_DASH=1 while the manifest-serving
HTTP server + inputstream.adaptive integration is being stabilized def _pv_youtube_installed() -> bool:
(file:// URLs don't work, and rapid retries via a port-0 HTTP server """Check whether plugin.video.youtube is installed + enabled. We don't
can trigger Kodi's 'two concurrent busydialogs' fatal). Default OFF enable it ourselves if the user removed it, we fall back to our own
until that's solid — progressive yt-dlp path is reliable. paths."""
try:
return bool(xbmc.getCondVisibility("System.HasAddon(plugin.video.youtube)"))
except Exception:
return False
def _play(yt_id: str) -> None:
"""Resolve playback in order of preference:
1. plugin.video.youtube delegation HD via their proven DASH MPD with
sidx-parsed SegmentTimeline. Default, when available.
2. Our DASH path only if `dash_enabled` setting / dash.on marker / env
are set (WIP, partial see M7 milestone).
3. yt-dlp progressive last-resort 360p, always works.
SponsorBlock attaches regardless of which path we took.
""" """
_log(f"play id={yt_id}") _log(f"play id={yt_id}")
# Tier 0: delegate to plugin.video.youtube if installed. Don't run our
# SponsorBlock monitor in this path — pv.youtube has its own and would
# double-skip if both fire.
use_pv_youtube = True
try:
use_pv_youtube = ADDON.getSettingBool("prefer_pv_youtube")
except Exception:
# Setting not yet in Kodi's cache (settings.xml just changed) — default on.
pass
if use_pv_youtube and _delegate_to_pv_youtube(yt_id):
return
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 # DASH path: read setting first; fall back to env-var; OR honor a magic file

View file

@ -3,7 +3,10 @@
<section id="torttube"> <section id="torttube">
<category id="general" label="General"> <category id="general" label="General">
<group id="quality" label="Quality"> <group id="quality" label="Quality">
<setting id="dash_enabled" type="boolean" label="Enable DASH (up to 1080p H.264) — WIP, may not work"> <setting id="prefer_pv_youtube" type="boolean" label="Use plugin.video.youtube for playback (HD)">
<default>true</default>
</setting>
<setting id="dash_enabled" type="boolean" label="Native DASH (WIP — fallback if pv.youtube absent)">
<default>false</default> <default>false</default>
</setting> </setting>
</group> </group>

View file

@ -25,6 +25,18 @@ _(none yet — opens with M1 development)_
to setResolvedUrl. plugin.video.youtube uses this pattern via a long-lived to setResolvedUrl. plugin.video.youtube uses this pattern via a long-lived
service addon. Worth filing an enhancement to either accept `file://` or service addon. Worth filing an enhancement to either accept `file://` or
document the LAN-IP HTTP-server pattern in the inputstream.adaptive docs. document the LAN-IP HTTP-server pattern in the inputstream.adaptive docs.
- **Architecture resolution: torttube delegates playback to plugin.video.youtube**
2026-05-23. After spending an iteration on a native DASH MPD builder + HTTP
manifest server, conceded that solving segment-timing alignment correctly
(sidx-parse, SegmentTimeline emission, audio-rate detection, init-segment
presentationTimeOffset) is multiple-week work. plugin.video.youtube has
years of that work already. torttube now does what it's faster at (Rust
rustypipe search/browse, SponsorBlock skip with fewer false positives)
and hands the video_id to pv.youtube for actual playback. This is the
"stand on giants' shoulders" call rather than reinvent. Native DASH code
path stays in the addon as a fallback when pv.youtube is absent (or as a
testbed for someday revisiting). Reference: https://kodi.wiki/view/Add-on:YouTube
- **DASH segment timing for googlevideo SegmentBase URLs** — Hit 2026-05-23. - **DASH segment timing for googlevideo SegmentBase URLs** — Hit 2026-05-23.
My MPD with one Representation per video/audio (using SegmentBase with My MPD with one Representation per video/audio (using SegmentBase with
indexRange to the sidx box of the static MP4) parses cleanly and segments indexRange to the sidx box of the static MP4) parses cleanly and segments