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:
parent
f1c7264e75
commit
503dbef5df
3 changed files with 192 additions and 12 deletions
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue