Video watchlist (shows + people): DB + endpoints + button + page (v1, no scan yet)

A curated follow-list for the video side, mirroring the music watchlist. v1 is
membership only — the monitoring/discovery engine is a later phase.

Backend:
- video_watchlist table (kind 'show'|'person', keyed on tmdb_id — the stable
  cross-context id both carry; library_id kept when owned). NOT the existing
  shows.monitored flag (that defaults to 1 / is library-only / has no people).
- VideoDatabase: add/remove/list/state/counts (upsert COALESCEs library_id +
  poster so a TMDB re-add can't wipe known data).
- /api/video/watchlist {GET, /add, /remove, /check, /counts}.
- query_library now selects s.tmdb_id so show cards can carry the key.

Frontend:
- video-watchlist-btn.js: shared eye button (the music ya-watchlist-btn mirror)
  — build/toggle/hydrate, one delegated capture-phase click handler, broadcasts
  soulsync:video-watchlist-changed so pages can react.
- Watchlist page (new subpage + video-watchlist.js): Shows / People tab switcher,
  poster grid to detail-page quality, reloads each visit, drops cards on unfollow.
- Wired the eye onto library TV-show cards (movies excluded — wishlist, not
  watch) + hydrate on render.

Tests: 6 new (DB upsert/COALESCE/state/counts + endpoint roundtrip/validation).
76 video tests green. Other card surfaces (cast, search, similar, filmography)
are the same VideoWatchlist.btn(...) one-liner — wired next.
This commit is contained in:
BoulderBadgeDad 2026-06-16 01:06:24 -07:00
parent 6d72b7ca11
commit e5a4dda117
11 changed files with 715 additions and 2 deletions

View file

@ -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

104
api/video/watchlist.py Normal file
View file

@ -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

View file

@ -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 "

View file

@ -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

View file

@ -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

View file

@ -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}

View file

@ -905,6 +905,43 @@
</div>
</div>
</section>
<!-- Watchlist (nav page): the shows + people you follow, split by
a tab switcher. Built by video/video-watchlist.js, styled by
.vwlp-* in video-side.css. -->
<section class="video-subpage" data-video-subpage="video-watchlist" hidden>
<div class="vwlp-page">
<div class="vwlp-head">
<div class="vwlp-head-glow" aria-hidden="true"></div>
<div class="vwlp-head-row">
<div class="vwlp-head-titles">
<h1 class="vwlp-title">Watchlist</h1>
<p class="vwlp-sub" data-vwlp-sub>Shows and people you follow</p>
</div>
<div class="vwlp-tabs" role="tablist">
<button class="vwlp-tab vwlp-tab--on" type="button" role="tab" data-vwlp-tab="show">
Shows <span class="vwlp-tab-n" data-vwlp-count-show>0</span>
</button>
<button class="vwlp-tab" type="button" role="tab" data-vwlp-tab="person">
People <span class="vwlp-tab-n" data-vwlp-count-person>0</span>
</button>
</div>
</div>
</div>
<div class="vwlp-body">
<div class="vwlp-loading hidden" data-vwlp-loading>
<div class="loading-spinner"></div>
<div class="loading-text">Loading watchlist&hellip;</div>
</div>
<div class="vwlp-grid" data-vwlp-grid="show"></div>
<div class="vwlp-grid hidden" data-vwlp-grid="person"></div>
<div class="vwlp-state hidden" data-vwlp-empty>
<div class="vwlp-state-ic">&#128065;</div>
<div class="vwlp-state-title" data-vwlp-empty-title>Nothing on your watchlist yet</div>
<div class="vwlp-state-sub">Hover any show or person card and hit the eye to follow them — new content lands here.</div>
</div>
</div>
</div>
</section>
<!-- TV-show detail (drill-in from a show card; not a nav page).
Isolated: built by video/video-detail.js, styled by .vd-* in
video-side.css. Inspired by the music artist-detail vibe. -->
@ -9507,6 +9544,9 @@
<script src="{{ url_for('static', filename='video/video-enrichment-manager.js', v=static_v) }}"></script>
<!-- Video dashboard data layer (isolated; populates the dashboard from the video DB) -->
<script src="{{ url_for('static', filename='video/video-dashboard.js', v=static_v) }}"></script>
<!-- Video watchlist button (shared; the add-to-watchlist control used by every
show-poster + person card — must load before the card renderers) -->
<script src="{{ url_for('static', filename='video/video-watchlist-btn.js', v=static_v) }}"></script>
<!-- Video library page (isolated; lists movies/shows + scan trigger) -->
<script src="{{ url_for('static', filename='video/video-library.js', v=static_v) }}"></script>
<!-- Video detail page (isolated; show drill-in: hero + season/episode tree) -->
@ -9516,6 +9556,8 @@
<script src="{{ url_for('static', filename='video/video-calendar.js', v=static_v) }}"></script>
<!-- Video person page (isolated; cast/crew drill-in → filmography) -->
<script src="{{ url_for('static', filename='video/video-person.js', v=static_v) }}"></script>
<!-- Video watchlist page (isolated; tabbed shows / people you follow) -->
<script src="{{ url_for('static', filename='video/video-watchlist.js', v=static_v) }}"></script>
<!-- Video worker orbs (isolated copy of the music orbs; idles into the
floating-orb animation around the SoulSync logo on the video dashboard) -->
<script src="{{ url_for('static', filename='video/video-worker-orbs.js', v=static_v) }}"></script>

View file

@ -65,6 +65,16 @@
fallback + '</div>\'"></div>'
: '<div class="library-artist-image"><div class="library-artist-image-fallback">' + fallback + '</div></div>';
// #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 + '</div>');
}
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) {

View file

@ -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; }

View file

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function eyeSvg(on) {
return '<svg width="15" height="15" viewBox="0 0 24 24" fill="' + (on ? 'currentColor' : 'none') +
'" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>' +
'<circle cx="12" cy="12" r="3"/></svg>';
}
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 '<button type="button" class="vwl-btn' + (on ? ' active' : '') + '"' +
' data-vwl-kind="' + opts.kind + '" data-vwl-id="' + esc(opts.tmdbId) + '"' +
' data-vwl-title="' + esc(opts.title || '') + '"' +
' data-vwl-poster="' + esc(opts.poster || '') + '"' +
(opts.libraryId ? ' data-vwl-libid="' + esc(opts.libraryId) + '"' : '') +
' title="' + (on ? 'On watchlist' : 'Add to watchlist') + '"' +
' aria-label="' + (on ? 'On watchlist' : 'Add to watchlist') + '">' + eyeSvg(on) + '</button>';
}
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 <a>, 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 };
})();

View file

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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
? '<img class="vwlp-card-img" src="' + esc(it.poster_url) + '" alt="" loading="lazy" ' +
'onload="this.classList.add(\'vwlp-loaded\')" onerror="this.style.display=\'none\'">'
: '<div class="vwlp-card-ph">' + ph + '</div>';
var btn = wlBtn({ kind: kind, tmdbId: it.tmdb_id, title: it.title,
poster: it.poster_url, libraryId: it.library_id });
return '<a class="vwlp-card' + (kind === 'person' ? ' vwlp-card--person' : '') + '" href="' + href + '" ' +
'data-vwlp-card="' + kind + '" data-vwlp-id="' + esc(it.tmdb_id) + '">' +
'<div class="vwlp-card-art">' + art + '<div class="vwlp-card-scrim"></div>' + btn + '</div>' +
'<div class="vwlp-card-info"><span class="vwlp-card-title" title="' + esc(it.title) + '">' +
esc(it.title) + '</span></div></a>';
}
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();
})();