diff --git a/api/video/library.py b/api/video/library.py index 7cd641c9..6c57f97c 100644 --- a/api/video/library.py +++ b/api/video/library.py @@ -6,7 +6,7 @@ Reads what the last scan mirrored from the media server into video.db. from __future__ import annotations -from flask import jsonify +from flask import jsonify, request from utils.logging_config import get_logger @@ -18,8 +18,15 @@ def register_routes(bp): def video_library(): from . import get_video_db try: - db = get_video_db() - return jsonify({"movies": db.list_movies(), "shows": db.list_shows()}) + return jsonify(get_video_db().query_library( + request.args.get("kind", "movies"), + search=request.args.get("search") or None, + letter=request.args.get("letter") or None, + sort=request.args.get("sort", "title"), + status=request.args.get("status", "all"), + page=request.args.get("page", 1), + limit=request.args.get("limit", 75), + )) except Exception: - logger.exception("Failed to list video library") + logger.exception("Failed to query video library") return jsonify({"error": "Failed to load video library"}), 500 diff --git a/database/video_database.py b/database/video_database.py index 3470d754..3c16c1d4 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -17,6 +17,7 @@ verbatim on first init. from __future__ import annotations import os +import re import sqlite3 import threading from pathlib import Path @@ -36,6 +37,14 @@ _SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql" _init_lock = threading.Lock() _initialized_paths: set[str] = set() +# Sort/letter key: title without a leading article, lowercased (so "The Matrix" +# files under M, like music's library). +_ARTICLE_RE = re.compile(r"^(the|a|an)\s+", re.IGNORECASE) + + +def _sort_title(title) -> str: + return _ARTICLE_RE.sub("", (title or "").strip()).lower() + class VideoDatabase: """Connection + schema manager for the isolated video library DB.""" @@ -198,17 +207,18 @@ class VideoDatabase: conn = self._get_connection() try: conn.execute( - "INSERT INTO movies (server_source, server_id, title, year, overview, " + "INSERT INTO movies (server_source, server_id, title, sort_title, year, overview, " "runtime_minutes, content_rating, studio, poster_url, has_file, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) " "ON CONFLICT(server_source, server_id) DO UPDATE SET " - "title=excluded.title, year=excluded.year, overview=excluded.overview, " - "runtime_minutes=excluded.runtime_minutes, content_rating=excluded.content_rating, " - "studio=excluded.studio, poster_url=excluded.poster_url, " - "has_file=excluded.has_file, updated_at=CURRENT_TIMESTAMP", - (server_source, item["server_id"], item.get("title"), item.get("year"), - item.get("overview"), item.get("runtime_minutes"), item.get("content_rating"), - item.get("studio"), item.get("poster_url"), 1 if item.get("file") else 0), + "title=excluded.title, sort_title=excluded.sort_title, year=excluded.year, " + "overview=excluded.overview, runtime_minutes=excluded.runtime_minutes, " + "content_rating=excluded.content_rating, studio=excluded.studio, " + "poster_url=excluded.poster_url, has_file=excluded.has_file, updated_at=CURRENT_TIMESTAMP", + (server_source, item["server_id"], item.get("title"), _sort_title(item.get("title")), + item.get("year"), item.get("overview"), item.get("runtime_minutes"), + item.get("content_rating"), item.get("studio"), item.get("poster_url"), + 1 if item.get("file") else 0), ) movie_id = conn.execute( "SELECT id FROM movies WHERE server_source=? AND server_id=?", @@ -227,16 +237,16 @@ class VideoDatabase: conn = self._get_connection() try: conn.execute( - "INSERT INTO shows (server_source, server_id, title, year, overview, status, " + "INSERT INTO shows (server_source, server_id, title, sort_title, year, overview, status, " "network, runtime_minutes, content_rating, poster_url, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) " "ON CONFLICT(server_source, server_id) DO UPDATE SET " - "title=excluded.title, year=excluded.year, overview=excluded.overview, " - "status=excluded.status, network=excluded.network, " + "title=excluded.title, sort_title=excluded.sort_title, year=excluded.year, " + "overview=excluded.overview, status=excluded.status, network=excluded.network, " "runtime_minutes=excluded.runtime_minutes, content_rating=excluded.content_rating, " "poster_url=excluded.poster_url, updated_at=CURRENT_TIMESTAMP", - (server_source, item["server_id"], item.get("title"), item.get("year"), - item.get("overview"), item.get("status"), item.get("network"), + (server_source, item["server_id"], item.get("title"), _sort_title(item.get("title")), + item.get("year"), item.get("overview"), item.get("status"), item.get("network"), item.get("runtime_minutes"), item.get("content_rating"), item.get("poster_url")), ) show_id = conn.execute( @@ -402,6 +412,83 @@ class VideoDatabase: finally: conn.close() + # ── paged/filtered/sorted library query (server-side, like music) ───────── + def query_library(self, kind: str, *, search=None, letter=None, sort="title", + status="all", page=1, limit=75) -> dict: + """One page of movies/shows with search + A–Z + sort + owned/wanted + filtering done in SQL. Returns {items, pagination:{...}} mirroring the + music library's contract.""" + try: + page = max(1, int(page or 1)) + limit = max(1, min(500, int(limit or 75))) + except (TypeError, ValueError): + page, limit = 1, 75 + is_shows = kind == "shows" + alias = "s" if is_shows else "m" + tbl = "shows" if is_shows else "movies" + + where, params = [], [] + if search: + where.append(f"{alias}.title LIKE ? COLLATE NOCASE") + params.append("%" + search + "%") + if letter and letter != "all": + col = f"COALESCE({alias}.sort_title, {alias}.title)" + if letter == "#": + where.append(f"substr(UPPER({col}), 1, 1) NOT BETWEEN 'A' AND 'Z'") + else: + where.append(f"{col} LIKE ? COLLATE NOCASE") + params.append(letter + "%") + if not is_shows: + if status == "owned": + where.append("m.has_file = 1") + elif status == "wanted": + where.append("m.has_file = 0") + else: + if status == "owned": + where.append("EXISTS (SELECT 1 FROM episodes e WHERE e.show_id=s.id AND e.has_file=1)") + elif status == "wanted": + where.append("NOT EXISTS (SELECT 1 FROM episodes e WHERE e.show_id=s.id AND e.has_file=1)") + where_sql = (" WHERE " + " AND ".join(where)) if where else "" + + title_key = f"COALESCE({alias}.sort_title, {alias}.title) COLLATE NOCASE ASC" + order_sql = { + "title": title_key, + "year": f"{alias}.year DESC, " + title_key, + "added": f"{alias}.added_at DESC", + }.get(sort, title_key) + + if is_shows: + select = ("SELECT s.id, s.title, s.year, " + "(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 " + "FROM shows s") + else: + select = ("SELECT m.id, m.title, m.year, m.has_file, " + "(m.poster_url IS NOT NULL AND m.poster_url <> '') AS has_poster, " + "(SELECT mf.resolution FROM media_files mf WHERE mf.movie_id=m.id LIMIT 1) AS resolution " + "FROM movies m") + + conn = self._get_connection() + try: + total = conn.execute(f"SELECT COUNT(*) FROM {tbl} {alias}{where_sql}", params).fetchone()[0] + rows = conn.execute( + f"{select}{where_sql} ORDER BY {order_sql} LIMIT ? OFFSET ?", + params + [limit, (page - 1) * limit]).fetchall() + items = [] + for r in rows: + d = dict(r) + d["has_poster"] = bool(d.get("has_poster")) + if not is_shows: + d["has_file"] = bool(d.get("has_file")) + items.append(d) + total_pages = max(1, (total + limit - 1) // limit) + return {"items": items, "pagination": { + "page": page, "total_pages": total_pages, "total_count": total, + "has_prev": page > 1, "has_next": page < total_pages}} + 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 27fec752..8719e60c 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -55,13 +55,13 @@ def test_library_endpoint_lists_content(tmp_path): try: videoapi._video_db.upsert_movie("plex", {"server_id": "m1", "title": "A", "poster_url": "/library/metadata/1/thumb/9"}) - resp = client.get("/api/video/library") + resp = client.get("/api/video/library?kind=movies") assert resp.status_code == 200 data = resp.get_json() - assert [m["title"] for m in data["movies"]] == ["A"] - assert data["movies"][0]["has_poster"] is True # flag, not the raw path - assert "poster_url" not in data["movies"][0] # don't leak server paths - assert data["shows"] == [] + assert [m["title"] for m in data["items"]] == ["A"] + assert data["items"][0]["has_poster"] is True # flag, not the raw path + assert "poster_url" not in data["items"][0] # don't leak server paths + assert data["pagination"]["total_count"] == 1 finally: videoapi._video_db = None diff --git a/tests/test_video_database.py b/tests/test_video_database.py index 394dd529..b39e518c 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -266,6 +266,45 @@ def test_list_movies_and_shows(db): assert (shows[0]["episode_count"], shows[0]["owned_count"]) == (2, 1) +def test_query_library_search_letter_sort_status_pagination(db): + db.upsert_movie("plex", {"server_id": "1", "title": "The Matrix", "year": 1999, + "file": {"relative_path": "x.mkv", "resolution": "1080p"}}) + db.upsert_movie("plex", {"server_id": "2", "title": "Akira", "year": 1988}) # wanted + db.upsert_movie("plex", {"server_id": "3", "title": "Avatar", "year": 2009, + "file": {"relative_path": "y.mkv"}}) + + # Article-aware sort: "The Matrix" files under M. + res = db.query_library("movies") + assert [i["title"] for i in res["items"]] == ["Akira", "Avatar", "The Matrix"] + assert res["pagination"]["total_count"] == 3 + + assert [i["title"] for i in db.query_library("movies", letter="m")["items"]] == ["The Matrix"] + assert [i["title"] for i in db.query_library("movies", search="aki")["items"]] == ["Akira"] + assert {i["title"] for i in db.query_library("movies", status="owned")["items"]} == {"The Matrix", "Avatar"} + assert [i["title"] for i in db.query_library("movies", status="wanted")["items"]] == ["Akira"] + assert [i["title"] for i in db.query_library("movies", sort="year")["items"]] == ["Avatar", "The Matrix", "Akira"] + + # Resolution badge field comes through. + assert db.query_library("movies", search="matrix")["items"][0]["resolution"] == "1080p" + + # Pagination. + p1 = db.query_library("movies", limit=2, page=1) + assert len(p1["items"]) == 2 and p1["pagination"]["total_pages"] == 2 and p1["pagination"]["has_next"] + p2 = db.query_library("movies", limit=2, page=2) + assert len(p2["items"]) == 1 and p2["pagination"]["has_prev"] + + +def test_query_library_shows_status_and_counts(db): + db.upsert_show_tree("plex", {"server_id": "s1", "title": "Owned Show", "seasons": [ + {"season_number": 1, "episodes": [{"episode_number": 1, "file": {"relative_path": "e.mkv"}}]}]}) + db.upsert_show_tree("plex", {"server_id": "s2", "title": "Wanted Show", "seasons": [ + {"season_number": 1, "episodes": [{"episode_number": 1}]}]}) + assert [i["title"] for i in db.query_library("shows", status="owned")["items"]] == ["Owned Show"] + assert [i["title"] for i in db.query_library("shows", status="wanted")["items"]] == ["Wanted Show"] + owned = db.query_library("shows", search="Owned")["items"][0] + assert (owned["episode_count"], owned["owned_count"]) == (1, 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 ad98aa22..9090a7a2 100644 --- a/tests/test_video_side_shell.py +++ b/tests/test_video_side_shell.py @@ -146,6 +146,9 @@ def test_video_library_subpage_present(): assert "data-video-lib-grid" in block assert 'data-video-lib-tab="movies"' in block and 'data-video-lib-tab="shows"' in block assert "data-video-scan-mode" in block # the Scan button + # Server-side paging + sort/filter controls (music parity). + assert "data-video-lib-pagination" in block and "data-video-lib-next" in block + assert "data-video-lib-sort" in block and "data-video-lib-status" in block assert "onclick" not in block # data-attr wired, no inline handlers diff --git a/webui/index.html b/webui/index.html index aba6d784..327bd0b7 100644 --- a/webui/index.html +++ b/webui/index.html @@ -729,6 +729,18 @@
+