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.
onAVStarted was calling _fetch_sb_segments synchronously, which
subprocess.run()'s our sidecar — up to 8s of blocking on Kodi's
serialized player event thread. When the user started a new video while
one was playing, pv.youtube's stream resolve for the new video raced
with our blocked callback and the new play got dropped as "unplayable
item" before pv.youtube could finish.
Moved the segment fetch + skip loop into a background thread that
starts from onAVStarted and returns instantly. Player callbacks now
clear in microseconds.
Two big changes:
- service.py is a new background-service component (xbmc.service
extension). On first run it extracts the bundled pv.youtube 7.4.3 zip
into Kodi's addons dir, forces a rescan, enables it. No more two-step
install. The bundle stays as a separate Kodi addon at runtime; we just
ship + auto-install it.
- The service also subclasses xbmc.Player and runs SponsorBlock for
ANY YouTube playback, not just plays initiated by torttube. The
TorttubePlayerMonitor watches onAVStarted globally, pulls the YouTube
ID from the playing-file URL (matches ?v=, ?file=XXX.mpd, ?video_id=
— covers pv.youtube's resolved URLs in every shape), then runs a poll-
skip loop on a background thread. Critical: this means SponsorBlock
now works when a video is cast to Kodi from the phone YouTube app —
the cast hits pv.youtube directly and our plugin code is never
invoked, but the service is always alive and catches it.
- Dropped the three _attach_sponsorblock call sites in main.py._play()
and the SponsorBlockMonitor class. The DASH path still needs to block
until playback ends so it can shut down its localhost MPD server;
that's now a tiny _wait_for_playback_end() that doesn't do SB.
- Removed the <import addon="plugin.video.youtube"> from addon.xml since
we bundle it; kept inputstream.adaptive as an optional import.
SponsorBlockMonitor's "playing file changed mid-monitor" exit was too
aggressive on the delegation path. When torttube hands off to
plugin.video.youtube, Kodi cycles through three URLs (torttube plugin →
pv.youtube plugin → MPD on localhost:50152) before settling. If
initial_file captured an intermediate one, the next poll's current_file
tripped the exit and SponsorBlock silently stopped without skipping
anything.
Removed the check. The isPlaying() guard already covers the "player
stopped" exit; the rare "user started a different video while our
monitor is still alive" case isn't worth breaking the common path.
Two fixes after a 2026-05-23 regression where a Kodi restart left
plugin.video.youtube's service.py un-started, breaking every delegated
play with a silent "Service IPC - Monitor has not started" error.
- Addon: probe pv.youtube's localhost httpd (50152/50153) before
delegating. If nothing is listening, show a "YouTube service down —
restart Kodi" notification and fall back to our DASH / progressive
paths instead of letting setResolvedUrl fail silently.
- Sidecar: revert tokio runtime from current_thread back to
multi_thread with 2 worker threads so subscriptions_feed's per-channel
tokio::spawn fan-out runs in parallel. The current_thread RSS savings
weren't worth the behavioral change.
Big sweep ahead of tagging v1:
WATCH LATER STALENESS (MED-2 2nd audit) — actually shipped.
- New _refresh_watch_later_item() that load-mutate-saves under the lock
helper, replacing the metadata for a single id in place.
- 'Refresh metadata' context-menu entry on every Watch Later item.
- _wl_refresh_action handler: validate id, call _resolve_video_metadata
(which factors out the same logic both wl_add and wl_refresh need),
patch the on-disk record, refresh the container if the user is
currently viewing the WL list.
- Bug: this was supposed to ship in the prior sprint but a duplicate
Edit replaced the wrong block and the _refresh_watch_later_item
function never actually landed in the file. Smoke caught it:
Kodi reported 'Error getting plugin://…?action=wl_refresh' because
the action raised NameError. Now landed properly; verified
end-to-end after a Kodi restart cleared the cached-addon stub.
MULTI-NIC _lan_ip (MED-7 2nd audit) — fixed.
- gethostbyname_ex now scans local interfaces first and prefers a
private-range LAN IP (192.168.x.x / 10.x.x.x / 172.16-31.x.x).
- Connect-trick to 8.8.8.8 stays as the fallback for hosts with a
single default route. 127.0.0.1 is the last resort.
- On hosts with Tailscale / OpenVPN / VPN tunnels as the default
route, this prevents inputstream.adaptive from getting handed a
VPN-tunnel IP it can't reach.
REMAINING LOW BATCH (1st + 2nd audit) — landed.
- _CHANNEL_ID_RE check in _add_video_items drops 'Go to channel'
entries when rustypipe ever hands us a non-UC-shaped id (LOW-1 2nd).
- _redact_query truncates queries before logging (LOW-3 2nd).
- _add_to_watch_later() now returns 'was_full' so the wl_add notify
can surface 'Watch Later at cap (500) — dropped oldest' (LOW-9 2nd).
- _remove_from_watch_later() returns 'removed' so wl_remove notifies
'Item was not in Watch Later' on no-op (LOW-7 2nd).
- _add_to_watch_later validates yt_id shape before writing (LOW-6 2nd).
- _record_search collapses whitespace before dedup (LOW-4 2nd).
- Sidecar tokio runtime now flavor='current_thread' — one-shot per
invocation, saves ~100KB RSS per spawn (LOW-6 1st).
- _MIME_CODEC_RE accepts either quote style (MED-5).
- Response::ok has a debug_assert! tripwire if a handler ever returns
its own 'ok' key (MED-6).
- _pick_thumbnail defends against rustypipe handing it a string,
dict, or list-of-non-dicts shape (MED-9 / HIGH-3 redux).
DANGEROUS-FUNCTIONS SCAN — clean.
- Zero shell=True, os.system, os.popen, eval, exec, pickle,
__import__ across both Python and Rust.
- All subprocess calls list-form, all URL building via urlencode,
all JSON via json/serde_json.
- xbmc.executebuiltin Container.Update / RunPlugin URLs always go
through _plugin_url(urlencode) — channel_id additionally regex-
validated for defense-in-depth.
CODE FEEL — humanized.
- Stripped all 'Audit CRIT-1 (2nd pass)' / 'Audit MED-X' ticket
prefixes across main.py + sidecar Rust. The 'why' comments stay;
the audit-trail breadcrumbs go. Code reads like working software,
not a postmortem trail.
- Section comments (── Search history ──, ── Watch Later ──,
── Subscriptions ──) added on the persistence block for navigation.
VERSION — bumped addon.xml to 1.0.0, Cargo.toml workspace to 1.0.0.
Verified live on Livingroom Pi after a Kodi restart: wl_add writes
fresh LTT metadata, manual mutation to 'STALE STUB' detected,
wl_refresh re-fetches and restores the canonical title.
User-curated, no YouTube account. Same posture as Watch Later: you
decide what comes back, no algorithm involved.
Sidecar SubscriptionsFeed op:
- Takes channel_ids: Vec<String>, per_channel + limit defaults 8/60
- Caps fan-out at 200 channels per call + each id at 64 chars to
keep a malicious caller from hammering YouTube via the sidecar
- Spawns one tokio::task per channel against
rustypipe::query().channel_videos(), merges results once all
finish, sorts by publish_date string newest-first
- A channel that 404s / region-blocks / rustypipe-errors is silently
dropped — one dead subscription doesn't kill the whole feed; the
failed list is returned in channels_failed for logging
Addon side:
- subscriptions.json under addon_data, persisted via same
_atomic_write_json + _with_lock helpers as Watch Later (no
repeat of the race + torn-write hazards the audit caught)
- Two new root menu entries (visible only when subscribed):
* 'Subscriptions Feed (N)' — chronological merge of latest uploads
* 'Subscriptions (channel list)' — per-channel browse
- Context menu on every video result toggles
'Subscribe to <channel>' / 'Unsubscribe from <channel>' based on
current sub state
- Context menu on each entry in the channel list has its own
'Unsubscribe from <channel>' for direct removal
- _unsubscribe_action does Container.Refresh only when
Container.FolderPath contains 'action=subs' (same guard pattern
we used for wl_remove)
Live smoke (browse-only, no playback, Leia still safe):
- Subscribed to LTT (UCXuqSBlHAE6Xw-yeJA0Tunw) + MKBHD
(UCBJycsmduvYEL83R_U4JriQ) via RunPlugin
- subscriptions.json correctly holds both
- action=subs shows MKBHD + Linus Tech Tips as channel folders
- action=subs_feed returns 16 merged items: MKBHD's recent uploads
plus LTT's
- Root menu now includes 'Subscriptions Feed (2)' and
'Subscriptions (channel list)'
Addon v0.0.17.
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.
Cobb 2026-05-23: 'trending on youtube is cancer. only braindead zombies
will want what is trending on youtube.' So no Trending entry. Instead:
Watch Later — pure user-curated. Pin a video from any listing context
menu ('Add to Watch Later'); it persists to watch_later.json under
addon_data with full metadata {id, name, channel, duration, thumbnail}
so re-rendering the list doesn't need to re-fetch from rustypipe.
Newest first, dedupe on id, cap WATCH_LATER_MAX=500.
New ops + actions:
- _watch_later_directory (action=watch_later) — renders saved videos
with _add_video_items(in_watch_later=True). Each item gets a
'Remove from Watch Later' context entry; the Add entry is suppressed.
- _wl_add_action (action=wl_add) — RunPlugin-style handler that gets
the id from the URL, calls sidecar 'resolve' for fresh metadata
(falls back to id-only if resolve fails), saves into watch_later.json,
notification toast.
- _wl_remove_action (action=wl_remove) — symmetric remove. Triggers
Container.Refresh so the item disappears immediately from the list.
- Root menu gains a 'Watch Later (N)' entry, always present, with the
count when N>0.
- _add_video_items now accepts in_watch_later=bool and adds either Add
or Remove to the context menu accordingly.
MED-8 from the audit: SponsorBlock response capped at 1 MiB before
JSON parse. Normal SponsorBlock responses are tens of KB; a degenerate
prefix collision or malicious mirror returning gigabytes would
otherwise be deserialized into memory before we filter. resp.bytes()
+ length check + serde_json::from_slice.
Verified live via JSON-RPC (browse-only, Leia not interrupted):
- Empty WL → notification path
- Addons.ExecuteAddon wl_add for LTT 2T8x5antlnc → watch_later.json
has full metadata block
- watch_later dir lists 'The Internet was WRONG: Trump Phone is
Shipping · Linus Tech Tips · 14:08'
- Root menu shows 'Watch Later (1)' alongside Search + Play by URL +
Recent searches.
Saved feedback memory at memory/feedback_no_youtube_trending.md so
future me doesn't propose Trending again.
Addon v0.0.15.
Full Opus-max adversarial audit found 2 CRIT, 7 HIGH, 9 MED, 9 LOW.
This commit lands all CRIT, all 7 HIGH, the highest-impact MEDs, and
the addon-manifest LOW. The remaining MED/LOW items are cosmetic or
defensive polish — captured in the audit report but not blocking.
CRIT-1: _attach_sponsorblock had a duplicate block stacked on top of
itself. After a video ended the second monitor instantiated, blocked
30s waiting for a player that wasn't there, logged 'timed out' and
returned. Net effect on the family-room TV: Kodi froze for 30s after
every single video. Deleted the duplicate (lines 512–530).
CRIT-2: MPD HTTP server bound to 0.0.0.0 + emitted
Access-Control-Allow-Origin: *. The MPD embeds signed googlevideo
segment URLs — anything on the LAN (guest phones, IoT, malicious sites
loaded in any browser) could scrape the manifest and grab those URLs.
Bind to the LAN IP only with a 127.0.0.1 fallback if that fails; drop
the gratuitous CORS header. Setting is off by default so the live
exposure was small, but locked down before M7 ever flips it on.
HIGH-1: _extract_id accepted arbitrary 'v=' query values without
validating the 11-char [A-Za-z0-9_-] shape. New _validate_id helper +
strict netloc-suffix matching (was: 'youtube.com' in netloc, which let
'myyoutube.com.evil.example' through). Every branch now pipes through
_validate_id. Same shape enforced in the sidecar via
validate_youtube_id() at every op entry point — defense in depth.
HIGH-2: _call_sidecar parser now picks the LAST non-empty stdout line
(robust against future stray println!/log lines), with a dedicated
JSONDecodeError catch that returns a clear 'sidecar stdout was not
JSON: <repr>' rather than a confusing 'sidecar exited 0' message.
HIGH-3: _pv_youtube_installed() now logs the probe exception before
returning False, so silent degradation to 360p is observable in
kodi.log instead of mysterious.
HIGH-4: SponsorBlockMonitor.run()'s getTime() catch widened from
(RuntimeError, OSError) to bare Exception — historically Kodi has
thrown other types when the player goes stale mid-poll, and we don't
want one of those to escape past _play's finally block and leak the
MPD HTTP server.
HIGH-5: Wrapped _attach_sponsorblock in try/except at both call sites
(pv.youtube delegate path + progressive fallback) so a SB monitor bug
can't pop a 'Plugin error' dialog on the TV after successful playback.
HIGH-6: Sidecar Search/ChannelVideos/Playlist now clamp 'limit' at
MAX_LIMIT=200. Prevents an unbounded limit=u32::MAX from OOMing Kodi
on a malicious or buggy addon request.
HIGH-7: Sidecar Rip op now requires dest_dir to be a prefix of an
allowlist (/storage/.kodi/temp/ or
/storage/.kodi/userdata/addon_data/plugin.video.torttube/). Op is
dormant today (no Python caller), but the protocol was a wide-open
arbitrary-write primitive.
MED-1: Bumped search / channel / playlist sidecar timeouts from 15s
to 25s — first-search-after-Kodi-boot on a slow LAN routinely hit 8s+
of rustypipe TLS-handshake + Innertube initial parse.
MED-2: _resolved_listitem now checks urlparse(url).path.endswith('.mpd'
/'.m3u8') instead of substring scan of the whole URL — yt-dlp URLs
have base64 blobs in the query string that can accidentally contain
those substrings.
MED-7: Error classifiers (classify_yt_dlp_error, classify_rustypipe_error)
now match on word-level patterns ('private video', 'age-restrict',
'region-restrict' etc.) instead of bare substrings — fixes
'private network' in a TLS error misclassifying as PrivateVideo,
'package' triggering AgeRestricted, etc.
LOW-1: addon.xml's plugin.video.youtube dep marked optional='true' —
our _pv_youtube_installed() check and fallback paths assumed
optionality; the manifest now matches.
Addon v0.0.13. Verified live via two browse-only smokes:
- Sidecar bad-id rejection ('../../etc/passwd'):
{ok:false, error:'invalid youtube id (length 16 != 11)', kind:'bad_request'}
- Files.GetDirectory ?q=cat: 12 results, formatted labels intact.
NOT VERIFIED VIA PLAYBACK (Leia was watching the TV).
Remaining MED/LOW items (mostly cosmetic): MED-3 endOfDirectory
cacheToDisc, MED-4 0-is-falsy in _format_duration/_format_views, MED-5
codec regex single-quote, MED-6 Response::Ok ok-clobber, MED-8 SB
response-size cap, MED-9 thumbnail dict type check, LOW-2..LOW-9
defensive polish. Tracked in audit report; can hit in a follow-up
sprint.
Sidecar Playlist op via rustypipe playlist(). Returns playlist metadata
block (id, name, channel, video_count) + items array. Verified live
against LTT's 'Consumer Advocacy' (PL8mG-RkN2uTzwoF72GqeqAJMI-N7scqtI):
returns the single video with full metadata.
Addon ?action=playlist&id=PL... lists items via _add_video_items reuse.
Verified via Files.GetDirectory JSON-RPC.
resources/settings.xml gains a 'dash_enabled' toggle (boolean, default
off). main.py checks ADDON.getSettingBool('dash_enabled') OR the
TORTTUBE_DASH env fallback before attempting the DASH path. Toggle via
Kodi Settings → Add-on settings → torttube, OR via
Addons.SetSettings JSON-RPC.
docs/upstream.md: filed a 'watching' entry for rustypipe PR #77
(Schmiddiii's late-May YouTube parsing fixes) with our independent
test data — player(), search(), and channel_videos() all still work
against current YouTube on 0.11.4, suggesting the PR fixes code paths
torttube doesn't yet exercise. Endorsement comment pending: gated on
creating a Sulkta-Coop codeberg account.
Observation from kodi.log: plugin.video.youtube successfully parsed a
DASH MPD with 26 streams via inputstream.adaptive on this same Pi —
proves DASH is solvable on our setup, just need to match the URL
pattern they use. M7 stabilization carrying forward.
Addon version 0.0.9.
Sidecar ChannelVideos op via rustypipe channel_videos(). Returns the
channel metadata block (id, name, subscribers, banner) alongside the
items array — same VideoItem shape as search.
Addon refactor: _add_video_items is now the shared listing builder.
Both _search_directory and _channel_directory call it. Each video
result gets a 'Go to <channel>' context-menu entry that
Container.Update's to ?action=channel&id=<channel_id> — so from any
search result, the user can drill into that channel's recent uploads
without going back through search.
Smoke verified on the Pi via Files.GetDirectory: LTT channel
(UCXuqSBlHAE6Xw-yeJA0Tunw) returned 30 recent videos.
Addon version 0.0.7.
Sidecar gains the 'search' op via rustypipe's
query().search::<VideoItem,_>() — returns id, title, channel, duration,
thumbnails, view_count. Default limit 25.
Addon root directory is no longer a placeholder notification:
- 'Search' entry → ?action=search → keyboard input → result list →
tap a result to play (each result is a play-action plugin URL).
- 'Play by URL' entry → ?action=play_by_url → keyboard input → PlayMedia.
- ?action=search also accepts inline 'q=…' so JSON-RPC clients can
drive search without going through the on-TV keyboard (useful for
share-to-TV from phone + tests).
- Result labels formatted as 'Title · Channel · Duration · Views',
with thumbnail + Kodi InfoLabels for richer skin views.
Verified via Files.GetDirectory JSON-RPC: 19 well-formatted LTT results
returned for query 'linus tech tips'.
Pending M4: channel browse, playlist browse, pagination, search history.
Addon version 0.0.6.
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.
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.
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).
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).