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.
Search history (M4 ergonomics):
- _record_search() stores the query into search_history.json under
Kodi's addon_data dir. Dedupe case-insensitively, keep newest first,
cap at SEARCH_HISTORY_MAX=12.
- _recent_directory() (action=recent) shows persisted queries as
quick-pick items, each re-running the same search on click. Each
item gets a 'Clear history' context menu entry. Tail entry clears
everything.
- Root menu adds a 'Recent searches' entry only when history is
non-empty.
- _clear_history_action wires the clear path + a notification.
cacheToDisc=False on the root menu and on search results (audit MED-3):
prevents an empty Search button getting stuck cached after a no-input
cancel, and prevents the previous query's result-set shadowing the
next search.
_format_duration / _format_views fixed to treat 0 as a real value and
'' only for None/negative (audit MED-4) — brand-new 0-view uploads
and livestream pre-rolls now render correctly.
Verified end-to-end via Files.GetDirectory:
- Two searches ('funny cats', 'python tutorial') record into history
- history.json contains ['funny cats', 'python tutorial'] (newest
first because 'funny cats' was searched second)
- Recent dir lists both + Clear tail entry
- Root dir gains 'Recent searches' entry once history is populated
No playback was tested (Leia was on the TV). Addon v0.0.14.
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.
Wire _attach_sponsorblock to also fire when delegating to pv.youtube.
xbmc.Player() is a global accessor so our monitor's getTime() /
seekTime() / isPlaying() work regardless of which addon initiated the
playback. Result: HD via pv.youtube's MPD + our SponsorBlock skips.
Verified: played LTT 2T8x5antlnc via delegation, seeked to 1:45, our
monitor detected the 1:48-2:08 sponsor segment, called seekTime(128.4),
log line '[torttube] sponsorblock skip: sponsor 108.3-128.4 (20s)'.
Position landed at 2:17 (post-skip natural playback).
After hitting the segment-timing wall on our hand-rolled DASH MPD
(audio drifted -25s -> -44s behind video on long content), pivoted to
delegating playback to plugin.video.youtube v7.4.3 which already has
years of sidx-parsed SegmentTimeline + multi-client fallback work.
torttube._play() now:
1. Tries _delegate_to_pv_youtube(yt_id) — sets a resolved URL of
'plugin://plugin.video.youtube/play/?video_id=<id>'. Kodi
chain-resolves to pv.youtube which builds the proper MPD and
hands inputstream.adaptive a correctly-aligned manifest. Default.
2. Falls back to our DASH builder (still in code, gated by
'dash_enabled' setting + dash.on marker) if pv.youtube is absent.
3. Falls through to yt-dlp progressive 360p as the final safety net.
When delegating, we skip our SponsorBlock monitor — pv.youtube has its
own and would double-skip otherwise.
Cobb-verified live on Livingroom Pi: LTT 'Trump Phone' (which crashed
our DASH with audio sync errors growing to -44s) now plays HD with
audio synced. 'Please sign in' message in log is from the tv_unplugged
Innertube client; pv.youtube falls back to a working client
automatically — no user account required.
Settings: prefer_pv_youtube boolean (default true). Addon v0.0.11.
Reference: https://kodi.wiki/view/Add-on:YouTube
Big strides today:
- Sidecar resolve_dash op works (verified live on Pi)
- MPD builder generates valid MPEG-DASH on-demand manifest with
H.264 720p/1080p video reps + best AAC audio rep
- ThreadingHTTPServer serves the MPD over the LAN IP (not 127.0.0.1
— curl in Kodi 20's inputstream.adaptive can't open that)
- inputstream.adaptive PARSES our manifest cleanly: 'Successfully
parsed manifest file (Periods: 1, Streams in first period: 2)'
- Segment GETs work once we set stream_headers with User-Agent
+ Origin + Referer (otherwise googlevideo 403s the audio segments)
Remaining issue:
- Audio drifts -25s → -44s behind video within seconds of playback
start. inputstream.adaptive needs explicit SegmentTimeline timing
derived from each rep's sidx box to stay aligned. Plugin.video.youtube
does this; we'd need to fetch+parse sidx ourselves or fork their
MPD-builder. Documented as M7-blocking + upstream PR candidate.
Default remains the stable yt-dlp progressive 360p path. DASH is
behind dash_enabled setting OR a dash.on marker file in addon_data.
Toggle on via:
ssh <kodi> 'touch /storage/.kodi/userdata/addon_data/plugin.video.torttube/dash.on'
Toggle off:
ssh <kodi> 'rm /storage/.kodi/userdata/addon_data/plugin.video.torttube/dash.on'
Addon v0.0.10. docs/upstream.md has the full segment-timing analysis.
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.
SponsorBlockMonitor (xbmc.Monitor subclass) attaches after setResolvedUrl:
- fetches segments from sidecar via the existing sponsorblock op
(SHA-256 prefix lookup, defaults to sponsor + selfpromo + interaction
categories)
- waits up to 30s for playback to actually start, then polls
Player.getTime() every 0.5s
- when position enters a skip segment, calls seekTime(end) and shows
a 'SponsorBlock — Skipped <category> (<duration>s)' toast
- UUIDs are remembered so a manual rewind into a previously-skipped
segment doesn't trigger again
- exits cleanly on playback stop or Kodi shutdown
Live-verified on the Livingroom Pi with LTT 2T8x5antlnc ('Trump Phone'),
which has two locked sponsor segments. Sought to 1:45, the monitor
fired at 108.3s and seeked to 128.4s — log line:
[torttube] sponsorblock skip: sponsor 108.3-128.4 (20s)
addon.xml v0.0.1 → v0.0.2.
Deferred for v0.0.3+: settings.xml category toggles + a skip-counter,
support for non-skip action types (mute, full, poi).
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
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.
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).