DASH HD playback — WIP behind TORTTUBE_DASH=1 + upstream notes
Sidecar resolve_dash op shipped — returns rustypipe's full video_only_streams + audio_streams (16+ representations for NGGYU, from 360p H.264 through 4K AV1). Addon _build_dash_mpd assembles a valid on-demand MPEG-DASH manifest filtered to H.264 ≤1080p + best AAC audio. Two unblocked-by-WIP issues surfaced during integration: - inputstream.adaptive's libcurl can't open file:// URLs (logged in docs/upstream.md as an enhancement-target). - Rapid Player.Open retries can trigger Kodi's 'two concurrent busydialogs' fatal exit; need lifecycle hardening before re-enabling. Pivoted to localhost HTTP-server serving (ThreadingHTTPServer on a port-0 socket, MPD bytes captured in a per-instance handler subclass). Lifecycle: server.shutdown() runs in a finally block after the SponsorBlockMonitor watcher exits. Works in isolation but Kodi crashed under rapid retry conditions — needs more testing. For v0.0.5: DASH path is gated behind TORTTUBE_DASH=1 env var; default falls through to the stable yt-dlp progressive 360p path that's been verified live. M7 milestone added to track the remaining work; PRs to inputstream.adaptive + Kodi candidates logged in docs/upstream.md.
This commit is contained in:
parent
6d07ed16fa
commit
d0316ba311
6 changed files with 329 additions and 14 deletions
|
|
@ -71,8 +71,25 @@
|
||||||
picked format 18, 360p H.264+AAC progressive)
|
picked format 18, 360p H.264+AAC progressive)
|
||||||
- [ ] armv7 build for older Pis (deferred — current TV is aarch64-capable
|
- [ ] armv7 build for older Pis (deferred — current TV is aarch64-capable
|
||||||
and my static sidecar runs on it even though userspace is armhf)
|
and my static sidecar runs on it even though userspace is armhf)
|
||||||
- [ ] 720p+ playback (deferred to M5+) — itag 22 (720p progressive) is
|
- [ ] 720p+ playback (DASH WIP — see "M7 — DASH/HD" below) — itag 22 (720p
|
||||||
deprecated by YouTube; higher quality needs DASH manifest generation
|
progressive) is deprecated by YouTube; higher quality needs DASH
|
||||||
|
manifest generation. Code path exists, gated behind `TORTTUBE_DASH=1`.
|
||||||
|
|
||||||
|
## M7 — DASH / HD playback [WIP]
|
||||||
|
|
||||||
|
- [x] sidecar `resolve_dash` op returns rustypipe's full
|
||||||
|
`video_only_streams` + `audio_streams` arrays (16+ representations)
|
||||||
|
- [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,
|
||||||
|
proper `SegmentBase indexRange` + `Initialization range`)
|
||||||
|
- [ ] **serve MPD over localhost HTTP** — inputstream.adaptive's libcurl
|
||||||
|
can't open `file://` URLs. ThreadingHTTPServer + Player monitor
|
||||||
|
lifecycle is sketched but caused Kodi's "two concurrent busydialogs"
|
||||||
|
fatal during rapid retries. Needs more work on lifecycle + retry
|
||||||
|
backoff before re-enabling.
|
||||||
|
- [ ] handle session-cookie / poToken inheritance — googlevideo URLs may
|
||||||
|
need the same client signature across MPD + segment fetches
|
||||||
|
- [ ] graceful fallback to progressive if MPD load fails mid-playback
|
||||||
|
|
||||||
## 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.2"
|
version="0.0.5"
|
||||||
provider-name="Sulkta">
|
provider-name="Sulkta">
|
||||||
<requires>
|
<requires>
|
||||||
<import addon="xbmc.python" version="3.0.0"/>
|
<import addon="xbmc.python" version="3.0.0"/>
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,17 @@ That's how Android / phone / "send to TV" flows hand off — Kodi already
|
||||||
exposes the endpoint, we just need to register the plugin URL.
|
exposes the endpoint, we just need to register the plugin URL.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import http.server
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import socketserver
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import parse_qsl, urlparse
|
from urllib.parse import parse_qsl, urlparse
|
||||||
|
from xml.sax.saxutils import escape as xml_escape
|
||||||
|
|
||||||
import xbmc
|
import xbmc
|
||||||
import xbmcaddon
|
import xbmcaddon
|
||||||
|
|
@ -104,23 +108,235 @@ def _resolved_listitem(stream_url: str, title: str | None) -> xbmcgui.ListItem:
|
||||||
li = xbmcgui.ListItem(label=title or "torttube")
|
li = xbmcgui.ListItem(label=title or "torttube")
|
||||||
li.setPath(stream_url)
|
li.setPath(stream_url)
|
||||||
li.setProperty("IsPlayable", "true")
|
li.setProperty("IsPlayable", "true")
|
||||||
# Tell inputstream.adaptive to handle DASH/HLS if the URL looks like a manifest.
|
# Tell inputstream.adaptive to handle DASH/HLS based on URL path/suffix.
|
||||||
if stream_url.endswith(".mpd"):
|
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")
|
||||||
elif stream_url.endswith(".m3u8"):
|
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")
|
||||||
return li
|
return li
|
||||||
|
|
||||||
|
|
||||||
def _play(yt_id: str) -> None:
|
class _MpdHandler(http.server.BaseHTTPRequestHandler):
|
||||||
"""Resolve via sidecar (yt-dlp combined-format path), hand URL to Kodi."""
|
"""Serves the per-video MPD file. The bytes come from the closure-captured
|
||||||
_log(f"play id={yt_id}")
|
`_MPD_BYTES` so the server can outlive the temp file if the addon decides
|
||||||
|
to clean up early. One handler per HTTPServer instance."""
|
||||||
|
|
||||||
|
mpd_bytes: bytes = b""
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802 — http.server convention
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/dash+xml")
|
||||||
|
self.send_header("Content-Length", str(len(self.mpd_bytes)))
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(self.mpd_bytes)
|
||||||
|
|
||||||
|
def log_message(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
# Silence the default request log — Kodi's log is verbose enough.
|
||||||
|
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."""
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
|
port = server.server_address[1]
|
||||||
|
url = f"http://127.0.0.1:{port}/manifest.mpd"
|
||||||
|
_log(f"MPD HTTP server up on {url}")
|
||||||
|
return url, server
|
||||||
|
|
||||||
|
|
||||||
|
_MIME_CODEC_RE = re.compile(r'codecs="([^"]+)"')
|
||||||
|
|
||||||
|
|
||||||
|
def _codec_from_mime(stream: dict[str, Any]) -> str:
|
||||||
|
"""Extract the full DASH codec string (avc1.4d4015 etc) from the mime field.
|
||||||
|
|
||||||
|
rustypipe gives `codec: "avc1"` (short form) but DASH MPDs need the full
|
||||||
|
profile/level identifier from `mime: 'video/mp4; codecs="avc1.4d4015"'`.
|
||||||
|
"""
|
||||||
|
mime = stream.get("mime", "")
|
||||||
|
m = _MIME_CODEC_RE.search(mime)
|
||||||
|
return m.group(1) if m else (stream.get("codec") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_dash_mpd(
|
||||||
|
details: dict[str, Any],
|
||||||
|
video_streams: list[dict[str, Any]],
|
||||||
|
audio_streams: list[dict[str, Any]],
|
||||||
|
) -> str | None:
|
||||||
|
"""Build a static MPEG-DASH on-demand manifest from rustypipe's stream data.
|
||||||
|
|
||||||
|
Picks H.264 (avc1) video streams up to 1080p — guaranteed to play on the
|
||||||
|
RPi 4's hardware H.264 decoder. Picks the best AAC (mp4a) audio stream.
|
||||||
|
Returns the MPD XML, or None if no compatible streams were found.
|
||||||
|
"""
|
||||||
|
duration_s = float(details.get("duration") or 0)
|
||||||
|
if duration_s <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Filter video: H.264 only (avc1.*), height <= 1080 (RPi 4 H.264 ceiling).
|
||||||
|
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
|
||||||
|
|
||||||
|
# Filter audio: AAC preferred, Opus fallback. Pick highest-bitrate of preferred codec.
|
||||||
|
aac = [s for s in audio_streams if "mp4a" in (s.get("codec") or s.get("mime", ""))]
|
||||||
|
opus = [s for s in audio_streams if "opus" in (s.get("codec") or s.get("mime", ""))]
|
||||||
|
audio_pool = aac or opus
|
||||||
|
if not audio_pool:
|
||||||
|
return None
|
||||||
|
best_audio = max(audio_pool, key=lambda s: s.get("bitrate") or 0)
|
||||||
|
if not (best_audio.get("init_range") and best_audio.get("index_range") and best_audio.get("url")):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Sort video high → low quality so inputstream.adaptive's default-best picks the top.
|
||||||
|
h264.sort(key=lambda s: (s.get("height") or 0, s.get("bitrate") or 0), reverse=True)
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="static"',
|
||||||
|
f' mediaPresentationDuration="PT{duration_s:.3f}S" minBufferTime="PT1.5S"',
|
||||||
|
' profiles="urn:mpeg:dash:profile:isoff-on-demand:2011">',
|
||||||
|
" <Period>",
|
||||||
|
' <AdaptationSet mimeType="video/mp4" startWithSAP="1" segmentAlignment="true">',
|
||||||
|
]
|
||||||
|
for v in h264:
|
||||||
|
codec = _codec_from_mime(v) or "avc1.4d401f"
|
||||||
|
ir = v["index_range"]
|
||||||
|
init = v["init_range"]
|
||||||
|
parts.append(
|
||||||
|
f' <Representation id="{v["itag"]}" mimeType="video/mp4" codecs="{codec}"'
|
||||||
|
f' width="{v.get("width", 0)}" height="{v.get("height", 0)}"'
|
||||||
|
f' bandwidth="{v.get("bitrate", 0)}" frameRate="{int(v.get("fps") or 30)}"'
|
||||||
|
f' startWithSAP="1">'
|
||||||
|
)
|
||||||
|
parts.append(f" <BaseURL>{xml_escape(v['url'])}</BaseURL>")
|
||||||
|
parts.append(
|
||||||
|
f' <SegmentBase indexRange="{ir["start"]}-{ir["end"]}" indexRangeExact="true">'
|
||||||
|
)
|
||||||
|
parts.append(f' <Initialization range="{init["start"]}-{init["end"]}"/>')
|
||||||
|
parts.append(" </SegmentBase>")
|
||||||
|
parts.append(" </Representation>")
|
||||||
|
parts.append(" </AdaptationSet>")
|
||||||
|
|
||||||
|
# Audio adaptation set.
|
||||||
|
a = best_audio
|
||||||
|
a_codec = _codec_from_mime(a) or ("mp4a.40.2" if "mp4a" in (a.get("codec") or "") else "opus")
|
||||||
|
a_mime = "audio/mp4" if "mp4a" in a_codec else "audio/webm"
|
||||||
|
a_ir = a["index_range"]
|
||||||
|
a_init = a["init_range"]
|
||||||
|
parts.append(
|
||||||
|
f' <AdaptationSet mimeType="{a_mime}" startWithSAP="1" segmentAlignment="true">'
|
||||||
|
)
|
||||||
|
parts.append(
|
||||||
|
f' <Representation id="{a["itag"]}" mimeType="{a_mime}" codecs="{a_codec}"'
|
||||||
|
f' bandwidth="{a.get("bitrate", 0)}" audioSamplingRate="44100" startWithSAP="1">'
|
||||||
|
)
|
||||||
|
parts.append(
|
||||||
|
' <AudioChannelConfiguration'
|
||||||
|
' schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011"'
|
||||||
|
f' value="{a.get("channels", 2)}"/>'
|
||||||
|
)
|
||||||
|
parts.append(f" <BaseURL>{xml_escape(a['url'])}</BaseURL>")
|
||||||
|
parts.append(
|
||||||
|
f' <SegmentBase indexRange="{a_ir["start"]}-{a_ir["end"]}" indexRangeExact="true">'
|
||||||
|
)
|
||||||
|
parts.append(f' <Initialization range="{a_init["start"]}-{a_init["end"]}"/>')
|
||||||
|
parts.append(" </SegmentBase>")
|
||||||
|
parts.append(" </Representation>")
|
||||||
|
parts.append(" </AdaptationSet>")
|
||||||
|
parts.append(" </Period>")
|
||||||
|
parts.append("</MPD>")
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _try_dash(yt_id: str) -> tuple[bytes | None, dict[str, Any]]:
|
||||||
|
"""Resolve via rustypipe + build DASH MPD. Returns (mpd_bytes, resp).
|
||||||
|
|
||||||
|
On any failure returns (None, resp) so the caller can fall back to
|
||||||
|
the progressive path. We serve the MPD over localhost HTTP because
|
||||||
|
inputstream.adaptive's libcurl can't open file:// URLs.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
resp = _call_sidecar({"op": "resolve_dash", "id": yt_id}, timeout_s=30)
|
||||||
|
except Exception as e:
|
||||||
|
_log(f"resolve_dash failed (will fall back): {e}", xbmc.LOGWARNING)
|
||||||
|
return None, {}
|
||||||
|
if not resp.get("ok"):
|
||||||
|
return None, resp
|
||||||
|
|
||||||
|
mpd = _build_dash_mpd(
|
||||||
|
resp.get("details") or {},
|
||||||
|
resp.get("video_only_streams") or [],
|
||||||
|
resp.get("audio_streams") or [],
|
||||||
|
)
|
||||||
|
if not mpd:
|
||||||
|
_log("DASH build returned no compatible streams; falling back to progressive")
|
||||||
|
return None, resp
|
||||||
|
return mpd.encode("utf-8"), resp
|
||||||
|
|
||||||
|
|
||||||
|
def _play(yt_id: str) -> None:
|
||||||
|
"""Resolve via DASH (rustypipe, up to 1080p H.264) with progressive
|
||||||
|
yt-dlp fallback (360p).
|
||||||
|
|
||||||
|
DASH path is gated behind TORTTUBE_DASH=1 while the manifest-serving
|
||||||
|
HTTP server + inputstream.adaptive integration is being stabilized
|
||||||
|
(file:// URLs don't work, and rapid retries via a port-0 HTTP server
|
||||||
|
can trigger Kodi's 'two concurrent busydialogs' fatal). Default OFF
|
||||||
|
until that's solid — progressive yt-dlp path is reliable.
|
||||||
|
"""
|
||||||
|
_log(f"play id={yt_id}")
|
||||||
|
|
||||||
|
mpd_bytes: bytes | None = None
|
||||||
|
dash_resp: dict[str, Any] = {}
|
||||||
|
if os.environ.get("TORTTUBE_DASH") == "1":
|
||||||
|
mpd_bytes, dash_resp = _try_dash(yt_id)
|
||||||
|
if mpd_bytes:
|
||||||
|
details = dash_resp.get("details") or {}
|
||||||
|
title = details.get("name")
|
||||||
|
mpd_url, server = _start_mpd_server(mpd_bytes)
|
||||||
|
_log(f"resolved via rustypipe DASH, serving manifest at {mpd_url}")
|
||||||
|
xbmcplugin.setResolvedUrl(_HANDLE, True, _resolved_listitem(mpd_url, title))
|
||||||
|
try:
|
||||||
|
_attach_sponsorblock(yt_id)
|
||||||
|
finally:
|
||||||
|
# Shut down the MPD server cleanly once playback ends or aborts.
|
||||||
|
# _attach_sponsorblock blocks while playback is active.
|
||||||
|
try:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
_log("MPD HTTP server shut down")
|
||||||
|
except Exception as e:
|
||||||
|
_log(f"MPD server shutdown error: {e}", xbmc.LOGWARNING)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fallback: progressive single-URL via yt-dlp (360p).
|
||||||
try:
|
try:
|
||||||
# resolve_play returns ONE combined audio+video URL — guaranteed
|
|
||||||
# to play in Kodi without needing inputstream.adaptive/DASH.
|
|
||||||
# ~3-5s overhead vs rustypipe but reliable audio sync.
|
|
||||||
resp = _call_sidecar({"op": "resolve_play", "id": yt_id}, timeout_s=45)
|
resp = _call_sidecar({"op": "resolve_play", "id": yt_id}, timeout_s=45)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log(f"sidecar resolve_play failed: {e}", xbmc.LOGERROR)
|
_log(f"sidecar resolve_play failed: {e}", xbmc.LOGERROR)
|
||||||
|
|
@ -150,8 +366,32 @@ def _play(yt_id: str) -> None:
|
||||||
xbmcplugin.setResolvedUrl(_HANDLE, False, xbmcgui.ListItem())
|
xbmcplugin.setResolvedUrl(_HANDLE, False, xbmcgui.ListItem())
|
||||||
return
|
return
|
||||||
|
|
||||||
_log(f"resolved via {resp.get('source')}, playing")
|
_log(f"resolved via yt-dlp progressive fallback, playing")
|
||||||
xbmcplugin.setResolvedUrl(_HANDLE, True, _resolved_listitem(stream_url, title))
|
xbmcplugin.setResolvedUrl(_HANDLE, True, _resolved_listitem(stream_url, title))
|
||||||
|
_attach_sponsorblock(yt_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_sponsorblock(yt_id: str) -> None:
|
||||||
|
"""Fetch SponsorBlock segments and block on the monitor loop. Always blocks
|
||||||
|
until playback ends (or 30s if playback never starts) so the caller can
|
||||||
|
use this as a 'wait for playback to finish' signal — needed to keep the
|
||||||
|
MPD HTTP server alive throughout playback.
|
||||||
|
|
||||||
|
Non-fatal on segment fetch error.
|
||||||
|
"""
|
||||||
|
skip_segments: list[dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
sb_resp = _call_sidecar({"op": "sponsorblock", "id": yt_id}, timeout_s=8)
|
||||||
|
if sb_resp.get("ok"):
|
||||||
|
segs = sb_resp.get("segments") or []
|
||||||
|
skip_segments = [s for s in segs if s.get("actionType") == "skip"]
|
||||||
|
_log(f"sponsorblock: {len(skip_segments)} skip segments")
|
||||||
|
except Exception as e:
|
||||||
|
_log(f"sponsorblock fetch failed (non-fatal): {e}", xbmc.LOGWARNING)
|
||||||
|
|
||||||
|
# Always run the watcher even with zero segments — it doubles as the
|
||||||
|
# 'block until playback ends' signal that gates MPD-server shutdown.
|
||||||
|
SponsorBlockMonitor(skip_segments).run()
|
||||||
|
|
||||||
# SponsorBlock: fetch segments, then block on a monitor that seeks past
|
# SponsorBlock: fetch segments, then block on a monitor that seeks past
|
||||||
# each skip segment as the playhead enters it. Best-effort — failure to
|
# each skip segment as the playhead enters it. Best-effort — failure to
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,24 @@ _(none yet — opens with M1 development)_
|
||||||
|
|
||||||
| Date | Project | PR/Issue | Title | Status | Outcome |
|
| Date | Project | PR/Issue | Title | Status | Outcome |
|
||||||
|------|---------|----------|-------|--------|---------|
|
|------|---------|----------|-------|--------|---------|
|
||||||
| _empty_ | | | | | |
|
| _none yet_ | | | | | |
|
||||||
|
|
||||||
|
## Investigation notes (not yet a PR — to be evaluated)
|
||||||
|
|
||||||
|
- **inputstream.adaptive — `file://` not supported by libcurl downloader**
|
||||||
|
([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
|
||||||
|
filesystem path via `file:///storage/.kodi/temp/torttube/<id>.mpd` fails
|
||||||
|
with `CURLOpen returned an error, download failed`. Common workaround
|
||||||
|
is a localhost HTTP server (plugin.video.youtube does this). Worth
|
||||||
|
filing an enhancement request to either accept `file://` or document
|
||||||
|
the recommended pattern.
|
||||||
|
- **Kodi — "Logic error due to two concurrent busydialogs" fatal**
|
||||||
|
Reproduced during rapid back-to-back `Player.Open` calls while a
|
||||||
|
previous play's BusyDialog was still dismissing. Log message itself
|
||||||
|
says "this is a known issue", but the app HARD-exits rather than
|
||||||
|
silently dropping one of the dialogs. Look up the upstream tracker
|
||||||
|
before filing.
|
||||||
|
|
||||||
## Watching (not ours, but relevant to torttube)
|
## Watching (not ours, but relevant to torttube)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,11 @@ enum Request {
|
||||||
/// no inputstream.adaptive needed. Slower than `resolve` but always
|
/// no inputstream.adaptive needed. Slower than `resolve` but always
|
||||||
/// gives a working stream.
|
/// gives a working stream.
|
||||||
ResolvePlay { id: String },
|
ResolvePlay { id: String },
|
||||||
|
/// DASH-ready resolve: returns rustypipe's full `video_only_streams`
|
||||||
|
/// + `audio_streams` arrays so the addon can build a DASH MPD with
|
||||||
|
/// all quality rungs and feed it to inputstream.adaptive. Unlocks
|
||||||
|
/// 1080p+ via H.264 hardware decode on RPi.
|
||||||
|
ResolveDash { id: String },
|
||||||
Rip { id: String, dest_dir: String },
|
Rip { id: String, dest_dir: String },
|
||||||
Sponsorblock {
|
Sponsorblock {
|
||||||
id: String,
|
id: String,
|
||||||
|
|
@ -131,6 +136,10 @@ async fn handle_line(line: &str) -> Response {
|
||||||
Ok(v) => Response::ok(v),
|
Ok(v) => Response::ok(v),
|
||||||
Err(e) => e.into(),
|
Err(e) => e.into(),
|
||||||
},
|
},
|
||||||
|
Request::ResolveDash { id } => match resolve::resolve_dash(&id).await {
|
||||||
|
Ok(v) => Response::ok(v),
|
||||||
|
Err(e) => e.into(),
|
||||||
|
},
|
||||||
Request::Rip { id, dest_dir } => match rip::rip(&id, &dest_dir).await {
|
Request::Rip { id, dest_dir } => match rip::rip(&id, &dest_dir).await {
|
||||||
Ok(v) => Response::ok(v),
|
Ok(v) => Response::ok(v),
|
||||||
Err(e) => e.into(),
|
Err(e) => e.into(),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,38 @@ use serde_json::Value;
|
||||||
|
|
||||||
use crate::{run_yt_dlp, HandlerError};
|
use crate::{run_yt_dlp, HandlerError};
|
||||||
|
|
||||||
|
/// DASH-ready resolve: returns rustypipe's full `video_only_streams` +
|
||||||
|
/// `audio_streams` arrays + `details`. The Python addon builds an MPD
|
||||||
|
/// from these and hands it to inputstream.adaptive — unlocks 1080p+ via
|
||||||
|
/// H.264 hardware decode on the RPi (vs the 360p ceiling on progressive).
|
||||||
|
pub(crate) async fn resolve_dash(id: &str) -> Result<Value, HandlerError> {
|
||||||
|
use rustypipe::client::RustyPipe;
|
||||||
|
|
||||||
|
let rp = RustyPipe::new();
|
||||||
|
let player = rp
|
||||||
|
.query()
|
||||||
|
.player(id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| classify_rustypipe_error(&e))?;
|
||||||
|
|
||||||
|
let details_json = serde_json::to_value(&player.details)
|
||||||
|
.map_err(|e| HandlerError::Internal(format!("serialize details: {e}")))?;
|
||||||
|
let video_streams = serde_json::to_value(&player.video_only_streams)
|
||||||
|
.map_err(|e| HandlerError::Internal(format!("serialize video_only_streams: {e}")))?;
|
||||||
|
let audio_streams = serde_json::to_value(&player.audio_streams)
|
||||||
|
.map_err(|e| HandlerError::Internal(format!("serialize audio_streams: {e}")))?;
|
||||||
|
|
||||||
|
tracing::info!(id, "resolve_dash ok via rustypipe");
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"source": "rustypipe",
|
||||||
|
"details": details_json,
|
||||||
|
"video_only_streams": video_streams,
|
||||||
|
"audio_streams": audio_streams,
|
||||||
|
"expires_in_seconds": player.expires_in_seconds,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// Playback-ready single-URL resolve. Asks yt-dlp for `best[ext=mp4]/best` —
|
/// Playback-ready single-URL resolve. Asks yt-dlp for `best[ext=mp4]/best` —
|
||||||
/// a combined audio+video format that Kodi can play as a plain HTTP URL.
|
/// a combined audio+video format that Kodi can play as a plain HTTP URL.
|
||||||
/// Slower than `resolve()` (~3-5s) but guarantees a working stream.
|
/// Slower than `resolve()` (~3-5s) but guarantees a working stream.
|
||||||
|
|
|
||||||
Reference in a new issue