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:
parent
7061001f66
commit
49222dd0b8
5 changed files with 135 additions and 6 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
85
tests/test_video_calendar_scope.py
Normal file
85
tests/test_video_calendar_scope.py
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue