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'
") + assert "data-video-lib-grid" in block # the card grid + assert "data-video-scan" in block # the Scan button + 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 + + +def test_video_library_module_referenced_and_isolated(): + assert "video/video-library.js" in _INDEX + stripped = _LIB_JS.strip() + assert stripped.startswith("/*") or stripped.startswith("(function") + assert "(function" in _LIB_JS and "})();" in _LIB_JS + assert "soulsync:video-page-shown" in _LIB_JS # decoupled via the event + assert "addEventListener" in _LIB_JS + assert "window." not in _LIB_JS + + def test_controller_is_isolated_iife_with_no_globals(): stripped = _JS.strip() # Wrapped in an IIFE → declares no module-level globals that could collide diff --git a/webui/index.html b/webui/index.html index 0d928da8..3f0a8116 100644 --- a/webui/index.html +++ b/webui/index.html @@ -669,6 +669,31 @@
+ @@ -8799,6 +8824,8 @@ + + diff --git a/webui/static/video/video-library.js b/webui/static/video/video-library.js new file mode 100644 index 00000000..45398e64 --- /dev/null +++ b/webui/static/video/video-library.js @@ -0,0 +1,178 @@ +/* + * SoulSync — Video Library page (isolated). + * + * Same isolation contract as the other video modules: self-contained IIFE, no + * globals, no inline handlers, lives under static/video/. Listens for the + * 'soulsync:video-page-shown' event (dispatched by video-side.js) and, when the + * Library page is shown, fetches /api/video/library and renders it. The Scan + * button asks the server to re-read the media server (source of truth) via + * /api/video/scan/request and polls /api/video/scan/status. + */ +(function () { + 'use strict'; + + var PAGE_ID = 'video-library'; + var LIBRARY_URL = '/api/video/library'; + var SCAN_REQUEST_URL = '/api/video/scan/request'; + var SCAN_STATUS_URL = '/api/video/scan/status'; + + var state = { tab: 'movies', data: null, loading: false, scanning: false }; + + function $(sel) { return document.querySelector(sel); } + + function el(tag, cls, text) { + var n = document.createElement(tag); + if (cls) n.className = cls; + if (text != null) n.textContent = text; + return n; + } + + function poster(node, title, url) { + var p = el('div', 'video-card-poster'); + if (url && /^https?:\/\//.test(url)) { + var img = el('img'); + img.alt = ''; + img.loading = 'lazy'; + img.src = url; + p.appendChild(img); + } else { + p.appendChild(el('span', 'video-card-poster-letter', + (title || '?').charAt(0).toUpperCase())); + } + node.appendChild(p); + } + + function movieCard(m) { + var card = el('div', 'video-card'); + poster(card, m.title, m.poster_url); + card.appendChild(el('div', 'video-card-title', m.title || 'Untitled')); + var meta = []; + if (m.year) meta.push(String(m.year)); + meta.push(m.has_file ? 'Owned' : 'Wanted'); + card.appendChild(el('div', 'video-card-meta', meta.join(' · '))); + return card; + } + + function showCard(s) { + var card = el('div', 'video-card'); + poster(card, s.title, s.poster_url); + card.appendChild(el('div', 'video-card-title', s.title || 'Untitled')); + var meta = []; + if (s.year) meta.push(String(s.year)); + meta.push((s.owned_count || 0) + '/' + (s.episode_count || 0) + ' eps'); + card.appendChild(el('div', 'video-card-meta', meta.join(' · '))); + return card; + } + + function render() { + var grid = $('[data-video-lib-grid]'); + var empty = $('[data-video-lib-empty]'); + var count = $('[data-video-lib-count]'); + if (!grid) return; + grid.textContent = ''; + var items = state.data ? (state.data[state.tab] || []) : []; + var maker = state.tab === 'movies' ? movieCard : showCard; + for (var i = 0; i < items.length; i++) grid.appendChild(maker(items[i])); + if (empty) empty.hidden = items.length > 0; + if (count) { + count.textContent = state.loading ? 'Loading…' + : items.length + ' ' + state.tab; + } + } + + function loadLibrary(force) { + if (state.data && !force) { render(); return; } + state.loading = true; + render(); + fetch(LIBRARY_URL, { headers: { 'Accept': 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + state.loading = false; + state.data = (d && !d.error) ? d : { movies: [], shows: [] }; + render(); + }) + .catch(function () { + state.loading = false; + state.data = { movies: [], shows: [] }; + render(); + }); + } + + function setScanLabel(text) { + var label = $('[data-video-scan-label]'); + if (label) label.textContent = text; + } + + function pollScan() { + fetch(SCAN_STATUS_URL, { headers: { 'Accept': 'application/json' } }) + .then(function (r) { return r.json(); }) + .then(function (s) { + if (s && s.state === 'scanning') { + setScanLabel((s.phase || 'Scanning') + '… ' + + (s.movies || 0) + 'm ' + (s.shows || 0) + 's'); + setTimeout(pollScan, 1500); + } else { + state.scanning = false; + var btn = $('[data-video-scan]'); + if (btn) btn.disabled = false; + setScanLabel('Scan Library'); + loadLibrary(true); + } + }) + .catch(function () { + state.scanning = false; + var btn = $('[data-video-scan]'); + if (btn) btn.disabled = false; + setScanLabel('Scan Library'); + }); + } + + function startScan() { + if (state.scanning) return; + state.scanning = true; + var btn = $('[data-video-scan]'); + if (btn) btn.disabled = true; + setScanLabel('Starting…'); + fetch(SCAN_REQUEST_URL, { method: 'POST', headers: { 'Accept': 'application/json' } }) + .then(function () { setTimeout(pollScan, 600); }) + .catch(function () { + state.scanning = false; + if (btn) btn.disabled = false; + setScanLabel('Scan Library'); + }); + } + + function wire() { + var tabs = document.querySelectorAll('[data-video-lib-tab]'); + for (var i = 0; i < tabs.length; i++) { + (function (tab) { + tab.addEventListener('click', function () { + state.tab = tab.getAttribute('data-video-lib-tab'); + var all = document.querySelectorAll('[data-video-lib-tab]'); + for (var j = 0; j < all.length; j++) { + all[j].classList.toggle('active', all[j] === tab); + } + render(); + }); + })(tabs[i]); + } + var scan = document.querySelector('[data-video-scan]'); + if (scan) scan.addEventListener('click', startScan); + } + + function onPageShown(e) { + if (!e || e.detail !== PAGE_ID) return; + loadLibrary(false); + } + + function init() { + wire(); + document.addEventListener('soulsync:video-page-shown', onPageShown); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 30c4622b..e04b1ad3 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -146,3 +146,83 @@ body[data-side="video"] .dashboard-header-sweep { color: var(--text-secondary, #9aa0aa); font-size: 14px; } + +/* ── Library page ──────────────────────────────────────────────────────── */ +.video-library-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} +.video-library-tabs { + display: flex; + gap: 6px; + padding: 4px; + background: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 12px; +} +.video-lib-tab { + padding: 8px 18px; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary, #9aa0aa); + background: transparent; + border: none; + border-radius: 9px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} +.video-lib-tab:hover { color: var(--text-primary, #e8e8ea); } +.video-lib-tab.active { + color: #fff; + background: rgb(var(--accent-rgb, 88 101 242)); +} +.video-library-count { + font-size: 13px; + color: var(--text-secondary, #9aa0aa); +} + +.video-library-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 18px; +} +.video-card { cursor: default; } +.video-card-poster { + position: relative; + aspect-ratio: 2 / 3; + border-radius: 12px; + overflow: hidden; + background: linear-gradient(135deg, rgba(var(--accent-rgb, 88 101 242), 0.25), rgba(0, 0, 0, 0.4)); + display: flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(255, 255, 255, 0.06); +} +.video-card-poster img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.video-card-poster-letter { + font-size: 40px; + font-weight: 700; + color: rgba(255, 255, 255, 0.55); +} +.video-card-title { + margin-top: 8px; + font-size: 13px; + font-weight: 600; + color: var(--text-primary, #e8e8ea); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.video-card-meta { + margin-top: 2px; + font-size: 12px; + color: var(--text-secondary, #9aa0aa); +}