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.
addon.xml now requires plugin.video.youtube >=7.0.0 in addition to
xbmc.python and (optional) inputstream.adaptive. When a user installs
torttube, Kodi resolves the dep and auto-fetches plugin.video.youtube
from the official Kodi addon repository — user only manages torttube,
the dep is transparent.
This is Cobb's preferred 'one-addon experience' without the
maintenance liability of vendoring + maintaining 5.6MB / 15k LOC of
their player code in our tree.
README architecture diagram updated to show pv.youtube as a dependency
rather than a sibling. docs/install.md notes that Kodi auto-installs
the dep — no manual pv.youtube step needed.
Addon version 0.0.12.
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
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).