From 49222dd0b8d983df3408873d562adcb996aad996 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sat, 20 Jun 2026 14:07:40 -0700 Subject: [PATCH] video calendar: watchlist-driven by default + an 'All library' toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The calendar pulled every airing show in the library regardless of whether you follow it. Now it's scoped to the EFFECTIVE watchlist by default — explicit show follows ∪ airing library shows (not muted), same logic as the Shows watchlist tab — so it tracks what you actually care about, and you can mute a show off it. A 'Watchlist / All library' toggle on the calendar lets you flip to everything you own (remembered in localStorage). - calendar_upcoming(watchlist_only=) adds the watchlist filter; /calendar takes ?scope=watchlist|all (default watchlist). - Calendar page gets a scope toggle (defaults watchlist, persists, refetches on change). Tests: DB scope (followed-only / airing-default / mute drops out / all-library sees all) + frontend wiring. node --check clean. --- api/video/calendar.py | 7 ++- database/video_database.py | 18 +++++- tests/test_video_calendar_scope.py | 85 ++++++++++++++++++++++++++++ webui/index.html | 4 ++ webui/static/video/video-calendar.js | 27 ++++++++- 5 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 tests/test_video_calendar_scope.py diff --git a/api/video/calendar.py b/api/video/calendar.py index cef8829a..cec38508 100644 --- a/api/video/calendar.py +++ b/api/video/calendar.py @@ -49,8 +49,12 @@ def register_routes(bp): logger.exception("airs_time backfill queue failed") from core.video.sources import resolve_video_server + # scope: 'watchlist' (default — shows you follow/track) or 'all' (every + # airing show in the library). The toggle on the page sends ?scope=. + scope = (request.args.get("scope") or "watchlist").lower() eps = db.calendar_upcoming(start.isoformat(), end.isoformat(), - server_source=resolve_video_server()) + server_source=resolve_video_server(), + watchlist_only=(scope != "all")) # Per-date counts drive the day-strip dots without a second query. counts: dict[str, int] = {} @@ -64,6 +68,7 @@ def register_routes(bp): "start": start.isoformat(), # window start (may be a future week) "end": end.isoformat(), "days": days, + "scope": "all" if scope == "all" else "watchlist", "counts_by_date": counts, "total": len(eps), "owned": owned, diff --git a/database/video_database.py b/database/video_database.py index 1de9c17e..4e308c3b 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -1631,16 +1631,28 @@ class VideoDatabase: finally: conn.close() - def calendar_upcoming(self, start_date: str, end_date: str, server_source=None) -> list[dict]: + def calendar_upcoming(self, start_date: str, end_date: str, server_source=None, + watchlist_only: bool = False) -> list[dict]: """Episodes airing in [start_date, end_date] (ISO) for shows on the active video server (``server_source``) — the Calendar feed. Scoped to one server so Plex and Jellyfin never commingle. Each row carries owned/missing - (has_file), a still flag, and show network/airs_time/year for the card.""" + (has_file), a still flag, and show network/airs_time/year for the card. + + ``watchlist_only`` restricts to the EFFECTIVE watchlist — explicit show + follows ∪ airing library shows (not muted), mirroring _effective_shows / + the Shows watchlist tab — so the calendar tracks what you follow.""" # server_source given → that server only; None → all owned shows. if server_source: srv_where, pre = "s.server_source = ?", [server_source] else: srv_where, pre = "s.server_source IS NOT NULL", [] + wl_where = "" + if watchlist_only: + active = self._ACTIVE_SHOW_SQL.replace("status", "s.status") + wl_where = ( + " AND (s.tmdb_id IN (SELECT tmdb_id FROM video_watchlist WHERE kind='show' AND state='follow')" + " OR (" + active + " AND s.tmdb_id NOT IN " + "(SELECT tmdb_id FROM video_watchlist WHERE kind='show' AND state='mute')))") conn = self._get_connection() try: rows = conn.execute( @@ -1653,7 +1665,7 @@ class VideoDatabase: "(s.backdrop_url IS NOT NULL AND s.backdrop_url<>'') AS show_has_backdrop " "FROM episodes e JOIN shows s ON s.id = e.show_id " "WHERE " + srv_where + " " - "AND e.air_date IS NOT NULL AND e.air_date >= ? AND e.air_date <= ? " + "AND e.air_date IS NOT NULL AND e.air_date >= ? AND e.air_date <= ?" + wl_where + " " "ORDER BY e.air_date, COALESCE(s.sort_title, s.title) COLLATE NOCASE, " "e.season_number, e.episode_number", pre + [start_date, end_date]).fetchall() diff --git a/tests/test_video_calendar_scope.py b/tests/test_video_calendar_scope.py new file mode 100644 index 00000000..ca8b49a0 --- /dev/null +++ b/tests/test_video_calendar_scope.py @@ -0,0 +1,85 @@ +"""Calendar source scope: watchlist (default) vs all-library. + +watchlist_only restricts the feed to the EFFECTIVE watchlist — explicit show +follows ∪ airing library shows (not muted) — so the calendar tracks what you +follow, with an 'All library' escape hatch. +""" + +from __future__ import annotations + +import pytest + +from database.video_database import VideoDatabase + + +@pytest.fixture() +def db(tmp_path): + return VideoDatabase(database_path=str(tmp_path / "video_library.db")) + + +def _seed_show(db, tmdb_id, title, server_id, air_date="2026-06-20"): + return db.upsert_show_tree("plex", { + "server_id": server_id, "title": title, "tmdb_id": tmdb_id, + "seasons": [{"season_number": 1, "episodes": [ + {"episode_number": 1, "title": "E1", "air_date": air_date}]}]}) + + +def _set_status(db, show_id, status): + conn = db._get_connection() + conn.execute("UPDATE shows SET status=? WHERE id=?", (status, show_id)) + conn.commit() + conn.close() + + +def _tmdbs(rows): + return {r["show_tmdb_id"] for r in rows} + + +def test_watchlist_scope_only_returns_followed_shows(db): + _seed_show(db, 11, "A", "sA") + _seed_show(db, 22, "B", "sB") + db.add_to_watchlist("show", 11, "A") # follow A only (B is not airing → not auto-included) + + allp = db.calendar_upcoming("2026-06-01", "2026-06-30", server_source="plex", watchlist_only=False) + wl = db.calendar_upcoming("2026-06-01", "2026-06-30", server_source="plex", watchlist_only=True) + assert _tmdbs(allp) == {11, 22} # all-library sees both + assert _tmdbs(wl) == {11} # watchlist sees only the followed one + + +def test_watchlist_scope_includes_airing_default_and_respects_mute(db): + a = _seed_show(db, 33, "Airing", "sA") + _set_status(db, a, "Returning Series") + + # airing show, not explicitly followed → still in the watchlist scope by default + wl = db.calendar_upcoming("2026-06-01", "2026-06-30", server_source="plex", watchlist_only=True) + assert _tmdbs(wl) == {33} + + # mute it → drops out of the watchlist scope (but still in all-library) + db.remove_from_watchlist("show", 33) # stores a 'mute' tombstone + wl2 = db.calendar_upcoming("2026-06-01", "2026-06-30", server_source="plex", watchlist_only=True) + assert wl2 == [] + allp = db.calendar_upcoming("2026-06-01", "2026-06-30", server_source="plex", watchlist_only=False) + assert _tmdbs(allp) == {33} + + +# ── frontend wiring (toggle defaults to watchlist, persists, refetches) ───── +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent +_JS = (_ROOT / "webui" / "static" / "video" / "video-calendar.js").read_text(encoding="utf-8") +_INDEX = (_ROOT / "webui" / "index.html").read_text(encoding="utf-8") + + +def test_calendar_has_scope_toggle_defaulting_to_watchlist(): + assert 'data-video-cal-scope="watchlist"' in _INDEX + assert 'data-video-cal-scope="all"' in _INDEX + # the watchlist button is the one pre-selected (--on) + i = _INDEX.index('data-video-cal-scope="watchlist"') + assert 'vcal-filter-btn--on' in _INDEX[i - 80:i] + + +def test_calendar_js_defaults_watchlist_and_sends_scope(): + assert "scope: 'watchlist'" in _JS # default state + assert "'&scope=' + (state.scope" in _JS # sent to the API + assert "function setScope(" in _JS # toggle refetches + assert "localStorage.setItem('vcalScope'" in _JS # remembers the choice diff --git a/webui/index.html b/webui/index.html index 1f3e2fbf..df9e54d4 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1136,6 +1136,10 @@ Compact +
+ + +
diff --git a/webui/static/video/video-calendar.js b/webui/static/video/video-calendar.js index 7761f083..8aab614b 100644 --- a/webui/static/video/video-calendar.js +++ b/webui/static/video/video-calendar.js @@ -18,7 +18,7 @@ var MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; var MO_FULL = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; - var state = { loaded: false, eps: {}, data: null, offset: 0, filter: 'all', view: 'compact' }; + var state = { loaded: false, eps: {}, data: null, offset: 0, filter: 'all', view: 'compact', scope: 'watchlist' }; function $(s) { return document.querySelector(s); } function esc(s) { @@ -322,7 +322,8 @@ var grid = $('[data-video-cal-grid]'); if (grid && state.data) grid.classList.add('vcal-fading'); showEmpty(false); showLoading(true); var base = new Date(); base.setHours(0, 0, 0, 0); base.setDate(base.getDate() + state.offset * 7); - fetch(URL + '?days=7&start=' + isoOf(base), { headers: { 'Accept': 'application/json' } }) + fetch(URL + '?days=7&start=' + isoOf(base) + '&scope=' + (state.scope || 'watchlist'), + { headers: { 'Accept': 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { showLoading(false); @@ -452,6 +453,10 @@ for (var k = 0; k < vbs.length; k++) (function (b) { b.addEventListener('click', function () { setView(b.getAttribute('data-video-cal-view')); }); })(vbs[k]); + var sbs = document.querySelectorAll('[data-video-cal-scope]'); + for (var j = 0; j < sbs.length; j++) (function (b) { + b.addEventListener('click', function () { setScope(b.getAttribute('data-video-cal-scope')); }); + })(sbs[j]); var addBtn = $('[data-video-cal-addmissing]'); if (addBtn) addBtn.addEventListener('click', addMissing); @@ -606,6 +611,22 @@ vbs[i].classList.toggle('vcal-view-btn--on', vbs[i].getAttribute('data-video-cal-view') === state.view); } + // Source switcher (watchlist ↔ all library). Unlike the filter, this changes + // WHICH shows the server returns, so it refetches. Remembers the choice. + function setScope(sc) { + if (sc !== 'watchlist' && sc !== 'all') return; + if (sc === state.scope) return; + state.scope = sc; + try { localStorage.setItem('vcalScope', sc); } catch (e) { /* private mode */ } + applyScope(); + load(); + } + function applyScope() { + var sbs = document.querySelectorAll('[data-video-cal-scope]'); + for (var i = 0; i < sbs.length; i++) + sbs[i].classList.toggle('vcal-filter-btn--on', sbs[i].getAttribute('data-video-cal-scope') === state.scope); + } + function onPageShown(e) { if (!e || e.detail !== PAGE_ID) return; if (!state.loaded) { state.scrollToNow = true; load(); } @@ -615,7 +636,9 @@ function init() { wire(); try { var sv = localStorage.getItem('vcalView'); if (sv) state.view = sv; } catch (e) { /* private mode */ } + try { var sc = localStorage.getItem('vcalScope'); if (sc === 'watchlist' || sc === 'all') state.scope = sc; } catch (e) { /* private mode */ } applyView(); + applyScope(); document.addEventListener('soulsync:video-page-shown', onPageShown); }