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:
parent
0a289fea3a
commit
9ed0aae2d0
5 changed files with 102 additions and 12 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"/>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue