From ba7562c5f2a7b2f5897d435e1c25839330b0f3d9 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 17 Jun 2026 21:13:33 -0700 Subject: [PATCH] Watchlist cards: show the channel/playlist video count, not '0 videos' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- api/video/youtube.py | 9 +++++++++ database/video_database.py | 21 ++++++++++++++------- tests/test_video_api.py | 6 ++++-- tests/test_video_database.py | 3 ++- webui/static/video/video-watchlist.js | 10 ++++++---- webui/static/video/video-youtube.js | 2 +- 6 files changed, 36 insertions(+), 15 deletions(-) diff --git a/api/video/youtube.py b/api/video/youtube.py index 2a26c15a..ecf12fe6 100644 --- a/api/video/youtube.py +++ b/api/video/youtube.py @@ -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"}) diff --git a/database/video_database.py b/database/video_database.py index 79294083..dd4f1ad4 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -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() diff --git a/tests/test_video_api.py b/tests/test_video_api.py index c1f418ef..ba39aafd 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -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() diff --git a/tests/test_video_database.py b/tests/test_video_database.py index 4877dada..9478682e 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -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): diff --git a/webui/static/video/video-watchlist.js b/webui/static/video/video-watchlist.js index 44207c1b..3e7ac55d 100644 --- a/webui/static/video/video-watchlist.js +++ b/webui/static/video/video-watchlist.js @@ -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 '
' + '
' + av + '
' + '' + '
' + - esc(ch.title) + '' + n + ' video' + (n === 1 ? '' : 's') + - '
'; + esc(ch.title) + '' + esc(meta) + ''; } // Followed playlists sit beside channels in the same grid; the ✕ unfollows. @@ -39,7 +39,9 @@ '' + '
' + - esc(pl.title) + 'Playlist
'; + esc(pl.title) + '' + + (pl.video_count > 0 ? esc(pl.video_count + ' video' + (pl.video_count === 1 ? '' : 's')) : 'Playlist') + + ''; } function $(s, r) { return (r || document).querySelector(s); } diff --git a/webui/static/video/video-youtube.js b/webui/static/video/video-youtube.js index e1045a1b..c604b479 100644 --- a/webui/static/video/video-youtube.js +++ b/webui/static/video/video-youtube.js @@ -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 });