From 73f8616d4512e0bc540bb6f208c16ef3942652ec Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 23 May 2026 12:38:32 -0700 Subject: [PATCH] =?UTF-8?q?Watch=20Later=20=E2=80=94=20user-curated=20anti?= =?UTF-8?q?dote=20to=20algorithm=20feeds=20+=20MED-8=20SB=20size=20cap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- addon/plugin.video.torttube/addon.xml | 2 +- addon/plugin.video.torttube/main.py | 187 +++++++++++++++++- .../crates/torttube-sidecar/src/sponsor.rs | 15 +- 3 files changed, 192 insertions(+), 12 deletions(-) diff --git a/addon/plugin.video.torttube/addon.xml b/addon/plugin.video.torttube/addon.xml index 44bf79f..f6be16f 100644 --- a/addon/plugin.video.torttube/addon.xml +++ b/addon/plugin.video.torttube/addon.xml @@ -1,7 +1,7 @@ diff --git a/addon/plugin.video.torttube/main.py b/addon/plugin.video.torttube/main.py index a0614f2..b40184f 100644 --- a/addon/plugin.video.torttube/main.py +++ b/addon/plugin.video.torttube/main.py @@ -713,6 +713,63 @@ def _clear_search_history() -> None: pass +# Watch Later — user-curated list of saved videos. The anti-algorithm answer +# to YouTube's recommendation cancer: you decide what comes back. Items are +# stored as {id, name, channel, duration, thumbnail} dicts so we don't need +# to re-fetch metadata when rendering the list. Newest first, no cap. + +WATCH_LATER_MAX = 500 + + +def _watch_later_path() -> str: + try: + import xbmcvfs + base = xbmcvfs.translatePath("special://profile/addon_data/plugin.video.torttube/") + except Exception: + base = "/storage/.kodi/userdata/addon_data/plugin.video.torttube/" + return os.path.join(base, "watch_later.json") + + +def _load_watch_later() -> list[dict[str, Any]]: + try: + with open(_watch_later_path(), "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list): + return [ + d for d in data + if isinstance(d, dict) and isinstance(d.get("id"), str) + ][:WATCH_LATER_MAX] + except (FileNotFoundError, json.JSONDecodeError, OSError): + pass + return [] + + +def _save_watch_later(items: list[dict[str, Any]]) -> None: + path = _watch_later_path() + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(items[:WATCH_LATER_MAX], f, ensure_ascii=False) + except OSError as e: + _log(f"watch later save failed (non-fatal): {e}", xbmc.LOGWARNING) + + +def _add_to_watch_later(item: dict[str, Any]) -> None: + yt_id = item.get("id") + if not yt_id: + return + items = _load_watch_later() + items = [i for i in items if i.get("id") != yt_id] # dedupe + items.insert(0, item) + _save_watch_later(items) + + +def _remove_from_watch_later(yt_id: str) -> None: + items = _load_watch_later() + items = [i for i in items if i.get("id") != yt_id] + _save_watch_later(items) + + def _pick_thumbnail(thumbs: list[dict[str, Any]] | None) -> str: """Pick the largest thumbnail from rustypipe's thumbnail list.""" if not thumbs: @@ -729,6 +786,19 @@ def _root_directory() -> None: ("Search", "DefaultAddonsSearch.png", _plugin_url(action="search")), ("Play by URL", "DefaultAddonNone.png", _plugin_url(action="play_by_url")), ] + wl = _load_watch_later() + if wl: + items.append( + ( + f"Watch Later ({len(wl)})", + "DefaultPlaylist.png", + _plugin_url(action="watch_later"), + ) + ) + else: + items.append( + ("Watch Later", "DefaultPlaylist.png", _plugin_url(action="watch_later")) + ) history = _load_search_history() if history: items.append( @@ -791,12 +861,88 @@ def _clear_history_action() -> None: xbmcplugin.endOfDirectory(_HANDLE, succeeded=True) -def _add_video_items(items: list[dict[str, Any]]) -> None: +def _watch_later_directory() -> None: + """Browse the user's curated Watch Later list.""" + items = _load_watch_later() + if not items: + xbmcgui.Dialog().notification( + "torttube", + "Watch Later is empty. Right-click any video to add.", + xbmcgui.NOTIFICATION_INFO, + 3500, + ) + xbmcplugin.endOfDirectory(_HANDLE, succeeded=False) + return + _add_video_items(items, in_watch_later=True) + xbmcplugin.endOfDirectory(_HANDLE, cacheToDisc=False) + + +def _wl_add_action() -> None: + """RunPlugin handler: add a video to Watch Later. + + Called from the context-menu Container action, so we don't have full + item metadata in the URL. We do a sidecar `resolve` to fetch metadata + fresh — slower than caching the item from the listing, but reliable + and works across navigation paths. + """ + params = dict(parse_qsl(_QS.lstrip("?"))) + try: + yt_id = _validate_id(params.get("id")) + except ValueError as e: + xbmcgui.Dialog().notification("torttube", str(e), xbmcgui.NOTIFICATION_ERROR, 3000) + return + # Try to get rich metadata via a fast rustypipe resolve. If anything fails, + # save the ID alone so we still remember the user's pin. + item: dict[str, Any] = {"id": yt_id} + try: + resp = _call_sidecar({"op": "resolve", "id": yt_id}, timeout_s=15) + if resp.get("ok"): + details = resp.get("details") or {} + item = { + "id": yt_id, + "name": details.get("name") or details.get("title") or yt_id, + "channel": { + "id": details.get("channel_id"), + "name": details.get("channel_name"), + }, + "duration": details.get("duration"), + "thumbnail": details.get("thumbnail"), + } + except Exception as e: + _log(f"watch-later metadata fetch failed (saving id only): {e}", xbmc.LOGWARNING) + _add_to_watch_later(item) + xbmcgui.Dialog().notification( + "torttube", + f"Added to Watch Later: {item.get('name') or yt_id}", + xbmcgui.NOTIFICATION_INFO, + 2500, + ) + + +def _wl_remove_action() -> None: + """RunPlugin handler: remove a video from Watch Later.""" + params = dict(parse_qsl(_QS.lstrip("?"))) + try: + yt_id = _validate_id(params.get("id")) + except ValueError as e: + xbmcgui.Dialog().notification("torttube", str(e), xbmcgui.NOTIFICATION_ERROR, 3000) + return + _remove_from_watch_later(yt_id) + xbmcgui.Dialog().notification( + "torttube", "Removed from Watch Later", xbmcgui.NOTIFICATION_INFO, 2000 + ) + # Refresh the current container so the item disappears immediately when + # invoked from inside the Watch Later list. + xbmc.executebuiltin("Container.Refresh") + + +def _add_video_items(items: list[dict[str, Any]], *, in_watch_later: bool = False) -> None: """Add VideoItem dicts to the current plugin directory, formatted for Kodi. Each item gets a play-action plugin URL, channel + duration + view-count metadata in the label, thumbnail art, video InfoLabels for skin support, - and a 'Go to channel' context menu entry when the channel id is known. + and context-menu entries: 'Go to channel' and either 'Add to Watch Later' + or 'Remove from Watch Later' depending on `in_watch_later`. """ xbmcplugin.setContent(_HANDLE, "videos") for item in items: @@ -836,16 +982,31 @@ def _add_video_items(items: list[dict[str, Any]]) -> None: except Exception: pass - # Context menu: jump to channel listing if we have the id. + # Context menu: jump to channel listing + Watch Later add/remove. + ctx: list[tuple[str, str]] = [] if channel_id: - li.addContextMenuItems( - [ - ( - f"Go to {channel_name or 'channel'}", - f"Container.Update({_plugin_url(action='channel', id=channel_id)})", - ) - ] + ctx.append( + ( + f"Go to {channel_name or 'channel'}", + f"Container.Update({_plugin_url(action='channel', id=channel_id)})", + ) ) + if in_watch_later: + ctx.append( + ( + "Remove from Watch Later", + f"RunPlugin({_plugin_url(action='wl_remove', id=yt_id)})", + ) + ) + else: + ctx.append( + ( + "Add to Watch Later", + f"RunPlugin({_plugin_url(action='wl_add', id=yt_id)})", + ) + ) + if ctx: + li.addContextMenuItems(ctx) xbmcplugin.addDirectoryItem( _HANDLE, _plugin_url(action="play", id=yt_id), li, isFolder=False @@ -1001,6 +1162,12 @@ def main() -> None: _recent_directory() elif action == "clear_history": _clear_history_action() + elif action == "watch_later": + _watch_later_directory() + elif action == "wl_add": + _wl_add_action() + elif action == "wl_remove": + _wl_remove_action() else: _root_directory() diff --git a/sidecar/crates/torttube-sidecar/src/sponsor.rs b/sidecar/crates/torttube-sidecar/src/sponsor.rs index 3e828df..02e7e7a 100644 --- a/sidecar/crates/torttube-sidecar/src/sponsor.rs +++ b/sidecar/crates/torttube-sidecar/src/sponsor.rs @@ -63,7 +63,20 @@ pub(crate) async fn fetch(id: &str, categories: &[String]) -> anyhow::Result = resp.json().await?; + // Cap the response body at 1 MiB. A normal prefix collision returns + // tens of KB; a degenerate response (or a hostile API mirror) returning + // gigabytes would otherwise be deserialized straight into memory before + // we filter to our target video_id. + const SPONSORBLOCK_MAX_BYTES: usize = 1 * 1024 * 1024; + let bytes = resp.bytes().await?; + if bytes.len() > SPONSORBLOCK_MAX_BYTES { + anyhow::bail!( + "sponsorblock response too large: {} bytes (cap {})", + bytes.len(), + SPONSORBLOCK_MAX_BYTES + ); + } + let body: Vec = serde_json::from_slice(&bytes)?; // Filter to the exact video id (the API returns all videos sharing the prefix). let segments: Vec<&ApiSegment> = body