From bae8060c09d1300d5c2c0409adfc8c8eadffdf7e Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 16 Jun 2026 16:33:48 -0700 Subject: [PATCH] Calendar: 'Add missing to wishlist' catch-up button A manual safety-net for the auto-promoter: queues episodes that have ALREADY aired, are missing, and aren't yet on the wishlist. Upcoming episodes are left alone (the calendar promotes them once they air), so it's a no-op on the current/ future weeks and useful when you page back to a past one. - calendar_upcoming now returns the show's tmdb_id. - /wishlist/check accepts {shows:[...]} -> by_show membership (db.wishlist_keys_ for_shows), so the button only counts/adds what's genuinely not yet queued. - Calendar: computes aired-missing (air_date < today, !has_file), checks wishlist membership, shows 'Add N missing to wishlist' when there's net-new; click groups by show -> /wishlist/add, toasts, fires soulsync:video-wishlist-changed, recomputes. Tests: +2 (wishlist_keys_for_shows, /wishlist/check by_show). Backend: 100 passed. --- api/video/wishlist.py | 11 +++-- database/video_database.py | 21 ++++++++++ tests/test_video_api.py | 8 ++++ tests/test_video_database.py | 9 ++++ webui/index.html | 5 +++ webui/static/video/video-calendar.js | 63 ++++++++++++++++++++++++++++ webui/static/video/video-side.css | 9 ++++ 7 files changed, 123 insertions(+), 3 deletions(-) diff --git a/api/video/wishlist.py b/api/video/wishlist.py index 5a0f39fa..487d6278 100644 --- a/api/video/wishlist.py +++ b/api/video/wishlist.py @@ -115,10 +115,15 @@ def register_routes(bp): from . import get_video_db body = request.get_json(silent=True) or {} try: - st = get_video_db().wishlist_state( + db = get_video_db() + st = db.wishlist_state( movie_ids=body.get("movie_ids") or [], show_tmdb_id=body.get("show_tmdb_id")) - return jsonify({"success": True, "movies": sorted(st["movies"]), - "episodes": sorted(st["episodes"])}) + out = {"success": True, "movies": sorted(st["movies"]), "episodes": sorted(st["episodes"])} + shows = body.get("shows") # multi-show membership for the calendar button + if shows: + keys = db.wishlist_keys_for_shows(shows) + out["by_show"] = {str(tid): sorted(ks) for tid, ks in keys.items()} + return jsonify(out) except Exception: logger.exception("Failed to check video wishlist") return jsonify({"success": False, "error": "Failed"}), 500 diff --git a/database/video_database.py b/database/video_database.py index 3cf6bfb2..f48a0de8 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -1147,6 +1147,7 @@ class VideoDatabase: "SELECT e.id, e.show_id, e.season_number, e.episode_number, e.title, " "e.overview, e.air_date, e.runtime_minutes, e.rating, e.has_file, e.monitored, " "(e.still_url IS NOT NULL AND e.still_url<>'') AS has_still, " + "s.tmdb_id AS show_tmdb_id, " "s.title AS show_title, s.network, s.airs_time, s.year AS show_year, s.status AS show_status, " "(s.poster_url IS NOT NULL AND s.poster_url<>'') AS show_has_poster, " "(s.backdrop_url IS NOT NULL AND s.backdrop_url<>'') AS show_has_backdrop " @@ -1641,6 +1642,26 @@ class VideoDatabase: "page": page, "total_pages": total_pages, "total_count": total, "has_prev": page > 1, "has_next": page < total_pages}} + def wishlist_keys_for_shows(self, show_tmdb_ids) -> dict: + """{show_tmdb_id: set('S_E')} of episodes already wishlisted — lets the + calendar's 'add missing' button skip what's already queued.""" + out: dict = {} + ids = [int(x) for x in (show_tmdb_ids or []) if x] + if not ids: + return out + conn = self._get_connection() + try: + for i in range(0, len(ids), 400): + chunk = ids[i:i + 400] + ph = ",".join("?" * len(chunk)) + for r in conn.execute( + f"SELECT tmdb_id, season_number, episode_number FROM video_wishlist " + f"WHERE kind='episode' AND tmdb_id IN ({ph})", chunk): + out.setdefault(r["tmdb_id"], set()).add("%s_%s" % (r["season_number"], r["episode_number"])) + return out + finally: + conn.close() + def wishlist_state(self, *, movie_ids=None, show_tmdb_id=None) -> dict: """Hydration: which of ``movie_ids`` are wishlisted, and which episode keys ('S_E') of ``show_tmdb_id`` are. Returns {movies:set, episodes:set}.""" diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 80925516..c80ca99d 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -487,3 +487,11 @@ def test_wishlist_routes_registered(): for r in ("/api/video/wishlist", "/api/video/wishlist/add", "/api/video/wishlist/remove", "/api/video/wishlist/check", "/api/video/wishlist/counts"): assert r in rules + + +def test_wishlist_check_by_show(tmp_path): + client, _ = _make_client(tmp_path) + client.post("/api/video/wishlist/add", json={ + "show": {"tmdb_id": 1396, "title": "BB"}, "episodes": [{"season_number": 1, "episode_number": 1}]}) + res = client.post("/api/video/wishlist/check", json={"shows": [1396, 1399]}).get_json() + assert res["by_show"]["1396"] == ["1_1"] and "1399" not in res["by_show"] diff --git a/tests/test_video_database.py b/tests/test_video_database.py index 6c43eedf..b63c26de 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -921,3 +921,12 @@ def test_wishlist_query_search_and_paging(db): p1 = db.query_wishlist("movie", page=1, limit=2) assert len(p1["items"]) == 2 and p1["pagination"]["total_pages"] == 3 assert db.query_wishlist("movie", page=99, limit=2)["pagination"]["page"] == 3 # clamps + + +def test_wishlist_keys_for_shows(db): + db.add_episodes_to_wishlist(1396, "BB", [{"season_number": 1, "episode_number": 1}, + {"season_number": 2, "episode_number": 3}]) + db.add_episodes_to_wishlist(1399, "GoT", [{"season_number": 1, "episode_number": 1}]) + keys = db.wishlist_keys_for_shows([1396, 1399, 9999]) + assert keys[1396] == {"1_1", "2_3"} and keys[1399] == {"1_1"} and 9999 not in keys + assert db.wishlist_keys_for_shows([]) == {} diff --git a/webui/index.html b/webui/index.html index df4650a4..4e7fee96 100644 --- a/webui/index.html +++ b/webui/index.html @@ -967,6 +967,11 @@ + diff --git a/webui/static/video/video-calendar.js b/webui/static/video/video-calendar.js index 57567634..c36b3ea9 100644 --- a/webui/static/video/video-calendar.js +++ b/webui/static/video/video-calendar.js @@ -291,10 +291,71 @@ state.eps = {}; (d.episodes || []).forEach(function (e) { state.eps[e.id] = e; }); render(); + refreshAddMissing(); // surface the catch-up button for aired-missing eps }) .catch(function () { showLoading(false); state.data = null; render(); }); } + // ── "Add missing to wishlist" (catch-up for the auto-promoter) ──────────── + // Targets already-AIRED, MISSING episodes in this week that aren't yet on the + // wishlist. Upcoming episodes are left alone (the calendar promotes them once + // they air). Mostly a no-op on the current/future weeks; useful on past ones. + function airedMissing() { + var d = state.data; if (!d) return []; + var today = d.today; + return (d.episodes || []).filter(function (e) { + return !e.has_file && e.show_tmdb_id && e.air_date && e.air_date < today; + }); + } + function refreshAddMissing() { + var btn = $('[data-video-cal-addmissing]'); if (!btn) return; + state.addMissing = []; + btn.classList.add('hidden'); + var cand = airedMissing(); + if (!cand.length) return; + var ids = []; cand.forEach(function (e) { if (ids.indexOf(e.show_tmdb_id) < 0) ids.push(e.show_tmdb_id); }); + fetch('/api/video/wishlist/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ shows: ids }) }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (res) { + var by = (res && res.by_show) || {}; + var fresh = cand.filter(function (e) { + var have = by[String(e.show_tmdb_id)] || []; + return have.indexOf(e.season_number + '_' + e.episode_number) < 0; + }); + state.addMissing = fresh; + var lbl = $('[data-video-cal-addmissing-label]'); + if (lbl) lbl.textContent = 'Add ' + fresh.length + ' missing to wishlist'; + btn.classList.toggle('hidden', !fresh.length); + }) + .catch(function () { /* leave hidden */ }); + } + function addMissing() { + var eps = state.addMissing || []; if (!eps.length) return; + var btn = $('[data-video-cal-addmissing]'); if (btn) btn.disabled = true; + var byShow = {}; + eps.forEach(function (e) { + var g = byShow[e.show_tmdb_id] || (byShow[e.show_tmdb_id] = { + tmdb_id: e.show_tmdb_id, title: e.show_title, + poster_url: e.show_has_poster ? ('/api/video/poster/show/' + e.show_id) : null, + library_id: e.show_id, episodes: [] }); + g.episodes.push({ season_number: e.season_number, episode_number: e.episode_number, + title: e.title, air_date: e.air_date }); + }); + var posts = Object.keys(byShow).map(function (k) { + var g = byShow[k]; + return fetch('/api/video/wishlist/add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ show: { tmdb_id: g.tmdb_id, title: g.title, poster_url: g.poster_url, + library_id: g.library_id }, episodes: g.episodes }) }).then(function (r) { return r.ok ? r.json() : null; }); + }); + Promise.all(posts).then(function () { + if (typeof showToast === 'function') showToast('Added ' + eps.length + ' missing episode' + (eps.length === 1 ? '' : 's') + ' to wishlist', 'success'); + document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed')); + if (btn) btn.disabled = false; + refreshAddMissing(); // recompute → button hides what's now queued + }).catch(function () { if (btn) btn.disabled = false; }); + } + function openFrom(target) { var el = target.closest('[data-cal-ep]'); if (!el) return false; @@ -344,6 +405,8 @@ for (var i = 0; i < fbs.length; i++) (function (b) { b.addEventListener('click', function () { state.filter = b.getAttribute('data-video-cal-filter'); render(); }); })(fbs[i]); + var addBtn = $('[data-video-cal-addmissing]'); + if (addBtn) addBtn.addEventListener('click', addMissing); // Keyboard nav for a page users live in: ← / → step weeks, T jumps to // today. Only when the calendar is the visible page, no modal is open, diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 82bc5437..52897e16 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -1773,6 +1773,15 @@ body[data-side="video"] #soulsync-toggle { display: none; } padding: 7px 14px; border-radius: 8px; cursor: pointer; transition: all 0.15s ease; } .vcal-filter-btn:hover { color: #fff; background: rgba(255, 255, 255, 0.08); } .vcal-filter-btn--on { color: #fff; background: rgba(var(--vcal-accent), 0.9); box-shadow: 0 4px 14px rgba(var(--vcal-accent), 0.32); } +/* catch-up button: queue already-aired missing episodes to the wishlist */ +.vcal-addmissing { display: inline-flex; align-items: center; gap: 7px; padding: 9px 16px; border-radius: 11px; + border: 1px solid rgba(var(--accent-rgb, 88 101 242), 0.4); cursor: pointer; font-size: 12.5px; font-weight: 800; + color: #fff; background: rgba(var(--accent-rgb, 88 101 242), 0.16); + transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease; } +.vcal-addmissing:hover { background: rgba(var(--accent-rgb, 88 101 242), 0.3); border-color: rgba(var(--accent-rgb, 88 101 242), 0.7); transform: translateY(-1px); } +.vcal-addmissing:disabled { opacity: 0.5; cursor: default; transform: none; } +.vcal-addmissing.hidden { display: none; } +.vcal-addmissing-ic { font-size: 15px; font-weight: 900; line-height: 1; } @media (max-width: 720px) { .vcal-head-row { flex-direction: column; align-items: stretch; } .vcal-controls { justify-content: space-between; } } /* ══════════════════════════════════════════════════════════════════════════