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 .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

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()
# ── 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]:
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()

View file

@ -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

View file

@ -138,10 +138,15 @@ def test_video_dashboard_data_module_referenced_and_isolated():
def test_video_library_subpage_present():
block = _block(
_INDEX, r'<section class="video-subpage" data-video-subpage="video-library"', "</section>")
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 # 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 "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():

View file

@ -699,28 +699,53 @@
</div>
</section>
<section class="video-subpage" data-video-subpage="video-library" hidden>
<div class="page-shell">
<div class="dashboard-header"><div class="dashboard-header-sweep" aria-hidden="true"><span></span></div>
<div class="header-text">
<h2 class="header-title"><img src="/static/dashboard.png" class="page-header-icon" alt=""><span>Library</span></h2>
<p class="header-subtitle">Movies and shows from your media server</p>
</div>
<div class="header-quick-nav">
<button class="header-button watchlist-button" type="button" data-video-scan>
<span class="hero-btn-icon">&#128260;</span>
<span class="hero-btn-label" data-video-scan-label>Scan Library</span>
<!-- Reuses the music library's exact classes (.library-container,
.library-artist-card, .alphabet-selector, .library-search-input)
so it looks identical — just fed from /api/video/library. -->
<div class="library-page-embed" id="video-library-page">
<div class="library-container">
<div class="library-header">
<div class="library-header-content">
<h2 class="library-title"><img src="/static/library.png" class="page-header-icon" alt=""><span>Library</span></h2>
<p class="library-subtitle">Movies and shows from your media server</p>
</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>
</div>
</div>
<div class="video-library-toolbar">
<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" type="button" data-video-lib-tab="shows">Shows</button>
<div class="library-controls">
<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" 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>
<span class="video-library-count" data-video-lib-count></span>
</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>
</section>
<section class="video-subpage" data-video-subpage="video-tools" hidden>

View file

@ -1,84 +1,120 @@
/*
* 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.
* Reuses the music library's exact look (.library-artist-card grid,
* .alphabet-selector, .library-search-input) only the data is new
* (/api/video/library) and posters come from the video poster proxy. Movies/
* Shows tabs, a search box, and an AZ selector filter client-side. Cards are
* NOT clickable yet (divs, not links). Self-contained IIFE, no globals.
*/
(function () {
'use strict';
var PAGE_ID = '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 el(tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text;
return n;
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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()));
// First letter for the AZ buckets, ignoring a leading article.
function letterOf(title) {
var t = String(title || '').toLowerCase().replace(/^(the|a|an)\s+/, '').trim();
var c = t.charAt(0);
return /[a-z]/.test(c) ? c : '#';
}
function buildAlphabet() {
var host = $('[data-video-lib-alphabet]');
if (!host || host.childNodes.length) return;
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) {
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 items() {
return (state.data && state.data[state.tab]) || [];
}
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'));
function filtered() {
var search = state.search.toLowerCase();
return items().filter(function (it) {
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 = [];
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;
if (it.year) meta.push(String(it.year));
if (kind === 'movie') meta.push(it.has_file ? 'Owned' : 'Wanted');
else meta.push((it.owned_count || 0) + '/' + (it.episode_count || 0) + ' eps');
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() {
var grid = $('[data-video-lib-grid]');
var empty = $('[data-video-lib-empty]');
var loading = $('[data-video-lib-loading]');
var count = $('[data-video-lib-count]');
var countLabel = $('[data-video-lib-count-label]');
var search = $('[data-video-lib-search]');
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;
if (loading) loading.classList.toggle('hidden', !state.loading);
if (search) search.placeholder = 'Search ' + (state.tab === 'movies' ? 'movies' : 'shows') + '...';
if (countLabel) countLabel.textContent = state.tab === 'movies' ? 'Movies' : 'Shows';
if (count) count.textContent = items().length;
if (state.loading) { grid.innerHTML = ''; if (empty) empty.classList.add('hidden'); return; }
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; }
state.loading = true;
render();
@ -89,33 +125,7 @@
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;
}
// 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);
.catch(function () { state.loading = false; state.data = { movies: [], shows: [] }; render(); });
}
function wire() {
@ -125,21 +135,37 @@
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);
}
for (var j = 0; j < all.length; j++) all[j].classList.toggle('active', all[j] === tab);
render();
});
})(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) {
if (!e || e.detail !== PAGE_ID) return;
loadLibrary(false);
load(false);
}
function init() {
buildAlphabet();
wire();
document.addEventListener('soulsync:video-page-shown', onPageShown);
document.addEventListener('soulsync:video-scan-progress', onScanProgress);

View file

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