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.
This commit is contained in:
parent
d3c875919f
commit
bae8060c09
7 changed files with 123 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}."""
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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([]) == {}
|
||||
|
|
|
|||
|
|
@ -967,6 +967,11 @@
|
|||
<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="missing">Missing</button>
|
||||
</div>
|
||||
<button class="vcal-addmissing hidden" type="button" data-video-cal-addmissing
|
||||
title="Add already-aired missing episodes to your wishlist">
|
||||
<span class="vcal-addmissing-ic" aria-hidden="true">+</span>
|
||||
<span data-video-cal-addmissing-label>Add missing to wishlist</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; } }
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
Loading…
Reference in a new issue