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 @@
+
+ Shows and people you followWatchlist
+