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; } }
/* ══════════════════════════════════════════════════════════════════════════