Kodi addon for YouTube via rustypipe + SponsorBlock. Replaces the dead plugin.video.youtube on LibreELEC RPi TVs.
Find a file
Kayos 03e1eb526a Second audit fix sprint — 2 CRIT + 5 HIGH + 5 MED
Second adversarial Opus audit found 2 CRIT + 7 HIGH + 9 MED + 9 LOW
against HEAD 503dbef. This sprint lands all CRIT, the 5 highest-value
HIGH items, and the most impactful MEDs. Remaining items deferred (see
notes at end).

CRIT-1 (race + torn write on Watch Later / search-history JSON state):
The persistence layer was load->mutate->save with plain open(w). Two
real failure modes were live:
  - Lost-update race: phone fires two RunPlugin wl_add back-to-back,
    each in a fresh Python interpreter. Interpreter A reads [], inserts
    AAA, writes [AAA]; B was already past _load_watch_later (also read
    []) and writes [BBB] — AAA lost.
  - Torn-write loss: open(w) truncates immediately. Kodi crash / Pi
    yank between truncate and json.dump finish leaves a zero-byte or
    partial file. _load* catches JSONDecodeError and returns [],
    silently wiping the user's pinned-videos list on next read.
Fix: _atomic_write_json (mkstemp + fsync + os.replace) + _with_lock
(fcntl.flock on a sibling .lock file). All four persistence functions
(_record_search, _clear_search_history, _add_to_watch_later,
_remove_from_watch_later) refactored to wrap load->mutate->save under
the lock. os.replace is atomic on POSIX same-filesystem; flock is
per-process advisory across the concurrent plugin interpreters.

CRIT-2 (rip allowlist bypass via ..): RIP_DEST_ALLOWLIST check was
string-level starts_with. '/storage/.kodi/temp/../../etc/cron.d' passed
the allowlist but escaped to anywhere. Dormant op (no Python caller
today) but the protocol is a wide-open arbitrary-write primitive
under the sidecar UID (root on LibreELEC). Fix: literal-prefix check
first (cheap reject), then create_dir_all + tokio::fs::canonicalize,
then a second check against the same allowlist on the canonical
result. Defeats .. and symlink escape.

HIGH-1 (SponsorBlock 1 MiB cap was post-hoc): The cap from the first
audit landed AFTER resp.bytes().await — which buffers the entire body
unbounded. A hostile mirror returning multi-GB would OOM the Pi before
the cap fired. Fix: stream via resp.chunk() in a loop, bail as soon as
accumulated bytes > cap. Defends OOM during ingest, not after.

HIGH-2 (Container.Refresh fires in wrong context): _wl_remove_action
unconditionally fired Refresh. Future refactors that expose the Remove
context menu outside the Watch Later listing (or stale context-menu
state across navigation) would refresh the wrong container — search
results would reload pointlessly when the user just hit Remove in WL.
Fix: only Refresh when Container.FolderPath contains
'action=watch_later'.

HIGH-3 (thumbnail shape — promoted from first-audit MED-9): The new
Watch Later code persists rustypipe Player.details which has a
thumbnail string in many versions. _pick_thumbnail called max() on a
string — iterates chars and crashes with AttributeError on the .get
lookup. Live crash-on-render of Watch Later. Fix: _pick_thumbnail now
accepts Any and dispatches on isinstance: empty/None -> '', str ->
str, dict -> .url, list -> filter for dicts then max-by-area.

HIGH-4 (search query unvalidated — length, control chars, surrogates):
Search took an arbitrary string from the addon and shipped it to
rustypipe. Three failure modes:
  - 10 MB query allocates 10 MB on stdin pipe + another 10 MB in
    serde_json::from_str + materializes in rustypipe's HTTP call. ~30
    MB blip per request on the 1 GB Pi.
  - Newlines in query produce multi-line entries in
    tracing::info!(query, ...) — log injection (mild on this stack,
    but obfuscates real entries).
  - Lone UTF-16 surrogates aren't a crash but produce a confusing
    error path.
Fix: validate_query() at the sidecar dispatch — 2 KB length cap,
reject control chars (TAB allowed since YouTube treats it as
whitespace). Addon-side _record_search now also collapses whitespace
via ' '.join(query.split()).

MED-1 (clear_history flicker): _clear_history_action finalized with
succeeded=True THEN Container.Update — caused a half-tick of empty
directory before the nav replaced it. Fix: succeeded=False first,
then the replace navigates cleanly.

MED-3 (LibreELEC-only fallback): _addon_data_path's xbmcvfs fallback
hardcoded /storage/.kodi/userdata/... — non-existent on Linux desktop
Kodi etc. Fix: fall back to ~/.kodi/userdata/addon_data/... which is
portable. Tests + dev rigs now persist correctly.

MED-4 (Response::ok clobber): Added debug_assert! so any future op
returning its own 'ok' key is caught in debug builds. Not a fix
per se — but a tripwire.

MED-5 (codec regex single-quote): Made _MIME_CODEC_RE accept either
quote style. Belt-and-braces against upstream MIME format drift.

MED-8 (SponsorBlockMonitor orphaned across plugin boundary): When we
delegate to plugin.video.youtube, our monitor runs in our plugin's
context against xbmc.Player()'s global state. If the user starts a
different video mid-monitor, the segments are for the old one — would
spuriously skip the new content. Fix: capture player.getPlayingFile()
at start, bail if it changes mid-loop.

MED-9 / LOW-2 carried over from first audit and tracked here too.

Smoke-verified via JSON-RPC (browse-only, no playback, Leia still
watching the TV):
- 3000-byte query rejected: 'query too long: 3000 bytes (cap 2048)'
- newline-in-query rejected: 'query contains control characters'
- legit search returns expected results
- wl_add + watch_later directory renders without thumbnail crash

