Watchlist cards: show the channel/playlist video count, not '0 videos'

The channel card's 'N videos' was actually the count of WISHED videos (0 until you
wish some) — so every channel read '0 videos'. It now shows the REMEMBERED catalog
size (from youtube_channel_videos, which fills in as a channel is enriched/opened),
falling back to 'Channel' when nothing's cached yet. Wished count is kept as a
separate wished_count field. Playlists likewise show their video count (cached when
the playlist is followed-with-videos or opened) instead of a static 'Playlist'.
list_watchlist_channels/playlists return the remembered count; the playlist detail +
follow endpoints cache the list so the count is known. 138 tests green.
This commit is contained in:
BoulderBadgeDad 2026-06-17 21:13:33 -07:00
parent abb9d0e89e
commit ba7562c5f2
6 changed files with 36 additions and 15 deletions

View file

@ -131,6 +131,11 @@ def register_routes(bp):
if not playlist or not playlist.get("playlist_id"):
return jsonify({"success": False, "error": "Could not resolve playlist"}), 404
ok = db.add_playlist_to_watchlist(playlist)
if ok and playlist.get("videos"): # remember the count straight away
try:
db.cache_channel_videos(playlist["playlist_id"], playlist["videos"])
except Exception:
pass
return jsonify({"success": ok, "following": ok,
"playlist": {k: playlist.get(k) for k in ("playlist_id", "title", "thumbnail_url")}})
except Exception:
@ -385,6 +390,10 @@ def register_routes(bp):
v["wished"] = v.get("youtube_id") in wished
if not v.get("published_at") and dates.get(v.get("youtube_id")):
v["published_at"] = dates[v["youtube_id"]]
try:
db.cache_channel_videos(playlist_id, vids) # remember the count for the watchlist card
except Exception:
pass
return jsonify({"success": True, "videos": vids, "playlist": pl,
"following": bool(db.playlist_watch_state([pl["playlist_id"]])),
"kind": "playlist", "source": "youtube"})

View file

@ -1846,18 +1846,21 @@ class VideoDatabase:
conn.close()
def list_watchlist_channels(self) -> list[dict]:
"""Followed channels (newest first), each with a wished-video count for the card."""
"""Followed channels (newest first): ``video_count`` is the REMEMBERED catalog
size (from the cache, fills in as the channel is enriched/opened), plus how
many of its videos are wished."""
conn = self._get_connection()
try:
rows = conn.execute(
"SELECT w.title, w.poster_url, w.source_id, w.date_added, "
"(SELECT COUNT(*) FROM youtube_channel_videos cv WHERE cv.channel_id = w.source_id) AS video_count, "
"(SELECT COUNT(*) FROM video_wishlist v WHERE v.kind='video' "
" AND v.parent_source_id = w.source_id) AS video_count "
" AND v.parent_source_id = w.source_id) AS wished_count "
"FROM video_watchlist w WHERE w.kind='channel' AND w.state='follow' "
"ORDER BY w.date_added DESC, w.id DESC").fetchall()
return [{"kind": "channel", "youtube_id": r["source_id"], "title": r["title"],
"poster_url": r["poster_url"], "video_count": r["video_count"],
"date_added": r["date_added"]} for r in rows]
"wished_count": r["wished_count"], "date_added": r["date_added"]} for r in rows]
finally:
conn.close()
@ -1918,14 +1921,18 @@ class VideoDatabase:
conn.close()
def list_watchlist_playlists(self) -> list[dict]:
"""Followed playlists (newest first)."""
"""Followed playlists (newest first), each with its remembered video count
(cached when the playlist is followed/opened)."""
conn = self._get_connection()
try:
rows = conn.execute(
"SELECT title, poster_url, source_id, date_added FROM video_watchlist "
"WHERE kind='playlist' AND state='follow' ORDER BY date_added DESC, id DESC").fetchall()
"SELECT w.title, w.poster_url, w.source_id, w.date_added, "
"(SELECT COUNT(*) FROM youtube_channel_videos cv WHERE cv.channel_id = w.source_id) AS video_count "
"FROM video_watchlist w WHERE w.kind='playlist' AND w.state='follow' "
"ORDER BY w.date_added DESC, w.id DESC").fetchall()
return [{"kind": "playlist", "playlist_id": r["source_id"], "title": r["title"],
"poster_url": r["poster_url"], "date_added": r["date_added"]} for r in rows]
"poster_url": r["poster_url"], "video_count": r["video_count"],
"date_added": r["date_added"]} for r in rows]
finally:
conn.close()

