video calendar: watchlist-driven by default + an 'All library' toggle

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.
This commit is contained in:
BoulderBadgeDad 2026-06-20 14:07:40 -07:00
parent 7061001f66
commit 49222dd0b8
5 changed files with 135 additions and 6 deletions

View file

@ -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,

View file

@ -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()

View file

@ -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

View file

@ -1136,6 +1136,10 @@
<span>Compact</span>
</button>
</div>
<div class="vcal-filter" role="group" aria-label="Source">
<button class="vcal-filter-btn vcal-filter-btn--on" type="button" data-video-cal-scope="watchlist" title="Only shows on your watchlist">Watchlist</button>
<button class="vcal-filter-btn" type="button" data-video-cal-scope="all" title="Every airing show in your library">All library</button>
</div>
<div class="vcal-filter" role="group" aria-label="Filter">
<button class="vcal-filter-btn vcal-filter-btn--on" type="button" data-video-cal-filter="all">All</button>
<button class="vcal-filter-btn" type="button" data-video-cal-filter="owned">In library</button>

View file

@ -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);
}