# torttube milestones ## M0 — Scaffold [current] - [x] `Sulkta-Coop/torttube` on LAN Gitea - [x] Layout: `addon/` (Python) + `sidecar/` (Rust workspace) - [x] GPL-3.0 license headers - [ ] crafting-table build target produces a static aarch64 sidecar binary ## M1 — sidecar resolve (three-tier) - [ ] reads `{"op":"resolve","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 ` → same JSON shape with `"source":"yt-dlp"` - [ ] **Tier 3:** new op `{"op":"rip","id":"","dest":"/storage/.kodi/temp/torttube/."}` 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":""}` → 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.py` register as video plugin - [x] `main.py` handles `plugin://plugin.video.torttube/?action=play&id=` and `?url=` — wired so JSON-RPC `Player.Open` from 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] - [x] sidecar `search` op via rustypipe `query().search::()` - [x] root directory listing in Kodi addon (Search + Play by URL entries) - [x] `?action=search` accepts inline `q=` (for JSON-RPC) or prompts keyboard - [x] result labels show title · channel · duration · view-count, with `IsPlayable=true`, thumbnails, video InfoLabels - [x] verified via JSON-RPC: `Files.GetDirectory` returns 19+ formatted results for "linus tech tips" - [x] channel browse → `{"op":"channel_videos","id":"…"}` — verified 30 videos returned for LTT's UCXuqSBlHAE6Xw-yeJA0Tunw via JSON-RPC - [x] context-menu entry on every result: "Go to " → channel listing - [x] 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] - [x] `SponsorBlockMonitor` (xbmc.Monitor subclass) polls `Player().getTime()` every 0.5s after playback starts; seeks past each segment exactly once (UUID dedup); exits cleanly on abort or playback stop - [x] toast on skip (`SponsorBlock — Skipped (s)`) - [x] only segments with `actionType: skip` are 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] - [x] cross-compile sidecar for aarch64-musl static via throwaway `messense/rust-musl-cross:aarch64-musl` container. 6.2MB stripped static binary. Builds clean from `scripts/build-addon-zip.sh`. - [x] 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/python3` which is always there). - [x] zip layout matches Kodi "install from zip" expectations - [x] addon.zip dropped at `dist/` under the repo root - [x] install + smoke recipe documented at `docs/install.md` - [x] **installed on a LibreELEC Pi** via SSH + `systemctl restart kodi` + `Addons.SetAddonEnabled` - [x] **JSON-RPC `Player.Open` smoke verified** — Rick Astley played end-to-end with audio + video synced (yt-dlp `-f best[ext=mp4]/best` picked 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 sets `plugin://plugin.video.youtube/play/?video_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 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] - [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 (filtered 720p-1080p so inputstream.adaptive's conservative chooser doesn't land below HD) + best AAC audio rep, XML-escaped URLs, proper `SegmentBase indexRange` + `Initialization range` - [x] **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**. - [x] `inputstream.adaptive.stream_headers` set with `User-Agent`, `Origin`, and `Referer` matching 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 `` per-segment timing derived from each representation's sidx box, OR `presentationTimeOffset`. Plugin.video.youtube derives these from sidx — we'd need to fetch + parse that. See `docs/upstream.md` for the upstream PR target. - [x] 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 - [x] **gated behind `dash_enabled` setting** (default off) and a `dash.on` marker 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](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