View file

@ -564,10 +564,12 @@ def test_youtube_follow_then_channels_and_wishlist(tmp_path):
assert data["added_videos"] == 2
assert data["counts"] == {"channel": 1, "video": 2}
# appears on the watchlist channels list with a video count
# appears on the watchlist channels list: wished_count = the 2 wished videos,
# video_count = the remembered catalog (0 here — nothing cached in this test).
chans = client.get("/api/video/youtube/channels").get_json()
assert chans["channels"][0]["youtube_id"] == "UCPlay"
assert chans["channels"][0]["video_count"] == 2
assert chans["channels"][0]["wished_count"] == 2
assert chans["channels"][0]["video_count"] == 0
# appears in the youtube wishlist as a nebula channel (year=season, video=episode)
wl = client.get("/api/video/youtube/wishlist").get_json()

View file

@ -1105,7 +1105,8 @@ def test_youtube_rows_do_not_disturb_tmdb_counts(db):
assert db.watchlist_counts() == {"show": 1, "person": 0, "total": 1}
# youtube counts live on their own surface
assert db.youtube_wishlist_counts() == {"channel": 1, "video": 1}
assert db.list_watchlist_channels()[0]["video_count"] == 1
assert db.list_watchlist_channels()[0]["wished_count"] == 1 # 1 wished video
assert db.list_watchlist_channels()[0]["video_count"] == 0 # remembered catalog (nothing cached)
def test_upgrade_from_pre_source_schema(tmp_path):

View file

@ -20,14 +20,14 @@
// (like any show/movie); the ✕ unfollows.
function channelCard(ch) {
var av = window.VideoYoutube ? VideoYoutube.avatar(ch, 'vyt-wcard-avatar') : '';
var n = ch.video_count || 0;
var n = ch.video_count || 0; // remembered catalog size (fills in as enriched)
var meta = n > 0 ? (n + ' video' + (n === 1 ? '' : 's')) : 'Channel';
return '<div class="vyt-wcard" data-vyt-open-channel="' + esc(ch.youtube_id) + '" title="Open channel">' +
'<div class="vyt-wcard-art">' + av + '</div>' +
'<button class="vyt-wcard-unfollow" type="button" data-vyt-wunfollow="' + esc(ch.youtube_id) +
'" title="Unfollow">&#10005;</button>' +
'<div class="vyt-wcard-info"><span class="vyt-wcard-title" title="' + esc(ch.title) + '">' +
esc(ch.title) + '</span><span class="vyt-wcard-meta">' + n + ' video' + (n === 1 ? '' : 's') +
'</span></div></div>';
esc(ch.title) + '</span><span class="vyt-wcard-meta">' + esc(meta) + '</span></div></div>';
}
// Followed playlists sit beside channels in the same grid; the ✕ unfollows.
@ -39,7 +39,9 @@
'<button class="vyt-wcard-unfollow" type="button" data-vyt-wunfollow-playlist="' + esc(pl.playlist_id) +
'" title="Unfollow">&#10005;</button>' +
'<div class="vyt-wcard-info"><span class="vyt-wcard-title" title="' + esc(pl.title) + '">' +
esc(pl.title) + '</span><span class="vyt-wcard-meta">Playlist</span></div></div>';
esc(pl.title) + '</span><span class="vyt-wcard-meta">' +
(pl.video_count > 0 ? esc(pl.video_count + ' video' + (pl.video_count === 1 ? '' : 's')) : 'Playlist') +
'</span></div></div>';
}
function $(s, r) { return (r || document).querySelector(s); }

View file

@ -151,7 +151,7 @@
function followPlaylist(pl) {
var p = pl || {};
return post('/api/video/youtube/playlist/follow', { playlist: {
playlist_id: p.playlist_id, title: p.title, thumbnail_url: p.thumbnail_url } });
playlist_id: p.playlist_id, title: p.title, thumbnail_url: p.thumbnail_url, videos: p.videos } });
}
function unfollowPlaylist(playlistId) {
return post('/api/video/youtube/playlist/unfollow', { playlist_id: playlistId });