video Library page: music-library visuals + posters + search + A-Z

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/<kind>/<id>
  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.
This commit is contained in:
BoulderBadgeDad 2026-06-14 00:51:47 -07:00
parent 9d3cf14cdd
commit fba47e9665
8 changed files with 248 additions and 160 deletions

View file

@ -41,9 +41,11 @@ def create_video_blueprint() -> Blueprint:
from .scan import register_routes as reg_scan from .scan import register_routes as reg_scan
from .library import register_routes as reg_library from .library import register_routes as reg_library
from .libraries import register_routes as reg_libraries from .libraries import register_routes as reg_libraries
from .poster import register_routes as reg_poster
reg_dashboard(bp) reg_dashboard(bp)
reg_scan(bp) reg_scan(bp)
reg_library(bp) reg_library(bp)
reg_libraries(bp) reg_libraries(bp)
reg_poster(bp)
return bp return bp

54
api/video/poster.py Normal file
View file

@ -0,0 +1,54 @@
"""Video poster proxy.
GET /api/video/poster/<kind>/<id> 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/<kind>/<int:item_id>", 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)

View file

@ -328,6 +328,14 @@ class VideoDatabase:
conn.close() conn.close()
# ── library listing ─────────────────────────────────────────────────────── # ── 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/<kind>/<id> when true).
d = dict(row)
d["has_poster"] = bool(d.pop("poster_url", None))
return d
def list_movies(self) -> list[dict]: def list_movies(self) -> list[dict]:
conn = self._get_connection() conn = self._get_connection()
try: try:
@ -335,7 +343,7 @@ class VideoDatabase:
"SELECT id, title, year, poster_url, has_file, monitored " "SELECT id, title, year, poster_url, has_file, monitored "
"FROM movies ORDER BY COALESCE(sort_title, title) COLLATE NOCASE, title" "FROM movies ORDER BY COALESCE(sort_title, title) COLLATE NOCASE, title"
).fetchall() ).fetchall()
return [dict(r) for r in rows] return [self._with_poster_flag(r) for r in rows]
finally: finally:
conn.close() 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 " "(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" "FROM shows s ORDER BY COALESCE(s.sort_title, s.title) COLLATE NOCASE, s.title"
).fetchall() ).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: finally:
conn.close() conn.close()

View file

@ -33,6 +33,7 @@ def test_blueprint_exposes_dashboard_route():
assert "/api/video/scan/status" in rules assert "/api/video/scan/status" in rules
assert "/api/video/library" in rules assert "/api/video/library" in rules
assert "/api/video/libraries" 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): 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): def test_library_endpoint_lists_content(tmp_path):
client, videoapi = _make_client(tmp_path) client, videoapi = _make_client(tmp_path)
try: 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") resp = client.get("/api/video/library")
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.get_json() data = resp.get_json()
assert [m["title"] for m in data["movies"]] == ["A"] 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 data["shows"] == []
finally: finally:
videoapi._video_db = None videoapi._video_db = None

View file

@ -138,10 +138,15 @@ def test_video_dashboard_data_module_referenced_and_isolated():
def test_video_library_subpage_present(): def test_video_library_subpage_present():
block = _block( block = _block(
_INDEX, r'<section class="video-subpage" data-video-subpage="video-library"', "</section>") _INDEX, r'<section class="video-subpage" data-video-subpage="video-library"', "</section>")
assert "data-video-lib-grid" in block # the card grid # Reuses the music library structure verbatim (no reinvented markup).
assert "data-video-scan" in block # the Scan button assert "library-container" in block
assert "library-search-input" in block # search bar
assert "alphabet-selector" in block # AZ 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 '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(): def test_video_library_module_referenced_and_isolated():

View file

@ -699,28 +699,53 @@
</div> </div>
</section> </section>
<section class="video-subpage" data-video-subpage="video-library" hidden> <section class="video-subpage" data-video-subpage="video-library" hidden>
<div class="page-shell"> <!-- Reuses the music library's exact classes (.library-container,
<div class="dashboard-header"><div class="dashboard-header-sweep" aria-hidden="true"><span></span></div> .library-artist-card, .alphabet-selector, .library-search-input)
<div class="header-text"> so it looks identical — just fed from /api/video/library. -->
<h2 class="header-title"><img src="/static/dashboard.png" class="page-header-icon" alt=""><span>Library</span></h2> <div class="library-page-embed" id="video-library-page">
<p class="header-subtitle">Movies and shows from your media server</p> <div class="library-container">
</div> <div class="library-header">
<div class="header-quick-nav"> <div class="library-header-content">
<button class="header-button watchlist-button" type="button" data-video-scan> <h2 class="library-title"><img src="/static/library.png" class="page-header-icon" alt=""><span>Library</span></h2>
<span class="hero-btn-icon">&#128260;</span> <p class="library-subtitle">Movies and shows from your media server</p>
<span class="hero-btn-label" data-video-scan-label>Scan Library</span> </div>
<div class="library-stats">
<span class="library-stat">
<span class="stat-number" data-video-lib-count>0</span>
<span class="stat-label" data-video-lib-count-label>Movies</span>
</span>
</div>
<button class="library-watchlist-all-btn library-export-btn" type="button" data-video-scan-mode="full" title="Scan the media server">
<span class="watchlist-all-icon" data-video-scan-icon>&#128260;</span>
<span class="watchlist-all-text" data-video-scan-label>Scan</span>
</button> </button>
</div> </div>
</div> <div class="library-controls">
<div class="video-library-toolbar"> <div class="video-library-tabs">
<div class="video-library-tabs"> <button class="video-lib-tab active" type="button" data-video-lib-tab="movies">Movies</button>
<button class="video-lib-tab active" type="button" data-video-lib-tab="movies">Movies</button> <button class="video-lib-tab" type="button" data-video-lib-tab="shows">Shows</button>
<button class="video-lib-tab" type="button" data-video-lib-tab="shows">Shows</button> </div>
<div class="library-search-container">
<input type="text" class="library-search-input" data-video-lib-search placeholder="Search movies...">
<div class="library-search-icon">&#128269;</div>
</div>
<div class="alphabet-selector">
<div class="alphabet-selector-inner" data-video-lib-alphabet></div>
</div>
</div>
<div class="library-content">
<div class="library-loading hidden" data-video-lib-loading>
<div class="loading-spinner"></div>
<div class="loading-text">Loading library...</div>
</div>
<div class="library-artists-grid" data-video-lib-grid></div>
<div class="library-empty hidden" data-video-lib-empty>
<div class="empty-icon">&#127916;</div>
<div class="empty-title" data-video-lib-empty-title>Nothing here yet</div>
<div class="empty-subtitle">Run a scan to pull your library from the media server.</div>
</div>
</div> </div>
<span class="video-library-count" data-video-lib-count></span>
</div> </div>
<div class="video-library-grid" data-video-lib-grid></div>
<p class="video-empty-note" data-video-lib-empty hidden>Nothing here yet — run a scan to pull your library from the media server.</p>
</div> </div>
</section> </section>
<section class="video-subpage" data-video-subpage="video-tools" hidden> <section class="video-subpage" data-video-subpage="video-tools" hidden>

View file

@ -1,84 +1,120 @@
/* /*
* SoulSync Video Library page (isolated). * SoulSync Video Library page (isolated).
* *
* Same isolation contract as the other video modules: self-contained IIFE, no * Reuses the music library's exact look (.library-artist-card grid,
* globals, no inline handlers, lives under static/video/. Listens for the * .alphabet-selector, .library-search-input) only the data is new
* 'soulsync:video-page-shown' event (dispatched by video-side.js) and, when the * (/api/video/library) and posters come from the video poster proxy. Movies/
* Library page is shown, fetches /api/video/library and renders it. The Scan * Shows tabs, a search box, and an AZ selector filter client-side. Cards are
* button asks the server to re-read the media server (source of truth) via * NOT clickable yet (divs, not links). Self-contained IIFE, no globals.
* /api/video/scan/request and polls /api/video/scan/status.
*/ */
(function () { (function () {
'use strict'; 'use strict';
var PAGE_ID = 'video-library'; var PAGE_ID = 'video-library';
var LIBRARY_URL = '/api/video/library'; var LIBRARY_URL = '/api/video/library';
var LETTERS = 'abcdefghijklmnopqrstuvwxyz'.split('');
var state = { tab: 'movies', data: null, loading: false, scanning: false }; var state = { tab: 'movies', data: null, loading: false, search: '', letter: 'all' };
function $(sel) { return document.querySelector(sel); } function $(sel) { return document.querySelector(sel); }
function el(tag, cls, text) { function esc(s) {
var n = document.createElement(tag); return String(s == null ? '' : s)
if (cls) n.className = cls; .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
if (text != null) n.textContent = text; .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return n;
} }
function poster(node, title, url) { // First letter for the AZ buckets, ignoring a leading article.
var p = el('div', 'video-card-poster'); function letterOf(title) {
if (url && /^https?:\/\//.test(url)) { var t = String(title || '').toLowerCase().replace(/^(the|a|an)\s+/, '').trim();
var img = el('img'); var c = t.charAt(0);
img.alt = ''; return /[a-z]/.test(c) ? c : '#';
img.loading = 'lazy'; }
img.src = url;
p.appendChild(img); function buildAlphabet() {
} else { var host = $('[data-video-lib-alphabet]');
p.appendChild(el('span', 'video-card-poster-letter', if (!host || host.childNodes.length) return;
(title || '?').charAt(0).toUpperCase())); var defs = [['all', 'All']].concat(
LETTERS.map(function (l) { return [l, l.toUpperCase()]; }), [['#', '#']]);
for (var i = 0; i < defs.length; i++) {
var b = document.createElement('button');
b.className = 'alphabet-btn' + (defs[i][0] === 'all' ? ' active' : '');
b.setAttribute('data-letter', defs[i][0]);
b.textContent = defs[i][1];
(function (letter, btn) {
btn.addEventListener('click', function () {
state.letter = letter;
var all = host.querySelectorAll('.alphabet-btn');
for (var j = 0; j < all.length; j++) all[j].classList.toggle('active', all[j] === btn);
render();
});
})(defs[i][0], b);
host.appendChild(b);
} }
node.appendChild(p);
} }
function movieCard(m) { function items() {
var card = el('div', 'video-card'); return (state.data && state.data[state.tab]) || [];
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) { function filtered() {
var card = el('div', 'video-card'); var search = state.search.toLowerCase();
poster(card, s.title, s.poster_url); return items().filter(function (it) {
card.appendChild(el('div', 'video-card-title', s.title || 'Untitled')); if (search && String(it.title || '').toLowerCase().indexOf(search) === -1) return false;
if (state.letter !== 'all' && letterOf(it.title) !== state.letter) return false;
return true;
});
}
function cardHTML(it, kind) {
var fallbackEmoji = kind === 'movie' ? '🎬' : '📺';
var img = it.has_poster
? '<div class="library-artist-image"><img src="/api/video/poster/' + kind + '/' + it.id +
'" alt="" loading="lazy" onerror="this.parentNode.innerHTML=\'<div class=&quot;library-artist-image-fallback&quot;>' +
fallbackEmoji + '</div>\'"></div>'
: '<div class="library-artist-image"><div class="library-artist-image-fallback">' + fallbackEmoji + '</div></div>';
var meta = []; var meta = [];
if (s.year) meta.push(String(s.year)); if (it.year) meta.push(String(it.year));
meta.push((s.owned_count || 0) + '/' + (s.episode_count || 0) + ' eps'); if (kind === 'movie') meta.push(it.has_file ? 'Owned' : 'Wanted');
card.appendChild(el('div', 'video-card-meta', meta.join(' · '))); else meta.push((it.owned_count || 0) + '/' + (it.episode_count || 0) + ' eps');
return card;
return '<div class="library-artist-card">' + img +
'<div class="library-artist-info">' +
'<h3 class="library-artist-name" title="' + esc(it.title) + '">' + esc(it.title) + '</h3>' +
'<div class="library-artist-stats"><span class="library-artist-stat">' +
esc(meta.join(' · ')) + '</span></div></div></div>';
} }
function render() { function render() {
var grid = $('[data-video-lib-grid]'); var grid = $('[data-video-lib-grid]');
var empty = $('[data-video-lib-empty]'); var empty = $('[data-video-lib-empty]');
var loading = $('[data-video-lib-loading]');
var count = $('[data-video-lib-count]'); var count = $('[data-video-lib-count]');
var countLabel = $('[data-video-lib-count-label]');
var search = $('[data-video-lib-search]');
if (!grid) return; if (!grid) return;
grid.textContent = '';
var items = state.data ? (state.data[state.tab] || []) : []; if (loading) loading.classList.toggle('hidden', !state.loading);
var maker = state.tab === 'movies' ? movieCard : showCard; if (search) search.placeholder = 'Search ' + (state.tab === 'movies' ? 'movies' : 'shows') + '...';
for (var i = 0; i < items.length; i++) grid.appendChild(maker(items[i])); if (countLabel) countLabel.textContent = state.tab === 'movies' ? 'Movies' : 'Shows';
if (empty) empty.hidden = items.length > 0; if (count) count.textContent = items().length;
if (count) {
count.textContent = state.loading ? 'Loading…' if (state.loading) { grid.innerHTML = ''; if (empty) empty.classList.add('hidden'); return; }
: items.length + ' ' + state.tab;
var kind = state.tab === 'movies' ? 'movie' : 'show';
var list = filtered();
grid.innerHTML = list.map(function (it) { return cardHTML(it, kind); }).join('');
if (empty) {
empty.classList.toggle('hidden', list.length > 0);
var t = $('[data-video-lib-empty-title]');
if (t) t.textContent = items().length === 0 ? 'Nothing here yet'
: 'No ' + (state.tab === 'movies' ? 'movies' : 'shows') + ' match';
} }
} }
function loadLibrary(force) { function load(force) {
if (state.data && !force) { render(); return; } if (state.data && !force) { render(); return; }
state.loading = true; state.loading = true;
render(); render();
@ -89,33 +125,7 @@
state.data = (d && !d.error) ? d : { movies: [], shows: [] }; state.data = (d && !d.error) ? d : { movies: [], shows: [] };
render(); render();
}) })
.catch(function () { .catch(function () { state.loading = false; state.data = { movies: [], shows: [] }; render(); });
state.loading = false;
state.data = { movies: [], shows: [] };
render();
});
}
function setScanLabel(text) {
var label = $('[data-video-scan-label]');
if (label) label.textContent = text;
}
// Scan is triggered by the shared controller (video-scan.js wires the
// [data-video-scan] button); we just reflect its progress + reload on done.
function onScanProgress(e) {
var s = e.detail || {};
if (s.state !== 'scanning') return;
var btn = $('[data-video-scan]');
if (btn) btn.disabled = true;
setScanLabel((s.phase || 'Scanning') + '… ' + (s.movies || 0) + 'm ' + (s.shows || 0) + 's');
}
function onScanDone() {
var btn = $('[data-video-scan]');
if (btn) btn.disabled = false;
setScanLabel('Scan Library');
loadLibrary(true);
} }
function wire() { function wire() {
@ -125,21 +135,37 @@
tab.addEventListener('click', function () { tab.addEventListener('click', function () {
state.tab = tab.getAttribute('data-video-lib-tab'); state.tab = tab.getAttribute('data-video-lib-tab');
var all = document.querySelectorAll('[data-video-lib-tab]'); var all = document.querySelectorAll('[data-video-lib-tab]');
for (var j = 0; j < all.length; j++) { for (var j = 0; j < all.length; j++) all[j].classList.toggle('active', all[j] === tab);
all[j].classList.toggle('active', all[j] === tab);
}
render(); render();
}); });
})(tabs[i]); })(tabs[i]);
} }
var search = $('[data-video-lib-search]');
if (search) {
search.addEventListener('input', function () { state.search = search.value || ''; render(); });
}
}
function onScanProgress(e) {
var s = e.detail || {};
if (s.state !== 'scanning') return;
var label = $('[data-video-scan-label]');
if (label) label.textContent = 'Scanning…';
}
function onScanDone() {
var label = $('[data-video-scan-label]');
if (label) label.textContent = 'Scan';
load(true);
} }
function onPageShown(e) { function onPageShown(e) {
if (!e || e.detail !== PAGE_ID) return; if (!e || e.detail !== PAGE_ID) return;
loadLibrary(false); load(false);
} }
function init() { function init() {
buildAlphabet();
wire(); wire();
document.addEventListener('soulsync:video-page-shown', onPageShown); document.addEventListener('soulsync:video-page-shown', onPageShown);
document.addEventListener('soulsync:video-scan-progress', onScanProgress); document.addEventListener('soulsync:video-scan-progress', onScanProgress);

View file

@ -175,23 +175,20 @@ body[data-side="video"] .dashboard-header-sweep {
} }
/* ── Library page ──────────────────────────────────────────────────────── */ /* ── Library page ──────────────────────────────────────────────────────── */
.video-library-toolbar { /* The grid/cards/search/alphabet all reuse the music library CSS verbatim.
display: flex; The only video-specific bit is the Movies/Shows tab pill. */
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.video-library-tabs { .video-library-tabs {
display: flex; display: flex;
gap: 6px; gap: 6px;
padding: 4px; padding: 4px;
margin: 0 auto 4px;
width: max-content;
background: rgba(0, 0, 0, 0.25); background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.07); border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 12px; border-radius: 12px;
} }
.video-lib-tab { .video-lib-tab {
padding: 8px 18px; padding: 8px 22px;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--text-secondary, #9aa0aa); color: var(--text-secondary, #9aa0aa);
@ -206,50 +203,3 @@ body[data-side="video"] .dashboard-header-sweep {
color: #fff; color: #fff;
background: rgb(var(--accent-rgb, 88 101 242)); 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);
}