Remaining deferred (cosmetic / no current impact):
- MED-2 (WL staleness): accept for v0.1, add lazy refresh later
- MED-6 (byte-length validate_youtube_id error message): cosmetic
- MED-7 (_lan_ip multi-NIC): not currently a problem on the family
  LAN, document and revisit if Tailscale lands on the Pi
- LOW-1..9: defensive polish, no real risk
- First-audit's deferred MED/LOWs: still defer-able

Addon v0.0.16.
2026-05-23 12:52:49 -07:00
addon/plugin.video.torttube Second audit fix sprint — 2 CRIT + 5 HIGH + 5 MED 2026-05-23 12:52:49 -07:00
docs Declare plugin.video.youtube as a Kodi addon dep 2026-05-23 12:15:06 -07:00
scripts M6 DONE — torttube ships, Rick Astley plays fullscreen on the Livingroom Pi 2026-05-23 10:18:26 -07:00
sidecar Second audit fix sprint — 2 CRIT + 5 HIGH + 5 MED 2026-05-23 12:52:49 -07:00
.gitignore M0 scaffold — Python addon + Rust sidecar 2026-05-23 08:14:09 -07:00
LICENSE M0 scaffold — Python addon + Rust sidecar 2026-05-23 08:14:09 -07:00
MILESTONES.md M7 DONE via delegation — pv.youtube plays HD with audio 2026-05-23 11:58:12 -07:00
README.md Declare plugin.video.youtube as a Kodi addon dep 2026-05-23 12:15:06 -07:00

torttube

Kodi addon for YouTube via RustyPipe extraction + SponsorBlock segment skipping.

Replaces the dead plugin.video.youtube on LibreELEC RPi TVs after Google required account-linking for the upstream addon.

Architecture

Kodi (LibreELEC, RPi)
  └── plugin.video.torttube     [Python addon — UI, browse, SponsorBlock]
        ├── torttube-sidecar    [Rust binary — JSON-over-stdio]
        │     ├── rustypipe         [Native Rust Innertube for browse]
        │     ├── yt-dlp subprocess [Fallback resolve]
        │     └── sponsorblock      [REST client, SHA-256 prefix lookup]
        └── plugin.video.youtube    [DEPENDENCY — handles HD playback]
              └── inputstream.adaptive  [DASH demux + decode]

plugin.video.youtube is declared as a Kodi addon dependency in addon.xml. When a user installs torttube, Kodi auto-fetches pv.youtube from the official Kodi addon repository — user only manages torttube; the dep is transparent.

torttube does what it's faster at: rustypipe-backed search/channel/playlist browse, SponsorBlock auto-skip via a tight xbmc.Player() monitor loop, JSON-RPC remote-control for share-to-TV. Playback hands off to pv.youtube via plugin://plugin.video.youtube/play/?video_id=<id> — they've spent years getting the DASH-MPD + multi-client Innertube fallback right. Our SponsorBlock monitor runs in parallel because xbmc.Player() is a global accessor that works regardless of which addon initiated playback.

Kodi addons are Python — the engine layer (n-param sig decoding, Innertube, SponsorBlock hashing) lives in a Rust sidecar so we get a single maintained extraction surface and clean aarch64/armv7 cross-compiles.

Three-tier resolve because YouTube actively fights every extractor:

  1. rustypipe (Rust) — preferred. Fast, in-process, no Python dep on the RPi.
  2. yt-dlp subprocess — fallback when rustypipe sig-decoding falls behind YouTube's deobfuscator changes. yt-dlp updates weekly; we shell out, parse -j JSON.
  3. Rip-to-temp — last resort when stream URLs 403 mid-playback (poToken expiry, cookie session mismatch). yt-dlp downloads to /storage/.kodi/temp/torttube/<id>.<ext>, Kodi plays the local file. Temp dir has size cap + age cleanup.

Status

M0 scaffold. Nothing playable yet — see MILESTONES.md.

Upstream — we fight with the FOSS extractor ecosystem, not next to it

YouTube's anti-scraping changes hit every extractor: NewPipe, yt-dlp, Invidious, rustypipe. Every fix we make in our sidecar gets evaluated for "is this upstreamable?" — if yes, the fix lands at the upstream project, not just here.

Active lanes:

  • rustypipe (Rust, codeberg.org/ThetaDev/rustypipe) — maintenance has slowed. Open PR #77 "Some fixes" is unmerged as of 2026-05-23. We will either help land it (review + ping maintainer) or fork to Sulkta-Coop/rustypipe if upstream stays quiet. Forking is the worst case, not the first move.
  • NewPipeExtractor (Java, github.com/TeamNewPipe/NewPipeExtractor) — actively maintained, 177 open issues. We use it as the reference implementation for Innertube behaviour. PRs to NPE land in Rust here via rustypipe, and vice versa.
  • yt-dlp (Python, github.com/yt-dlp/yt-dlp) — the gold standard. We're more consumers than contributors here, but if our rip-to-temp tier surfaces a specific extractor bug we file it.

Issues we're watching:

  • NPE #1339 — n-parameter deobfuscation
  • NPE #1444 — distinguish unavailable vs unextractable
  • NPE #1360 — refactor link handlers (help wanted)
  • NPE #1357 — JDoc checks in PR pipeline (good first issue)
  • rustypipe PR #77 — open as of 2026-05-23, unmerged

Contribution log lives at docs/upstream.md — every PR we file lands there with its outcome.

License

GPL-3.0-or-later. Matches RustyPipe and NewPipeExtractor.