Live install verified end-to-end:
- SSH'd into 192.168.0.158 (LibreELEC, Kodi 20.3 Nexus, kernel aarch64
/ userspace armhf — that's why the static Rust sidecar runs but the
PyInstaller yt-dlp binary couldn't)
- Dropped addon dir into /storage/.kodi/addons/
- systemctl restart kodi → Kodi rescans /storage/.kodi/addons/
- JSON-RPC Addons.SetAddonEnabled flipped enabled:false → true
- Player.Open with plugin URL → 7s yt-dlp resolve → VideoFullScreen.xml,
fullscreen:true, currentwindow 12005, audio+video synced
Fixes that surfaced during the install:
- yt-dlp swap: PyInstaller aarch64 binary needs ld-linux-aarch64.so.1
which LibreELEC doesn't ship. Switched to the universal Python zipapp
(~3MB) which runs on /usr/bin/python3.11. build-addon-zip.sh updated.
- main.py now puts the addon's bin/ dir on PATH so the sidecar's
Command::new('yt-dlp') call resolves to the bundled zipapp.
- Cosmetic fix: resolve.rs's classify_yt_dlp_error preserves the
original error message (was downcasing it for keyword matching and
then using the lowercased copy as the user-facing error).
Caveats logged for later:
- 360p ceiling (yt-dlp '-f best[ext=mp4]' picks itag 18; 720p
progressive itag 22 is deprecated by YouTube; higher quality wants
DASH manifest generation).
- ALSA sink: device 'sysdefault:CARD=vc4hdmi1' fails to open on this
Pi but Kodi auto-falls-back to 'sysdefault' so audio works. Worth
cleaning up in Kodi audio settings later.
MILESTONES + docs/install.md updated with the SSH + JSON-RPC alternate
install path.
Realized during M6 packaging that the rustypipe path returns separate
audio + video DASH streams (Opus 251 + AV1 401 on the smoke video). Kodi
can't sync those without an inputstream.adaptive DASH manifest, which
would need server-side manifest generation — M3+ territory.
Stopgap for shippable M3: new sidecar op resolve_play that asks yt-dlp
for -f best[ext=mp4]/best — one combined audio+video URL Kodi plays as
plain HTTP. ~3-5s overhead vs rustypipe but reliable sync.
main.py _play() now calls resolve_play. resolve still exists for
metadata + browse paths (M4 will use it).
Rebuilt aarch64-musl binary, repackaged plugin.video.torttube-0.0.1.zip
(38.7MB, md5 f2c08aed130b1c1bd231a9b6cbfac93c). Live at:
smb://lucy/downloads/torttube/plugin.video.torttube-0.0.1.zip
scripts/build-addon-zip.sh runs the whole pipeline from a host with ssh
lucy:
- one-shot messense/rust-musl-cross:aarch64-musl container builds the
sidecar static (6.2MB stripped). Doesn't mutate crafting-table.
- fetches yt-dlp_linux_aarch64 from the upstream release page so Tier 2
+ Tier 3 work on the Pi (LibreELEC ships no Python YouTube tools)
- packages everything into plugin.video.torttube.zip with the Kodi
install-from-zip layout
- drops the zip at /mnt/user/downloads/torttube/ on Lucy SMB
Cargo.toml swaps rustypipe to default-features=false +
rustls-tls-webpki-roots so the cross-compile is openssl-free.
addon.xml drops the unused script.module.requests requirement — main.py
only uses Python stdlib + Kodi's own modules.
docs/install.md walks the Kodi UI flow + a smoke curl that fires
Player.Open via JSON-RPC. Pi-side smoke is pending Cobb's install on
192.168.0.158.
main.py now handles the standard Kodi plugin-URL routing:
plugin://plugin.video.torttube/?action=play&id=<yt-id>
plugin://plugin.video.torttube/?action=play&url=<full-url>
Either form calls the sidecar resolve op, picks a stream URL from the
response (rustypipe video_stream preferred, yt-dlp combined fallback),
and hands it to Kodi via xbmcplugin.setResolvedUrl.
URL parser accepts watch?v=, youtu.be/, /shorts/, /embed/, /live/, and
bare 11-char IDs. setResolvedUrl flags inputstream.adaptive for .mpd
and .m3u8 manifests so DASH/HLS streams play with the right demuxer.
This makes 'share to TV' work over Kodi's existing JSON-RPC API on
:8080 — Player.Open with a plugin URL is all the remote client needs.
No new server, no app — Kore / Yatse / curl / HA all already work.
docs/remote-control.md captures the curl recipe + Android share-target
plan for the eventual companion app.
JSON-over-stdio loop on tokio with four ops:
- ping liveness
- resolve Tier 1 rustypipe → Tier 2 yt-dlp -j fallback. Typed
errors (age/region/private/not-found) short-circuit
Tier 2 so we don't double-hit a wall. Pass-through
serialization of player.details + selected streams,
so the Python addon parses what it needs without us
coupling to rustypipe's struct shape.
- rip Tier 3 yt-dlp downloads bestvideo+bestaudio to a
caller-supplied dest_dir, returns the resulting
path + size for the addon to play as a local file.
- sponsorblock SHA-256 prefix lookup (first 4 hex), filter to the
exact video_id locally. Categories default to
[sponsor, selfpromo, interaction]; caller can override.
Smoke ran in crafting-table against dQw4w9WgXcQ — rustypipe 0.11.4
still resolves cleanly in 2026-05, sig decoding intact, both 4K AV1
video and Opus 128kbps audio came back with valid signed URLs.
SponsorBlock returns empty segments for music videos (as expected).
Cobb wants rustypipe primary, yt-dlp fallback, and rip-to-temp as the
last-resort path when streams die mid-play. README expanded to spell
out all three tiers + adds the 'fight YouTube alongside the FOSS
ecosystem' framing. MILESTONES M1 rewritten to cover all three tiers.
New file docs/upstream.md tracks every PR we file against rustypipe /
NPE / yt-dlp with honest outcomes. Opens empty; fills as M1+ surface
real bugs to fix.
Kodi addon (plugin.video.torttube) shell with Cargo workspace for the
rustypipe-backed sidecar binary. No working extraction yet — addon.xml
parses, main.py is a notification stub, sidecar's main.rs prints scaffold
banner. See MILESTONES.md for M1..M6.
License: GPL-3.0-or-later (matches rustypipe + NewPipeExtractor).