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:
parent
6d72b7ca11
commit
e5a4dda117
11 changed files with 715 additions and 2 deletions
|
|
@ -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
104
api/video/watchlist.py
Normal 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
|
||||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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…</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">👁</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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
138
webui/static/video/video-watchlist-btn.js
Normal file
138
webui/static/video/video-watchlist-btn.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
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 };
|
||||
})();
|
||||
122
webui/static/video/video-watchlist.js
Normal file
122
webui/static/video/video-watchlist.js
Normal 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, '&').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
|
||||
? '<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();
|
||||
})();
|
||||
Loading…
Reference in a new issue