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 +