Watch Later — user-curated antidote to algorithm feeds + MED-8 SB size cap

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.
This commit is contained in:
Kayos 2026-05-23 12:38:32 -07:00
parent f1c7264e75
commit 503dbef5df
3 changed files with 192 additions and 12 deletions

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.video.torttube"
name="torttube"
version="0.0.14"
version="0.0.15"
provider-name="Sulkta-Coop">
<requires>
<import addon="xbmc.python" version="3.0.0"/>

View file

@ -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()