diff --git a/api/video/__init__.py b/api/video/__init__.py
index c8f38764..dcdbdc5c 100644
--- a/api/video/__init__.py
+++ b/api/video/__init__.py
@@ -39,7 +39,9 @@ def create_video_blueprint() -> Blueprint:
from .dashboard import register_routes as reg_dashboard
from .scan import register_routes as reg_scan
+ from .library import register_routes as reg_library
reg_dashboard(bp)
reg_scan(bp)
+ reg_library(bp)
return bp
diff --git a/api/video/library.py b/api/video/library.py
new file mode 100644
index 00000000..7cd641c9
--- /dev/null
+++ b/api/video/library.py
@@ -0,0 +1,25 @@
+"""Video library listing endpoint.
+
+GET /api/video/library -> {"movies": [...], "shows": [...]}
+Reads what the last scan mirrored from the media server into video.db.
+"""
+
+from __future__ import annotations
+
+from flask import jsonify
+
+from utils.logging_config import get_logger
+
+logger = get_logger("video_api.library")
+
+
+def register_routes(bp):
+ @bp.route("/library", methods=["GET"])
+ def video_library():
+ from . import get_video_db
+ try:
+ db = get_video_db()
+ return jsonify({"movies": db.list_movies(), "shows": db.list_shows()})
+ except Exception:
+ logger.exception("Failed to list video library")
+ return jsonify({"error": "Failed to load video library"}), 500
diff --git a/database/video_database.py b/database/video_database.py
index 3f2df864..a3a0bef6 100644
--- a/database/video_database.py
+++ b/database/video_database.py
@@ -314,6 +314,31 @@ class VideoDatabase:
finally:
conn.close()
+ # ── library listing ───────────────────────────────────────────────────────
+ def list_movies(self) -> list[dict]:
+ conn = self._get_connection()
+ try:
+ rows = conn.execute(
+ "SELECT id, title, year, poster_url, has_file, monitored "
+ "FROM movies ORDER BY COALESCE(sort_title, title) COLLATE NOCASE, title"
+ ).fetchall()
+ return [dict(r) for r in rows]
+ finally:
+ conn.close()
+
+ def list_shows(self) -> list[dict]:
+ conn = self._get_connection()
+ try:
+ rows = conn.execute(
+ "SELECT s.id, s.title, s.year, s.poster_url, s.monitored, "
+ "(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 "
+ "FROM shows s ORDER BY COALESCE(s.sort_title, s.title) COLLATE NOCASE, s.title"
+ ).fetchall()
+ return [dict(r) for r in rows]
+ finally:
+ conn.close()
+
# ── health ───────────────────────────────────────────────────────────────
def health_check(self) -> bool:
"""True when the DB opens and passes a quick integrity check."""
diff --git a/tests/test_video_api.py b/tests/test_video_api.py
index a43aaa44..8f66b2ad 100644
--- a/tests/test_video_api.py
+++ b/tests/test_video_api.py
@@ -31,6 +31,7 @@ def test_blueprint_exposes_dashboard_route():
assert "/api/video/dashboard" in rules
assert "/api/video/scan/request" in rules
assert "/api/video/scan/status" in rules
+ assert "/api/video/library" in rules
def test_dashboard_endpoint_returns_zeroed_json(tmp_path):
@@ -46,6 +47,19 @@ def test_dashboard_endpoint_returns_zeroed_json(tmp_path):
videoapi._video_db = None # don't leak the tmp DB to other tests
+def test_library_endpoint_lists_content(tmp_path):
+ client, videoapi = _make_client(tmp_path)
+ try:
+ videoapi._video_db.upsert_movie("plex", {"server_id": "m1", "title": "A"})
+ resp = client.get("/api/video/library")
+ assert resp.status_code == 200
+ data = resp.get_json()
+ assert [m["title"] for m in data["movies"]] == ["A"]
+ assert data["shows"] == []
+ finally:
+ videoapi._video_db = None
+
+
def test_video_api_imports_nothing_from_music():
base = Path(__file__).resolve().parent.parent / "api" / "video"
for py in base.glob("*.py"):
diff --git a/tests/test_video_database.py b/tests/test_video_database.py
index a8b44026..ed110a17 100644
--- a/tests/test_video_database.py
+++ b/tests/test_video_database.py
@@ -225,6 +225,19 @@ def test_prune_missing_removes_unseen_top_level(db):
assert ids == {"a"}
+def test_list_movies_and_shows(db):
+ db.upsert_movie("plex", {"server_id": "m1", "title": "Zardoz", "year": 1974})
+ db.upsert_movie("plex", {"server_id": "m2", "title": "Akira", "year": 1988})
+ db.upsert_show_tree("plex", {"server_id": "s1", "title": "Show", "seasons": [
+ {"season_number": 1, "episodes": [
+ {"episode_number": 1, "file": {"relative_path": "e1.mkv"}},
+ {"episode_number": 2}]}]})
+ assert [m["title"] for m in db.list_movies()] == ["Akira", "Zardoz"] # title NOCASE sort
+ shows = db.list_shows()
+ assert len(shows) == 1
+ assert (shows[0]["episode_count"], shows[0]["owned_count"]) == (2, 1)
+
+
# ── isolation: the video DB imports nothing from music ───────────────────────
def test_video_db_module_imports_nothing_from_music():
diff --git a/tests/test_video_side_shell.py b/tests/test_video_side_shell.py
index 749018fc..7346a4f8 100644
--- a/tests/test_video_side_shell.py
+++ b/tests/test_video_side_shell.py
@@ -17,6 +17,7 @@ _ROOT = Path(__file__).resolve().parent.parent
_INDEX = (_ROOT / "webui" / "index.html").read_text(encoding="utf-8", errors="replace")
_JS = (_ROOT / "webui" / "static" / "video" / "video-side.js").read_text(encoding="utf-8")
_DASH_JS = (_ROOT / "webui" / "static" / "video" / "video-dashboard.js").read_text(encoding="utf-8")
+_LIB_JS = (_ROOT / "webui" / "static" / "video" / "video-library.js").read_text(encoding="utf-8")
_CSS_PATH = _ROOT / "webui" / "static" / "video" / "video-side.css"
EXPECTED_VIDEO_PAGES = {
@@ -132,6 +133,25 @@ def test_video_dashboard_data_module_referenced_and_isolated():
assert "window." not in _DASH_JS
+def test_video_library_subpage_present():
+ block = _block(
+ _INDEX, r' Movies and shows from your media server Nothing here yet — run a scan to pull your library from the media server.
+
Library