From fba47e9665c26a980549cb3d77f0d7adc79e7010 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 14 Jun 2026 00:51:47 -0700 Subject: [PATCH] video Library page: music-library visuals + posters + search + A-Z MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilt the Library page to reuse the music library's exact look — no reinvention, just new data: - Same classes: .library-container, .library-artist-card grid, .alphabet- selector, .library-search-input, loading/empty states. Movies/Shows tab pill is the only video-specific bit. - Real posters via a server-side proxy: GET /api/video/poster// streams the Plex/Jellyfin artwork (token stays server-side); cards fall back to an emoji on miss. list_movies/list_shows now expose has_poster (no raw server paths leaked). - Client-side search + A-Z letter filter (article-aware) over the loaded set; cards are divs (not clickable yet, per request). Scan button in the header reuses the shared scan controller and reloads on done. 110 tests green. --- api/video/__init__.py | 2 + api/video/poster.py | 54 ++++++++ database/video_database.py | 26 +++- tests/test_video_api.py | 6 +- tests/test_video_side_shell.py | 11 +- webui/index.html | 61 ++++++--- webui/static/video/video-library.js | 188 ++++++++++++++++------------ webui/static/video/video-side.css | 60 +-------- 8 files changed, 248 insertions(+), 160 deletions(-) create mode 100644 api/video/poster.py diff --git a/api/video/__init__.py b/api/video/__init__.py index e8d3204d..b1ae7e3a 100644 --- a/api/video/__init__.py +++ b/api/video/__init__.py @@ -41,9 +41,11 @@ def create_video_blueprint() -> Blueprint: from .scan import register_routes as reg_scan from .library import register_routes as reg_library from .libraries import register_routes as reg_libraries + from .poster import register_routes as reg_poster reg_dashboard(bp) reg_scan(bp) reg_library(bp) reg_libraries(bp) + reg_poster(bp) return bp diff --git a/api/video/poster.py b/api/video/poster.py new file mode 100644 index 00000000..cc175aa2 --- /dev/null +++ b/api/video/poster.py @@ -0,0 +1,54 @@ +"""Video poster proxy. + +GET /api/video/poster// streams a movie/show poster from the media +server, server-side (so the Plex token / Jellyfin key never reaches the +browser). Falls back to 404 so the frontend shows its placeholder. +""" + +from __future__ import annotations + +from flask import Response, abort + +from utils.logging_config import get_logger + +logger = get_logger("video_api.poster") + + +def register_routes(bp): + @bp.route("/poster//", methods=["GET"]) + def video_poster(kind, item_id): + from . import get_video_db + ref = get_video_db().get_poster_ref(kind, item_id) + if not ref or not ref.get("poster_url"): + abort(404) + try: + import requests + from config.settings import config_manager + source = ref.get("server_source") + if source == "plex": + cfg = config_manager.get_plex_config() or {} + base, token = cfg.get("base_url"), cfg.get("token") + if not base or not token: + abort(404) + url = base.rstrip("/") + ref["poster_url"] + params = {"X-Plex-Token": token} + elif source == "jellyfin": + cfg = config_manager.get_jellyfin_config() or {} + base, key = cfg.get("base_url"), cfg.get("api_key") + if not base: + abort(404) + url = base.rstrip("/") + f"/Items/{ref['server_id']}/Images/Primary" + params = {"api_key": key} if key else {} + else: + abort(404) + + upstream = requests.get(url, params=params, timeout=15, stream=True) + if upstream.status_code != 200: + abort(404) + ctype = upstream.headers.get("Content-Type", "image/jpeg") + resp = Response(upstream.iter_content(8192), content_type=ctype) + resp.headers["Cache-Control"] = "public, max-age=86400" + return resp + except Exception: + logger.exception("video poster proxy failed for %s/%s", kind, item_id) + abort(404) diff --git a/database/video_database.py b/database/video_database.py index 2d23f5c0..53884d18 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -328,6 +328,14 @@ class VideoDatabase: conn.close() # ── library listing ─────────────────────────────────────────────────────── + @staticmethod + def _with_poster_flag(row: dict) -> dict: + # Don't leak the raw server thumb path; just say whether a poster exists + # (the frontend hits /api/video/poster// when true). + d = dict(row) + d["has_poster"] = bool(d.pop("poster_url", None)) + return d + def list_movies(self) -> list[dict]: conn = self._get_connection() try: @@ -335,7 +343,7 @@ class VideoDatabase: "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] + return [self._with_poster_flag(r) for r in rows] finally: conn.close() @@ -348,7 +356,21 @@ class VideoDatabase: "(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] + return [self._with_poster_flag(r) for r in rows] + finally: + conn.close() + + def get_poster_ref(self, kind: str, item_id: int) -> dict | None: + """Server source/id/poster path for one movie or show, for the poster proxy.""" + table = {"movie": "movies", "show": "shows"}.get(kind) + if not table: + return None + conn = self._get_connection() + try: + row = conn.execute( + f"SELECT server_source, server_id, poster_url FROM {table} WHERE id=?", + (item_id,)).fetchone() + return dict(row) if row else None finally: conn.close() diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 0dd2c448..8b878fba 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -33,6 +33,7 @@ def test_blueprint_exposes_dashboard_route(): assert "/api/video/scan/status" in rules assert "/api/video/library" in rules assert "/api/video/libraries" in rules + assert any(r.startswith("/api/video/poster/") for r in rules) def test_dashboard_endpoint_returns_zeroed_json(tmp_path): @@ -51,11 +52,14 @@ def test_dashboard_endpoint_returns_zeroed_json(tmp_path): 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"}) + videoapi._video_db.upsert_movie("plex", {"server_id": "m1", "title": "A", + "poster_url": "/library/metadata/1/thumb/9"}) 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["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"] == [] finally: videoapi._video_db = None diff --git a/tests/test_video_side_shell.py b/tests/test_video_side_shell.py index ffdb3a48..3ffcc180 100644 --- a/tests/test_video_side_shell.py +++ b/tests/test_video_side_shell.py @@ -138,10 +138,15 @@ def test_video_dashboard_data_module_referenced_and_isolated(): def test_video_library_subpage_present(): block = _block( _INDEX, r'
") - assert "data-video-lib-grid" in block # the card grid - assert "data-video-scan" in block # the Scan button + # Reuses the music library structure verbatim (no reinvented markup). + assert "library-container" in block + assert "library-search-input" in block # search bar + assert "alphabet-selector" in block # A–Z selector + assert "library-artists-grid" in block # the music card grid + assert "data-video-lib-grid" in block assert 'data-video-lib-tab="movies"' in block and 'data-video-lib-tab="shows"' in block - assert "onclick" not in block # data-attr wired, no inline handlers + assert "data-video-scan-mode" in block # the Scan button + assert "onclick" not in block # data-attr wired, no inline handlers def test_video_library_module_referenced_and_isolated(): diff --git a/webui/index.html b/webui/index.html index 1c391de0..b8cb4ae3 100644 --- a/webui/index.html +++ b/webui/index.html @@ -699,28 +699,53 @@