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")
|
logger.exception("airs_time backfill queue failed")
|
||||||
|
|
||||||
from core.video.sources import resolve_video_server
|
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(),
|
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.
|
# Per-date counts drive the day-strip dots without a second query.
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
|
|
@ -64,6 +68,7 @@ def register_routes(bp):
|
||||||
"start": start.isoformat(), # window start (may be a future week)
|
"start": start.isoformat(), # window start (may be a future week)
|
||||||
"end": end.isoformat(),
|
"end": end.isoformat(),
|
||||||
"days": days,
|
"days": days,
|
||||||
|
"scope": "all" if scope == "all" else "watchlist",
|
||||||
"counts_by_date": counts,
|
"counts_by_date": counts,
|
||||||
"total": len(eps),
|
"total": len(eps),
|
||||||
"owned": owned,
|
"owned": owned,
|
||||||
|
|
|
||||||
|
|
@ -1631,16 +1631,28 @@ class VideoDatabase:
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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
|
"""Episodes airing in [start_date, end_date] (ISO) for shows on the active
|
||||||
video server (``server_source``) — the Calendar feed. Scoped to one server
|
video server (``server_source``) — the Calendar feed. Scoped to one server
|
||||||
so Plex and Jellyfin never commingle. Each row carries owned/missing
|
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.
|
# server_source given → that server only; None → all owned shows.
|
||||||
if server_source:
|
if server_source:
|
||||||
srv_where, pre = "s.server_source = ?", [server_source]
|
srv_where, pre = "s.server_source = ?", [server_source]
|
||||||
else:
|
else:
|
||||||
srv_where, pre = "s.server_source IS NOT NULL", []
|
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()
|
conn = self._get_connection()
|
||||||
try:
|
try:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
|
|
@ -1653,7 +1665,7 @@ class VideoDatabase:
|
||||||
"(s.backdrop_url IS NOT NULL AND s.backdrop_url<>'') AS show_has_backdrop "
|
"(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 "
|
"FROM episodes e JOIN shows s ON s.id = e.show_id "
|
||||||
"WHERE " + srv_where + " "
|
"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, "
|
"ORDER BY e.air_date, COALESCE(s.sort_title, s.title) COLLATE NOCASE, "
|
||||||
"e.season_number, e.episode_number",
|
"e.season_number, e.episode_number",
|
||||||
pre + [start_date, end_date]).fetchall()
|
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>
|
<span>Compact</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<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 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>
|
<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 = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
var MO_FULL = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August',
|
var MO_FULL = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August',
|
||||||
'September', 'October', 'November', 'December'];
|
'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 $(s) { return document.querySelector(s); }
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
|
|
@ -322,7 +322,8 @@
|
||||||
var grid = $('[data-video-cal-grid]'); if (grid && state.data) grid.classList.add('vcal-fading');
|
var grid = $('[data-video-cal-grid]'); if (grid && state.data) grid.classList.add('vcal-fading');
|
||||||
showEmpty(false); showLoading(true);
|
showEmpty(false); showLoading(true);
|
||||||
var base = new Date(); base.setHours(0, 0, 0, 0); base.setDate(base.getDate() + state.offset * 7);
|
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 (r) { return r.ok ? r.json() : null; })
|
||||||
.then(function (d) {
|
.then(function (d) {
|
||||||
showLoading(false);
|
showLoading(false);
|
||||||
|
|
@ -452,6 +453,10 @@
|
||||||
for (var k = 0; k < vbs.length; k++) (function (b) {
|
for (var k = 0; k < vbs.length; k++) (function (b) {
|
||||||
b.addEventListener('click', function () { setView(b.getAttribute('data-video-cal-view')); });
|
b.addEventListener('click', function () { setView(b.getAttribute('data-video-cal-view')); });
|
||||||
})(vbs[k]);
|
})(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]');
|
var addBtn = $('[data-video-cal-addmissing]');
|
||||||
if (addBtn) addBtn.addEventListener('click', 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);
|
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) {
|
function onPageShown(e) {
|
||||||
if (!e || e.detail !== PAGE_ID) return;
|
if (!e || e.detail !== PAGE_ID) return;
|
||||||
if (!state.loaded) { state.scrollToNow = true; load(); }
|
if (!state.loaded) { state.scrollToNow = true; load(); }
|
||||||
|
|
@ -615,7 +636,9 @@
|
||||||
function init() {
|
function init() {
|
||||||
wire();
|
wire();
|
||||||
try { var sv = localStorage.getItem('vcalView'); if (sv) state.view = sv; } catch (e) { /* private mode */ }
|
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();
|
applyView();
|
||||||
|
applyScope();
|
||||||
document.addEventListener('soulsync:video-page-shown', onPageShown);
|
document.addEventListener('soulsync:video-page-shown', onPageShown);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue