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"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<addon id="plugin.video.torttube"
|
<addon id="plugin.video.torttube"
|
||||||
name="torttube"
|
name="torttube"
|
||||||
version="0.0.14"
|
version="0.0.15"
|
||||||
provider-name="Sulkta-Coop">
|
provider-name="Sulkta-Coop">
|
||||||
<requires>
|
<requires>
|
||||||
<import addon="xbmc.python" version="3.0.0"/>
|
<import addon="xbmc.python" version="3.0.0"/>
|
||||||
|
|
|
||||||
|
|
@ -713,6 +713,63 @@ def _clear_search_history() -> None:
|
||||||
pass
|
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:
|
def _pick_thumbnail(thumbs: list[dict[str, Any]] | None) -> str:
|
||||||
"""Pick the largest thumbnail from rustypipe's thumbnail list."""
|
"""Pick the largest thumbnail from rustypipe's thumbnail list."""
|
||||||
if not thumbs:
|
if not thumbs:
|
||||||
|
|
@ -729,6 +786,19 @@ def _root_directory() -> None:
|
||||||
("Search", "DefaultAddonsSearch.png", _plugin_url(action="search")),
|
("Search", "DefaultAddonsSearch.png", _plugin_url(action="search")),
|
||||||
("Play by URL", "DefaultAddonNone.png", _plugin_url(action="play_by_url")),
|
("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()
|
history = _load_search_history()
|
||||||
if history:
|
if history:
|
||||||
items.append(
|
items.append(
|
||||||
|
|
@ -791,12 +861,88 @@ def _clear_history_action() -> None:
|
||||||
xbmcplugin.endOfDirectory(_HANDLE, succeeded=True)
|
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.
|
"""Add VideoItem dicts to the current plugin directory, formatted for Kodi.
|
||||||
|
|
||||||
Each item gets a play-action plugin URL, channel + duration + view-count
|
Each item gets a play-action plugin URL, channel + duration + view-count
|
||||||
metadata in the label, thumbnail art, video InfoLabels for skin support,
|
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")
|
xbmcplugin.setContent(_HANDLE, "videos")
|
||||||
for item in items:
|
for item in items:
|
||||||
|
|
@ -836,16 +982,31 @@ def _add_video_items(items: list[dict[str, Any]]) -> None:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
if channel_id:
|
||||||
li.addContextMenuItems(
|
ctx.append(
|
||||||
[
|
(
|
||||||
(
|
f"Go to {channel_name or 'channel'}",
|
||||||
f"Go to {channel_name or 'channel'}",
|
f"Container.Update({_plugin_url(action='channel', id=channel_id)})",
|
||||||
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(
|
xbmcplugin.addDirectoryItem(
|
||||||
_HANDLE, _plugin_url(action="play", id=yt_id), li, isFolder=False
|
_HANDLE, _plugin_url(action="play", id=yt_id), li, isFolder=False
|
||||||
|
|
@ -1001,6 +1162,12 @@ def main() -> None:
|
||||||
_recent_directory()
|
_recent_directory()
|
||||||
elif action == "clear_history":
|
elif action == "clear_history":
|
||||||
_clear_history_action()
|
_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:
|
else:
|
||||||
_root_directory()
|
_root_directory()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,20 @@ pub(crate) async fn fetch(id: &str, categories: &[String]) -> anyhow::Result<ser
|
||||||
anyhow::bail!("sponsorblock http {}", resp.status());
|
anyhow::bail!("sponsorblock http {}", resp.status());
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: Vec<ApiResponse> = 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<ApiResponse> = serde_json::from_slice(&bytes)?;
|
||||||
|
|
||||||
// Filter to the exact video id (the API returns all videos sharing the prefix).
|
// Filter to the exact video id (the API returns all videos sharing the prefix).
|
||||||
let segments: Vec<&ApiSegment> = body
|
let segments: Vec<&ApiSegment> = body
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue