diff --git a/api/video/__init__.py b/api/video/__init__.py index baba7598..f4844fc2 100644 --- a/api/video/__init__.py +++ b/api/video/__init__.py @@ -46,6 +46,7 @@ def create_video_blueprint() -> Blueprint: from .detail import register_routes as reg_detail from .search import register_routes as reg_search from .calendar import register_routes as reg_calendar + from .watchlist import register_routes as reg_watchlist reg_dashboard(bp) reg_scan(bp) reg_library(bp) @@ -55,5 +56,6 @@ def create_video_blueprint() -> Blueprint: reg_detail(bp) reg_search(bp) reg_calendar(bp) + reg_watchlist(bp) return bp diff --git a/api/video/watchlist.py b/api/video/watchlist.py new file mode 100644 index 00000000..f889a9ca --- /dev/null +++ b/api/video/watchlist.py @@ -0,0 +1,104 @@ +"""Video watchlist API — the user's curated follow-list of shows + people. + +Mirrors the music watchlist's add/remove/list/check shape. v1 just manages +membership (so cards can toggle + the Watchlist page can render); the +monitoring/discovery engine that turns a follow into new downloads is a later +phase. Reads/writes only video_library.db via the shared VideoDatabase. +""" + +from __future__ import annotations + +from flask import jsonify, request + +from utils.logging_config import get_logger + +logger = get_logger("video_api.watchlist") + +_KINDS = ("show", "person") + + +def register_routes(bp): + @bp.route("/watchlist", methods=["GET"]) + def video_watchlist_list(): + """All watchlist entries grouped by kind (for the tabbed page).""" + from . import get_video_db + try: + db = get_video_db() + kind = request.args.get("kind") + if kind in _KINDS: + items = db.list_watchlist(kind) + return jsonify({"success": True, "kind": kind, "items": items}) + rows = db.list_watchlist() + shows = [r for r in rows if r.get("kind") == "show"] + people = [r for r in rows if r.get("kind") == "person"] + return jsonify({"success": True, "shows": shows, "people": people, + "counts": {"show": len(shows), "person": len(people), + "total": len(rows)}}) + except Exception: + logger.exception("Failed to list video watchlist") + return jsonify({"success": False, "error": "Failed to load watchlist"}), 500 + + @bp.route("/watchlist/counts", methods=["GET"]) + def video_watchlist_counts(): + from . import get_video_db + try: + return jsonify({"success": True, **get_video_db().watchlist_counts()}) + except Exception: + logger.exception("Failed to count video watchlist") + return jsonify({"success": False, "error": "Failed"}), 500 + + @bp.route("/watchlist/add", methods=["POST"]) + def video_watchlist_add(): + """Add a show/person. Body: {kind, tmdb_id, title, poster_url?, library_id?}.""" + from . import get_video_db + body = request.get_json(silent=True) or {} + kind = body.get("kind") + tmdb_id = body.get("tmdb_id") + title = (body.get("title") or "").strip() + if kind not in _KINDS or not tmdb_id or not title: + return jsonify({"success": False, "error": "kind, tmdb_id and title are required"}), 400 + try: + ok = get_video_db().add_to_watchlist( + kind, int(tmdb_id), title, + poster_url=body.get("poster_url") or None, + library_id=body.get("library_id") or None) + if not ok: + return jsonify({"success": False, "error": "Could not add to watchlist"}), 400 + return jsonify({"success": True, "watched": True}) + except Exception: + logger.exception("Failed to add to video watchlist") + return jsonify({"success": False, "error": "Failed"}), 500 + + @bp.route("/watchlist/remove", methods=["POST"]) + def video_watchlist_remove(): + """Remove a show/person. Body: {kind, tmdb_id}.""" + from . import get_video_db + body = request.get_json(silent=True) or {} + kind = body.get("kind") + tmdb_id = body.get("tmdb_id") + if kind not in _KINDS or not tmdb_id: + return jsonify({"success": False, "error": "kind and tmdb_id are required"}), 400 + try: + removed = get_video_db().remove_from_watchlist(kind, int(tmdb_id)) + return jsonify({"success": True, "watched": False, "removed": removed}) + except Exception: + logger.exception("Failed to remove from video watchlist") + return jsonify({"success": False, "error": "Failed"}), 500 + + @bp.route("/watchlist/check", methods=["POST"]) + def video_watchlist_check(): + """Hydrate cards. Body: {kind, tmdb_ids: [...]} → {results: {id: true}}. + Only watched ids appear in results (absent = not watched).""" + from . import get_video_db + body = request.get_json(silent=True) or {} + kind = body.get("kind") + ids = body.get("tmdb_ids") or [] + if kind not in _KINDS: + return jsonify({"success": False, "error": "kind is required"}), 400 + try: + state = get_video_db().watchlist_state(kind, ids) + # JSON object keys must be strings. + return jsonify({"success": True, "results": {str(k): True for k in state}}) + except Exception: + logger.exception("Failed to check video watchlist") + return jsonify({"success": False, "error": "Failed"}), 500 diff --git a/database/video_database.py b/database/video_database.py index ec73f124..1797b0c3 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -29,7 +29,7 @@ logger = get_logger("video_database") # Bump when video_schema.sql changes in a way worth recording. Stored in # PRAGMA user_version as a backstop indicator (nothing gates on it yet). -SCHEMA_VERSION = 7 +SCHEMA_VERSION = 8 _DEFAULT_DB_PATH = "database/video_library.db" _SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql" @@ -1208,6 +1208,95 @@ class VideoDatabase: finally: conn.close() + # ── User watchlist (curated follow-list: shows + people) ────────────────── + # Mirrors the music watchlist_artists model: an explicit follow-list that may + # include shows/people not in the library yet. Keyed on (kind, tmdb_id). The + # monitoring/discovery engine is a later phase — these just manage membership. + def add_to_watchlist(self, kind: str, tmdb_id: int, title: str, + poster_url: str | None = None, library_id: int | None = None) -> bool: + """Add a show or person. Idempotent upsert on (kind, tmdb_id) — re-adding + refreshes title/poster/library_id without duplicating. Returns True on success.""" + if kind not in ("show", "person") or not tmdb_id or not title: + return False + conn = self._get_connection() + try: + conn.execute( + """INSERT INTO video_watchlist (kind, tmdb_id, title, poster_url, library_id) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(kind, tmdb_id) DO UPDATE SET + title=excluded.title, + poster_url=COALESCE(excluded.poster_url, video_watchlist.poster_url), + library_id=COALESCE(excluded.library_id, video_watchlist.library_id)""", + (kind, int(tmdb_id), title, poster_url, library_id)) + conn.commit() + return True + except Exception: + logger.exception("add_to_watchlist failed (%s %s)", kind, tmdb_id) + return False + finally: + conn.close() + + def remove_from_watchlist(self, kind: str, tmdb_id: int) -> bool: + """Drop a show/person from the watchlist. Returns True if a row went.""" + if kind not in ("show", "person") or not tmdb_id: + return False + conn = self._get_connection() + try: + cur = conn.execute("DELETE FROM video_watchlist WHERE kind=? AND tmdb_id=?", + (kind, int(tmdb_id))) + conn.commit() + return cur.rowcount > 0 + finally: + conn.close() + + def list_watchlist(self, kind: str | None = None) -> list[dict]: + """Watchlist entries newest-first; optionally filtered to one kind.""" + conn = self._get_connection() + try: + sql = ("SELECT kind, tmdb_id, title, poster_url, library_id, date_added " + "FROM video_watchlist") + args: list = [] + if kind in ("show", "person"): + sql += " WHERE kind=?" + args.append(kind) + sql += " ORDER BY date_added DESC, id DESC" + return [dict(r) for r in conn.execute(sql, args).fetchall()] + finally: + conn.close() + + def watchlist_state(self, kind: str, tmdb_ids) -> dict: + """{tmdb_id: True} for the given ids that are on the watchlist — used to + hydrate card buttons. Ids not on the list are simply absent.""" + out: dict = {} + ids = [int(x) for x in (tmdb_ids or []) if x] + if kind not in ("show", "person") or not ids: + return out + conn = self._get_connection() + try: + for i in range(0, len(ids), 400): # stay under SQLite's variable cap + chunk = ids[i:i + 400] + ph = ",".join("?" * len(chunk)) + rows = conn.execute( + f"SELECT tmdb_id FROM video_watchlist WHERE kind=? AND tmdb_id IN ({ph})", + [kind] + chunk).fetchall() + for r in rows: + out[r["tmdb_id"]] = True + return out + finally: + conn.close() + + def watchlist_counts(self) -> dict: + """{'show': n, 'person': n, 'total': n} for badges/headers.""" + conn = self._get_connection() + try: + rows = conn.execute( + "SELECT kind, COUNT(*) AS n FROM video_watchlist GROUP BY kind").fetchall() + counts = {r["kind"]: r["n"] for r in rows} + return {"show": counts.get("show", 0), "person": counts.get("person", 0), + "total": sum(counts.values())} + finally: + conn.close() + def movie_detail(self, movie_id: int) -> dict | None: """Full movie detail: the movie + owned/file info. Drives the (isolated) video movie-detail page.""" @@ -1290,7 +1379,7 @@ class VideoDatabase: }.get(sort, title_key) if is_shows: - select = ("SELECT s.id, s.title, s.year, " + select = ("SELECT s.id, s.title, s.year, s.tmdb_id, " "(s.poster_url IS NOT NULL AND s.poster_url <> '') AS has_poster, " "(SELECT COUNT(*) FROM episodes e WHERE e.show_id=s.id) AS episode_count, " "(SELECT COUNT(*) FROM episodes e WHERE e.show_id=s.id AND e.has_file=1) AS owned_count " diff --git a/database/video_schema.sql b/database/video_schema.sql index d4603e2b..067e484f 100644 --- a/database/video_schema.sql +++ b/database/video_schema.sql @@ -330,6 +330,24 @@ CREATE TABLE IF NOT EXISTS activity ( ); CREATE INDEX IF NOT EXISTS idx_activity_created ON activity(created_at); +-- ── User watchlist (curated follow-list: shows + people) ──────────────────── +-- DISTINCT from the library-derived v_watchlist below: this is the user's +-- explicit follow-list and may include shows/people that are NOT in the library +-- yet (the whole point of following someone). Keyed on the stable cross-context +-- tmdb_id that both shows and people carry. The monitoring/discovery engine is a +-- later phase — this table just records membership + enough to render + link. +CREATE TABLE IF NOT EXISTS video_watchlist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, -- 'show' | 'person' + tmdb_id INTEGER NOT NULL, + title TEXT NOT NULL, -- show title / person name + poster_url TEXT, -- poster (show) / photo (person) + library_id INTEGER, -- shows.id when owned (else NULL) + date_added TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(kind, tmdb_id) +); +CREATE INDEX IF NOT EXISTS idx_video_watchlist_kind ON video_watchlist(kind); + -- ── Derived views: Watchlist / Wishlist / Calendar ────────────────────────── -- WATCHLIST = things you follow for NEW content: monitored shows + channels. CREATE VIEW IF NOT EXISTS v_watchlist AS diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 768295a0..ef5573bc 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -291,3 +291,63 @@ def test_video_api_imports_nothing_from_music(): s = line.strip() if s.startswith("import ") or s.startswith("from "): assert "music" not in s.lower(), f"{py.name}: music import leaked: {s!r}" + + +# ── Watchlist endpoints (shows + people) ──────────────────────────────── + +def test_watchlist_routes_registered(): + from api.video import create_video_blueprint + app = Flask(__name__) + app.register_blueprint(create_video_blueprint(), url_prefix="/api/video") + rules = {r.rule for r in app.url_map.iter_rules()} + for r in ("/api/video/watchlist", "/api/video/watchlist/add", + "/api/video/watchlist/remove", "/api/video/watchlist/check", + "/api/video/watchlist/counts"): + assert r in rules, r + + +def test_watchlist_add_check_list_remove_roundtrip(tmp_path): + client, _ = _make_client(tmp_path) + + # empty to start + assert client.get("/api/video/watchlist").get_json() == { + "success": True, "shows": [], "people": [], + "counts": {"show": 0, "person": 0, "total": 0}} + + # add a show + a person + r = client.post("/api/video/watchlist/add", json={ + "kind": "show", "tmdb_id": 1399, "title": "Game of Thrones", + "poster_url": "/p.jpg", "library_id": 7}) + assert r.get_json() == {"success": True, "watched": True} + client.post("/api/video/watchlist/add", json={ + "kind": "person", "tmdb_id": 287, "title": "Brad Pitt"}) + + # list groups by kind + data = client.get("/api/video/watchlist").get_json() + assert data["counts"] == {"show": 1, "person": 1, "total": 2} + assert data["shows"][0]["title"] == "Game of Thrones" + assert data["people"][0]["tmdb_id"] == 287 + + # check (hydration) — only watched ids come back, keys are strings + chk = client.post("/api/video/watchlist/check", + json={"kind": "show", "tmdb_ids": [1399, 9999]}).get_json() + assert chk == {"success": True, "results": {"1399": True}} + + # counts endpoint + assert client.get("/api/video/watchlist/counts").get_json() == { + "success": True, "show": 1, "person": 1, "total": 2} + + # remove + rem = client.post("/api/video/watchlist/remove", + json={"kind": "show", "tmdb_id": 1399}).get_json() + assert rem["success"] is True and rem["watched"] is False and rem["removed"] is True + assert client.get("/api/video/watchlist/counts").get_json()["show"] == 0 + + +def test_watchlist_add_validates_input(tmp_path): + client, _ = _make_client(tmp_path) + assert client.post("/api/video/watchlist/add", json={"kind": "movie", "tmdb_id": 1, "title": "x"}).status_code == 400 + assert client.post("/api/video/watchlist/add", json={"kind": "show", "title": "no id"}).status_code == 400 + assert client.post("/api/video/watchlist/add", json={"kind": "show", "tmdb_id": 1}).status_code == 400 # no title + assert client.post("/api/video/watchlist/remove", json={"kind": "person"}).status_code == 400 + assert client.post("/api/video/watchlist/check", json={"tmdb_ids": [1]}).status_code == 400 # no kind diff --git a/tests/test_video_database.py b/tests/test_video_database.py index 37d82588..7a5b3e59 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -767,3 +767,38 @@ def test_prune_is_scoped_to_one_server(db): db.prune_missing("movies", "plex", seen_ids=[]) assert db.query_library("movies", server_source="plex")["pagination"]["total_count"] == 0 assert db.query_library("movies", server_source="jellyfin")["pagination"]["total_count"] == 1 + + +# ── user watchlist (shows + people) ───────────────────────────────────── + +def test_watchlist_add_list_remove(db): + assert db.add_to_watchlist("show", 1399, "Game of Thrones", poster_url="/p.jpg", library_id=7) is True + assert db.add_to_watchlist("person", 287, "Brad Pitt") is True + assert db.add_to_watchlist("movie", 1, "nope") is False # wrong kind + assert db.add_to_watchlist("show", 0, "no id") is False # no tmdb id + rows = db.list_watchlist() + assert {r["kind"] for r in rows} == {"show", "person"} + assert db.list_watchlist("person")[0]["title"] == "Brad Pitt" + assert db.remove_from_watchlist("show", 1399) is True + assert db.remove_from_watchlist("show", 1399) is False # already gone + + +def test_watchlist_reAdd_is_upsert_and_coalesces_library_id(db): + db.add_to_watchlist("show", 1399, "GoT", poster_url="/a.jpg", library_id=7) + # Re-add WITHOUT library_id/poster (e.g. from a TMDB search card) must not + # wipe the previously-known library_id/poster, but should refresh the title. + db.add_to_watchlist("show", 1399, "Game of Thrones") + rows = db.list_watchlist("show") + assert len(rows) == 1 # no duplicate + assert rows[0]["title"] == "Game of Thrones" # refreshed + assert rows[0]["library_id"] == 7 and rows[0]["poster_url"] == "/a.jpg" # preserved + + +def test_watchlist_state_and_counts(db): + db.add_to_watchlist("show", 1399, "GoT") + db.add_to_watchlist("show", 1396, "Breaking Bad") + db.add_to_watchlist("person", 287, "Brad Pitt") + assert db.watchlist_state("show", [1399, 1396, 9999]) == {1399: True, 1396: True} + assert db.watchlist_state("show", []) == {} + assert db.watchlist_state("person", [287]) == {287: True} + assert db.watchlist_counts() == {"show": 2, "person": 1, "total": 3} diff --git a/webui/index.html b/webui/index.html index 1a6b17bf..b3f64ad8 100644 --- a/webui/index.html +++ b/webui/index.html @@ -905,6 +905,43 @@ + + @@ -9507,6 +9544,9 @@ + + @@ -9516,6 +9556,8 @@ + + diff --git a/webui/static/video/video-library.js b/webui/static/video/video-library.js index 5da9a962..8b693ae7 100644 --- a/webui/static/video/video-library.js +++ b/webui/static/video/video-library.js @@ -65,6 +65,16 @@ fallback + '\'">' : '
' + fallback + '
'; + // #watchlist: TV shows get a hover "follow" eye (movies don't — they're + // wishlist, not watchlist). Injected inside the positioned poster box. + if (kind === 'show' && it.tmdb_id && window.VideoWatchlist) { + var wlb = VideoWatchlist.btn({ + kind: 'show', tmdbId: it.tmdb_id, title: it.title, + poster: it.has_poster ? ('/api/video/poster/show/' + it.id) : '', libraryId: it.id + }); + if (wlb) img = img.replace(/<\/div>$/, wlb + ''); + } + var badge = ''; if (kind === 'movie') { var rl = resLabel(it.resolution); @@ -97,6 +107,8 @@ var empty = $('[data-video-lib-empty]'); if (grid) grid.innerHTML = items.map(function (it) { return cardHTML(it, kind); }).join(''); if (empty) empty.classList.toggle('hidden', items.length > 0); + // #watchlist: paint the follow eye on shows already on the watchlist. + if (grid && kind === 'show' && window.VideoWatchlist) VideoWatchlist.hydrate(grid); } function updatePagination(p) { diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index a43f5a8f..62e27d6a 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -1774,3 +1774,94 @@ body[data-side="video"] #soulsync-toggle { display: none; } .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); } @media (max-width: 720px) { .vcal-head-row { flex-direction: column; align-items: stretch; } .vcal-controls { justify-content: space-between; } } + +/* ══════════════════════════════════════════════════════════════════════════ + Watchlist — shared "add to watchlist" button (.vwl-btn) + the Watchlist page + (.vwlp-*). The button is the video mirror of the music ya-watchlist-btn. + ══════════════════════════════════════════════════════════════════════════ */ + +/* Shared eye button — overlaid top-right on a show poster / person photo. */ +.vwl-btn { + position: absolute; top: 8px; right: 8px; z-index: 4; + width: 30px; height: 30px; border-radius: 9px; + display: flex; align-items: center; justify-content: center; + background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.14); color: rgba(255, 255, 255, 0.82); + cursor: pointer; opacity: 0; transform: translateY(-2px); + transition: opacity 0.18s ease, transform 0.18s ease, background 0.18s ease, + color 0.18s ease, border-color 0.18s ease; } +.vwl-btn:hover { background: rgba(0, 0, 0, 0.78); color: #fff; border-color: rgba(255, 255, 255, 0.3); } +.vwl-btn.active { opacity: 1; transform: none; + color: rgb(var(--accent-rgb, 88 101 242)); + background: rgba(var(--accent-rgb, 88 101 242), 0.18); + border-color: rgba(var(--accent-rgb, 88 101 242), 0.55); } +.vwl-btn:disabled { opacity: 0.5; cursor: default; } +/* hover-reveal on the cards that host it (always shown once active / on touch) */ +.library-artist-card:hover .vwl-btn, .vwlp-card:hover .vwl-btn, +.vd-cast-card:hover .vwl-btn, .vsr-card:hover .vwl-btn, .vd-sim-card:hover .vwl-btn { + opacity: 1; transform: none; } +@media (hover: none) { .vwl-btn { opacity: 1; transform: none; } } +@media (prefers-reduced-motion: reduce) { .vwl-btn { transition: opacity 0.18s ease; transform: none; } } + +/* ── Watchlist page ───────────────────────────────────────────────────────── */ +.vwlp-page { color: var(--text-primary, #fff); padding: 0 0 70px; } +.vwlp-head { position: relative; padding: 30px 40px 0; overflow: hidden; } +.vwlp-head-glow { position: absolute; top: -190px; left: 50%; transform: translateX(-50%); + width: 860px; height: 390px; pointer-events: none; z-index: 0; + background: radial-gradient(56% 70% at 50% 0%, rgba(var(--accent-rgb, 88 101 242), 0.22), transparent 70%); filter: blur(10px); } +.vwlp-head-row { position: relative; z-index: 1; display: flex; align-items: flex-end; + justify-content: space-between; gap: 20px; flex-wrap: wrap; } +.vwlp-title { font-size: 36px; font-weight: 800; letter-spacing: -0.025em; margin: 0; } +.vwlp-sub { margin: 7px 0 0; color: rgba(255, 255, 255, 0.55); font-size: 14px; font-weight: 600; } + +.vwlp-tabs { display: inline-flex; gap: 3px; padding: 4px; border-radius: 13px; + background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.08); } +.vwlp-tab { border: 0; background: transparent; color: rgba(255, 255, 255, 0.7); + font-size: 13px; font-weight: 800; padding: 8px 16px; border-radius: 9px; cursor: pointer; + display: inline-flex; align-items: center; gap: 8px; transition: all 0.15s ease; } +.vwlp-tab:hover { color: #fff; background: rgba(255, 255, 255, 0.07); } +.vwlp-tab--on { color: #fff; background: rgba(var(--accent-rgb, 88 101 242), 0.9); + box-shadow: 0 4px 14px rgba(var(--accent-rgb, 88 101 242), 0.32); } +.vwlp-tab-n { font-size: 11px; font-weight: 800; min-width: 20px; text-align: center; + padding: 1px 7px; border-radius: 999px; background: rgba(0, 0, 0, 0.25); color: inherit; } +.vwlp-tab--on .vwlp-tab-n { background: rgba(0, 0, 0, 0.28); } + +.vwlp-body { padding: 26px 40px 0; } +.vwlp-loading { padding: 80px 0; text-align: center; } +.vwlp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(158px, 1fr)); gap: 22px 20px; } +.vwlp-grid.hidden { display: none; } + +.vwlp-card { position: relative; display: block; text-decoration: none; color: inherit; + transition: transform 0.16s ease; } +.vwlp-card:hover { transform: translateY(-5px); } +.vwlp-card-art { position: relative; aspect-ratio: 2 / 3; border-radius: 14px; overflow: hidden; + background: linear-gradient(140deg, #1a1a22, #0c0c10); border: 1px solid rgba(255, 255, 255, 0.07); + box-shadow: 0 14px 34px rgba(0, 0, 0, 0.4); transition: border-color 0.18s ease, box-shadow 0.18s ease; } +.vwlp-card:hover .vwlp-card-art { border-color: rgba(var(--accent-rgb, 88 101 242), 0.5); + box-shadow: 0 22px 50px rgba(0, 0, 0, 0.55), 0 0 24px rgba(var(--accent-rgb, 88 101 242), 0.22); } +.vwlp-card--person .vwlp-card-art { border-radius: 16px; } +.vwlp-card-img { width: 100%; height: 100%; object-fit: cover; display: block; + opacity: 0; transition: opacity 0.45s ease; } +.vwlp-card-img.vwlp-loaded { opacity: 1; } +.vwlp-card-ph { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; + font-size: 40px; opacity: 0.5; } +.vwlp-card-scrim { position: absolute; inset: 0; pointer-events: none; + background: linear-gradient(0deg, rgba(0, 0, 0, 0.4), transparent 42%); } +.vwlp-card-info { padding: 9px 4px 0; } +.vwlp-card-title { display: block; font-size: 13.5px; font-weight: 700; line-height: 1.3; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.vwlp-state { text-align: center; padding: 90px 20px; color: rgba(255, 255, 255, 0.6); } +.vwlp-state.hidden { display: none; } +.vwlp-state-ic { font-size: 46px; opacity: 0.5; } +.vwlp-state-title { font-size: 20px; font-weight: 800; color: #fff; margin-top: 12px; } +.vwlp-state-sub { font-size: 14px; margin-top: 6px; max-width: 420px; margin-left: auto; margin-right: auto; } + +@media (max-width: 900px) { + .vwlp-head, .vwlp-body { padding-left: 18px; padding-right: 18px; } + .vwlp-grid { grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); gap: 18px 14px; } +} + +/* Ensure the show poster box is a positioning context for its .vwl-btn + (scoped to VIDEO cards so the shared music .library-artist-image is untouched). */ +.video-card--clickable .library-artist-image { position: relative; } diff --git a/webui/static/video/video-watchlist-btn.js b/webui/static/video/video-watchlist-btn.js new file mode 100644 index 00000000..2ff07aba --- /dev/null +++ b/webui/static/video/video-watchlist-btn.js @@ -0,0 +1,138 @@ +/* + * SoulSync — Video watchlist button (shared). + * + * One source of truth so every TV-show poster and person card gets the SAME + * "add to watchlist" control + behaviour — the video mirror of the music + * ya-watchlist-btn (eye icon, top-right, `.active` = watched). + * + * Renderers call `VideoWatchlist.btn({kind, tmdbId, title, poster, libraryId})` + * to emit the markup. A single delegated click handler toggles add/remove and + * flips the visual; `VideoWatchlist.hydrate(root)` batch-checks watched state + * after a render. Self-contained — inert until a `.vwl-btn` exists in the DOM. + * + * kind is 'show' or 'person' ONLY (movies + episodes are wishlist, not watch). + */ +(function () { + 'use strict'; + + // Client-side cache of watched tmdb_ids per kind (so freshly-rendered cards + // can paint correctly before a hydrate round-trip returns). + var WATCHED = { show: {}, person: {} }; + + function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + function eyeSvg(on) { + return '' + + ''; + } + function toast(msg, type) { if (typeof showToast === 'function') showToast(msg, type); } + + // Build the button markup. Returns '' for invalid input (so it can be + // string-concatenated unconditionally by callers). + function btn(opts) { + if (!opts || !opts.tmdbId) return ''; + if (opts.kind !== 'show' && opts.kind !== 'person') return ''; + var on = !!WATCHED[opts.kind][opts.tmdbId]; + return ''; + } + + function paint(b, on) { + b.classList.toggle('active', on); + b.title = on ? 'On watchlist' : 'Add to watchlist'; + b.setAttribute('aria-label', b.title); + var svg = b.querySelector('svg'); + if (svg) svg.setAttribute('fill', on ? 'currentColor' : 'none'); + } + + // Reflect a (kind, id) across EVERY matching button in the DOM — the same + // show can appear in the library, similar rail, search, etc. at once. + function syncAll(kind, id, on) { + if (on) WATCHED[kind][id] = true; else delete WATCHED[kind][id]; + var nodes = document.querySelectorAll('.vwl-btn[data-vwl-kind="' + kind + '"][data-vwl-id="' + id + '"]'); + for (var i = 0; i < nodes.length; i++) paint(nodes[i], on); + // Let interested pages (e.g. the Watchlist page) react — e.g. drop a card + // when it's un-followed, or refresh counts. + document.dispatchEvent(new CustomEvent('soulsync:video-watchlist-changed', { + detail: { kind: kind, id: id, watched: on } + })); + } + + function toggle(b) { + var kind = b.getAttribute('data-vwl-kind'), id = b.getAttribute('data-vwl-id'); + if (!kind || !id || b.disabled) return; + var on = b.classList.contains('active'); + b.disabled = true; + var done = function () { b.disabled = false; }; + if (on) { + fetch('/api/video/watchlist/remove', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kind: kind, tmdb_id: Number(id) }) + }).then(function (r) { return r.json(); }).then(function (d) { + if (d && d.success) { syncAll(kind, id, false); toast('Removed from watchlist', 'info'); } + }).catch(function () { toast('Watchlist update failed', 'error'); }).then(done); + } else { + var body = { + kind: kind, tmdb_id: Number(id), + title: b.getAttribute('data-vwl-title') || '', + poster_url: b.getAttribute('data-vwl-poster') || '' + }; + var lib = b.getAttribute('data-vwl-libid'); + if (lib) body.library_id = Number(lib); + fetch('/api/video/watchlist/add', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }).then(function (r) { return r.json(); }).then(function (d) { + if (d && d.success) { syncAll(kind, id, true); toast('Added to watchlist', 'success'); } + else { toast((d && d.error) || 'Could not add', 'error'); } + }).catch(function () { toast('Watchlist update failed', 'error'); }).then(done); + } + } + + // Batch-check watched state for every un-painted button under `root`. + function hydrate(root) { + root = root || document; + ['show', 'person'].forEach(function (kind) { + var nodes = root.querySelectorAll('.vwl-btn[data-vwl-kind="' + kind + '"]'); + if (!nodes.length) return; + var ids = [], seen = {}; + for (var i = 0; i < nodes.length; i++) { + var id = nodes[i].getAttribute('data-vwl-id'); + if (id && !seen[id]) { seen[id] = 1; ids.push(Number(id)); } + } + if (!ids.length) return; + fetch('/api/video/watchlist/check', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kind: kind, tmdb_ids: ids }) + }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) { + if (!d || !d.results) return; + for (var k in d.results) { if (d.results[k]) WATCHED[kind][k] = true; } + var ns = root.querySelectorAll('.vwl-btn[data-vwl-kind="' + kind + '"]'); + for (var j = 0; j < ns.length; j++) { + var bid = ns[j].getAttribute('data-vwl-id'); + if (bid && d.results[bid]) paint(ns[j], true); + } + }).catch(function () { /* non-critical */ }); + }); + } + + // One capture-phase handler for the whole document: a watchlist button lives + // inside a card , so we must stop the click before it navigates. + document.addEventListener('click', function (e) { + var b = e.target.closest && e.target.closest('.vwl-btn'); + if (!b) return; + e.preventDefault(); + e.stopPropagation(); + toggle(b); + }, true); + + window.VideoWatchlist = { btn: btn, hydrate: hydrate, _watched: WATCHED }; +})(); diff --git a/webui/static/video/video-watchlist.js b/webui/static/video/video-watchlist.js new file mode 100644 index 00000000..3819881b --- /dev/null +++ b/webui/static/video/video-watchlist.js @@ -0,0 +1,122 @@ +/* + * SoulSync — Video Watchlist page (isolated). + * + * The shows + people you follow, split by a Shows / People tab switcher. Reads + * /api/video/watchlist; cards reuse the shared VideoWatchlist eye-button (here it + * reads as "watched" and un-follows on click). v1 is membership only — the + * monitoring/discovery engine that turns follows into downloads comes later. + */ +(function () { + 'use strict'; + + var PAGE_ID = 'video-watchlist'; + var state = { loaded: false, tab: 'show', data: { show: [], person: [] } }; + + function $(s, r) { return (r || document).querySelector(s); } + function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + function wlBtn(opts) { return (window.VideoWatchlist) ? VideoWatchlist.btn(opts) : ''; } + + function cardHTML(it, kind) { + var href = kind === 'person' + ? '/video-detail/tmdb/person/' + it.tmdb_id + : (it.library_id ? '/video-detail/library/show/' + it.library_id + : '/video-detail/tmdb/show/' + it.tmdb_id); + var ph = kind === 'person' ? '👤' : '📺'; // 👤 / 📺 + var art = it.poster_url + ? '' + : '
' + ph + '
'; + var btn = wlBtn({ kind: kind, tmdbId: it.tmdb_id, title: it.title, + poster: it.poster_url, libraryId: it.library_id }); + return '
' + + '
' + art + '
' + btn + '
' + + '
' + + esc(it.title) + '
'; + } + + function updateEmpty() { + var n = state.data[state.tab].length; + ['show', 'person'].forEach(function (k) { + var g = $('[data-vwlp-grid="' + k + '"]'); + if (g) g.classList.toggle('hidden', k !== state.tab || state.data[k].length === 0); + }); + var empty = $('[data-vwlp-empty]'); + if (empty) empty.classList.toggle('hidden', n > 0); + var et = $('[data-vwlp-empty-title]'); + if (et && n === 0) et.textContent = state.tab === 'show' + ? 'No shows on your watchlist yet' : 'No people on your watchlist yet'; + } + + function setTab(tab) { + state.tab = tab; + var tabs = document.querySelectorAll('[data-vwlp-tab]'); + for (var i = 0; i < tabs.length; i++) + tabs[i].classList.toggle('vwlp-tab--on', tabs[i].getAttribute('data-vwlp-tab') === tab); + updateEmpty(); + } + + function render() { + // Seed the shared cache so every button paints "watched" with no flash + // (everything on this page is, by definition, followed). + if (window.VideoWatchlist) { + state.data.show.forEach(function (it) { VideoWatchlist._watched.show[it.tmdb_id] = true; }); + state.data.person.forEach(function (it) { VideoWatchlist._watched.person[it.tmdb_id] = true; }); + } + var sg = $('[data-vwlp-grid="show"]'), pg = $('[data-vwlp-grid="person"]'); + if (sg) sg.innerHTML = state.data.show.map(function (it) { return cardHTML(it, 'show'); }).join(''); + if (pg) pg.innerHTML = state.data.person.map(function (it) { return cardHTML(it, 'person'); }).join(''); + var cs = $('[data-vwlp-count-show]'); if (cs) cs.textContent = state.data.show.length; + var cp = $('[data-vwlp-count-person]'); if (cp) cp.textContent = state.data.person.length; + setTab(state.tab); + } + + function load() { + state.loaded = true; + var ld = $('[data-vwlp-loading]'); if (ld) ld.classList.remove('hidden'); + fetch('/api/video/watchlist', { headers: { Accept: 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + if (ld) ld.classList.add('hidden'); + state.data = (d && d.success) + ? { show: d.shows || [], person: d.people || [] } + : { show: [], person: [] }; + render(); + }) + .catch(function () { if (ld) ld.classList.add('hidden'); state.data = { show: [], person: [] }; render(); }); + } + + // When an item is un-followed (here or anywhere), drop its card + fix counts. + function onChanged(e) { + var det = (e && e.detail) || {}; + if (det.watched) return; // additions are picked up on next page load + var kind = det.kind, id = String(det.id); + if (!state.data[kind]) return; + state.data[kind] = state.data[kind].filter(function (it) { return String(it.tmdb_id) !== id; }); + var card = document.querySelector('.vwlp-card[data-vwlp-card="' + kind + '"][data-vwlp-id="' + id + '"]'); + if (card && card.parentNode) card.parentNode.removeChild(card); + var c = $('[data-vwlp-count-' + kind + ']'); if (c) c.textContent = state.data[kind].length; + updateEmpty(); + } + + function wire() { + var tabs = document.querySelectorAll('[data-vwlp-tab]'); + for (var i = 0; i < tabs.length; i++) (function (b) { + b.addEventListener('click', function () { setTab(b.getAttribute('data-vwlp-tab')); }); + })(tabs[i]); + document.addEventListener('soulsync:video-watchlist-changed', onChanged); + } + + function onShown(e) { if (e && e.detail === PAGE_ID) load(); } // reload each visit → stays fresh + + function init() { + wire(); + document.addEventListener('soulsync:video-page-shown', onShown); + } + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); + else init(); +})();