URLs, mount paths, and LAN host bindings parameterized via env or relative paths
so the repo stands up from a clean clone anywhere. Drop cross-codebase refs
("mirrors clawdforge's pattern"), Sulkta-Coop client/merchant test fixtures,
and audit-changelog scaffolding from comments. README terser, technical content
preserved.
8.1 KiB
torttube milestones
M0 — Scaffold [current]
Sulkta-Coop/torttubeon LAN Gitea- Layout:
addon/(Python) +sidecar/(Rust workspace) - GPL-3.0 license headers
- crafting-table build target produces a static aarch64 sidecar binary
M1 — sidecar resolve (three-tier)
- reads
{"op":"resolve","id":"<yt-id>"}from stdin - Tier 1: rustypipe →
{"streams":[…],"title":"…","duration_s":N,"source":"rustypipe"} - Tier 2: on Tier-1 failure, shell out to
yt-dlp -j <url>→ same JSON shape with"source":"yt-dlp" - Tier 3: new op
{"op":"rip","id":"<yt-id>","dest":"/storage/.kodi/temp/torttube/<id>.<ext>"}invoked by addon on Tier-1+2 stream failures or 403-mid-play; yt-dlp downloads file, sidecar returns local path - typed errors for age-restricted / region-restricted / private (not panics)
- sig decoding verified against a known-good video
- DASH manifest URL or per-itag direct stream URL — whichever inputstream.adaptive prefers
M2 — SponsorBlock
{"op":"sponsorblock","id":"<yt-id>"}→ segments array- SHA-256 prefix lookup (privacy-preserving — only send first 4 hex chars)
- category filter honoured from addon settings (skip / mute / show only)
- cache per-session
M3 — Kodi addon plays one video
addon.xml+main.pyregister as video pluginmain.pyhandlesplugin://plugin.video.torttube/?action=play&id=<id>and?url=<full-url>— wired so JSON-RPCPlayer.Openfrom any LAN client (phone, HA, curl) triggers resolve + play. See docs/remote-control.md.- cross-compile sidecar for aarch64, drop into
bin/of addon dir - install + smoke on a LibreELEC RPi on the LAN
- (later) hardcoded list of 3 test videos for in-Kodi navigation
M4 — search + channel browse [PARTIAL]
- sidecar
searchop via rustypipequery().search::<VideoItem,_>() - root directory listing in Kodi addon (Search + Play by URL entries)
?action=searchaccepts inlineq=(for JSON-RPC) or prompts keyboard- result labels show title · channel · duration · view-count, with
IsPlayable=true, thumbnails, video InfoLabels - verified via JSON-RPC:
Files.GetDirectoryreturns 19+ formatted results for "linus tech tips" - channel browse →
{"op":"channel_videos","id":"…"}— verified 30 videos returned for LTT's UCXuqSBlHAE6Xw-yeJA0Tunw via JSON-RPC - context-menu entry on every result: "Go to " → channel listing
- playlist browse →
{"op":"playlist","id":"…"}— verified with LTT's "Consumer Advocacy" playlist, returns playlist metadata (name, channel, video_count) + items array - paginated results (currently capped at limit=30/50/100)
- search history
M5 — SponsorBlock skipping [DONE]
SponsorBlockMonitor(xbmc.Monitor subclass) pollsPlayer().getTime()every 0.5s after playback starts; seeks past each segment exactly once (UUID dedup); exits cleanly on abort or playback stop- toast on skip (
SponsorBlock — Skipped <category> (<duration>s)) - only segments with
actionType: skipare honored (mute/etc. ignored for now — adding those is a multi-pass M5+ pass) - category toggles + skip-counter in settings.xml (deferred — defaults currently: sponsor, selfpromo, interaction skipped automatically)
M6 — install + cross-compile [DONE]
- cross-compile sidecar for aarch64-musl static via throwaway
messense/rust-musl-cross:aarch64-muslcontainer. 6.2MB stripped static binary. Builds clean fromscripts/build-addon-zip.sh. - bundle yt-dlp's universal Python zipapp (~3MB, not the PyInstaller
binary — that breaks on LibreELEC's armhf userspace because the
aarch64 dynamic loader doesn't exist; Python zipapp runs on
/usr/bin/python3which is always there). - zip layout matches Kodi "install from zip" expectations
- addon.zip dropped at
dist/under the repo root - install + smoke recipe documented at
docs/install.md - installed on a LibreELEC Pi via SSH +
systemctl restart kodi+Addons.SetAddonEnabled - JSON-RPC
Player.Opensmoke verified — Rick Astley played end-to-end with audio + video synced (yt-dlp-f best[ext=mp4]/bestpicked format 18, 360p H.264+AAC progressive) - armv7 build for older Pis (deferred — current TV is aarch64-capable and my static sidecar runs on it even though userspace is armhf)
- 720p+ playback (DASH WIP — see "M7 — DASH/HD" below) — itag 22 (720p
progressive) is deprecated by YouTube; higher quality needs DASH
manifest generation. Code path exists, gated behind
TORTTUBE_DASH=1.
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 setsplugin://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_unpluggedclient — succeeds on the next client without needing the user to link an account
Verified live on a LibreELEC Pi 2026-05-23 — 1080p H.264 with synced audio, fullscreen.
Settings: prefer_pv_youtube (default true). Disable to fall through
to our native DASH/progressive paths.
M7-rejected — native DASH builder [PARKED]
- sidecar
resolve_dashop returns rustypipe's fullvideo_only_streams+audio_streamsarrays (16+ representations) - addon
_build_dash_mpdconstructs valid on-demand MPD with H.264 video reps (filtered 720p-1080p so inputstream.adaptive's conservative chooser doesn't land below HD) + best AAC audio rep, XML-escaped URLs, properSegmentBase indexRange+Initialization range - serve MPD over localhost HTTP — ThreadingHTTPServer binds to LAN IP
(not 127.0.0.1 — that fails curl auth in Kodi 20). Lifecycle: server
stays up until SponsorBlockMonitor exits, then
server.shutdown()in finally block. Verified live: inputstream.adaptive parses the MPD cleanly. inputstream.adaptive.stream_headersset withUser-Agent,Origin, andReferermatching 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, ORpresentationTimeOffset. Plugin.video.youtube derives these from sidx — we'd need to fetch + parse that. Seedocs/upstream.mdfor the upstream PR target. - 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
- gated behind
dash_enabledsetting (default off) and adash.onmarker 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?")
This isn't a separate milestone, it's a posture. Every sidecar bug we diagnose: ask "is this rustypipe / NPE / yt-dlp's bug?" — if yes, fix lands upstream too. Log every filed PR in docs/upstream.md.
Opening shortlist:
- rustypipe PR #77 — review + help land
- NPE #1357 (JDoc) — smallest credible PR, opens the relationship
- NPE #1444 (typed errors) — clean signal/contract change
- NPE #1360 (refactor link handlers, "help wanted")
- rustypipe ↔ NPE port — anything one project has that the other lacks