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