From 68582af374fd3a77c786f2956640c00b1162b13d Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 14 Jun 2026 08:57:25 -0700 Subject: [PATCH] video Library: server-side paging + sort/filter + card badges (music parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handles big libraries (your ~8500 movies) like music does instead of rendering everything at once. - DB: sort_title populated article-aware on upsert ('The Matrix' files under M); query_library(kind, search, letter, sort, status, page, limit) does all filtering/sorting/paging in SQL and returns music's pagination shape {page,total_pages,total_count,has_prev,has_next} + badge fields (resolution, owned/episode counts). - GET /api/video/library now takes those params (per kind) instead of dumping everything. - Library page: 75/page with ← Previous / Page X of Y / Next → (music's exact controls/classes), Sort (Title/Year/Recently Added) + Owned/Wanted filter, server-side search + A–Z. Cards gain a resolution chip (4K/1080p/…) and the owned/wanted meta. Still not clickable. 124 tests green. --- api/video/library.py | 15 ++- database/video_database.py | 117 ++++++++++++++++--- tests/test_video_api.py | 10 +- tests/test_video_database.py | 39 +++++++ tests/test_video_side_shell.py | 3 + webui/index.html | 17 +++ webui/static/video/video-library.js | 168 +++++++++++++++------------- webui/static/video/video-side.css | 22 ++++ 8 files changed, 289 insertions(+), 102 deletions(-) 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 @@
🔍
+
+ + +
@@ -744,6 +756,11 @@
Nothing here yet
Run a scan to pull your library from the media server.
+ diff --git a/webui/static/video/video-library.js b/webui/static/video/video-library.js index 4cd9b497..a7a20994 100644 --- a/webui/static/video/video-library.js +++ b/webui/static/video/video-library.js @@ -1,11 +1,10 @@ /* * SoulSync — Video Library page (isolated). * - * 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 A–Z selector filter client-side. Cards are - * NOT clickable yet (divs, not links). Self-contained IIFE, no globals. + * Reuses the music library's look + paging model: server-side search / A–Z / + * sort / owned-wanted filter / pagination (75 per page) via /api/video/library, + * rendering one page of music-style .library-artist-card cards (with a poster + * proxy + resolution badge). Cards are NOT clickable yet. IIFE, no globals. */ (function () { 'use strict'; @@ -14,7 +13,9 @@ var LIBRARY_URL = '/api/video/library'; var LETTERS = 'abcdefghijklmnopqrstuvwxyz'.split(''); - var state = { tab: 'movies', data: null, loading: false, search: '', letter: 'all' }; + var state = { tab: 'movies', search: '', letter: 'all', sort: 'title', + status: 'all', page: 1, limit: 75, loaded: false }; + var searchTimer = null; function $(sel) { return document.querySelector(sel); } @@ -24,11 +25,14 @@ .replace(/"/g, '"').replace(/'/g, '''); } - // First letter for the A–Z 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 resLabel(res) { + if (!res) return ''; + res = String(res).toLowerCase(); + if (res.indexOf('2160') > -1 || res === '4k') return '4K'; + if (res.indexOf('1080') > -1) return '1080p'; + if (res.indexOf('720') > -1) return '720p'; + if (res.indexOf('480') > -1 || res.indexOf('576') > -1) return 'SD'; + return res.toUpperCase(); } function buildAlphabet() { @@ -46,88 +50,92 @@ 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(); + reload(); }); })(defs[i][0], b); host.appendChild(b); } } - function items() { - return (state.data && state.data[state.tab]) || []; - } - - 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 fallback = kind === 'movie' ? '🎬' : '📺'; var img = it.has_poster ? '
' - : '
' + fallbackEmoji + '
'; + fallback + '\'">' + : '
' + fallback + '
'; + + var badge = ''; + if (kind === 'movie') { + var rl = resLabel(it.resolution); + if (rl) badge = '
' + rl + '
'; + } var meta = []; 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 '
' + img + + return '
' + img + badge + '
' + '

' + esc(it.title) + '

' + '
' + esc(meta.join(' · ')) + '
'; } - function render() { + function showLoading(on) { + var l = $('[data-video-lib-loading]'); + if (l) l.classList.toggle('hidden', !on); + } + + function renderItems(items, kind) { 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; - - 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'; - } + if (grid) grid.innerHTML = items.map(function (it) { return cardHTML(it, kind); }).join(''); + if (empty) empty.classList.toggle('hidden', items.length > 0); } - function load(force) { - if (state.data && !force) { render(); return; } - state.loading = true; - render(); - fetch(LIBRARY_URL, { headers: { 'Accept': 'application/json' } }) + function updatePagination(p) { + var box = $('[data-video-lib-pagination]'); + var prev = $('[data-video-lib-prev]'); + var next = $('[data-video-lib-next]'); + var info = $('[data-video-lib-pageinfo]'); + if (!box) return; + if (!p) { box.classList.add('hidden'); return; } + if (prev) prev.disabled = !p.has_prev; + if (next) next.disabled = !p.has_next; + if (info) info.textContent = 'Page ' + p.page + ' of ' + p.total_pages; + box.classList.toggle('hidden', p.total_pages <= 1); + } + + function setCount(n) { + var c = $('[data-video-lib-count]'); + var lbl = $('[data-video-lib-count-label]'); + if (c) c.textContent = n; + if (lbl) lbl.textContent = state.tab === 'movies' ? 'Movies' : 'Shows'; + } + + function load() { + state.loaded = true; + showLoading(true); + var kind = state.tab === 'movies' ? 'movies' : 'shows'; + var params = new URLSearchParams({ + kind: kind, search: state.search, letter: state.letter, sort: state.sort, + status: state.status, page: state.page, limit: state.limit }); + fetch(LIBRARY_URL + '?' + params.toString(), { 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(); + showLoading(false); + if (!d || d.error) { renderItems([], kind); updatePagination(null); setCount(0); return; } + renderItems(d.items || [], kind); + updatePagination(d.pagination); + setCount(d.pagination ? d.pagination.total_count : 0); }) - .catch(function () { state.loading = false; state.data = { movies: [], shows: [] }; render(); }); + .catch(function () { showLoading(false); renderItems([], kind); updatePagination(null); setCount(0); }); } + function reload() { state.page = 1; load(); } + function wire() { var tabs = document.querySelectorAll('[data-video-lib-tab]'); for (var i = 0; i < tabs.length; i++) { @@ -136,39 +144,43 @@ 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(); + var s = $('[data-video-lib-search]'); + if (s) s.placeholder = 'Search ' + (state.tab === 'movies' ? 'movies' : 'shows') + '...'; + reload(); }); })(tabs[i]); } var search = $('[data-video-lib-search]'); if (search) { - search.addEventListener('input', function () { state.search = search.value || ''; render(); }); + search.addEventListener('input', function () { + state.search = search.value || ''; + if (searchTimer) clearTimeout(searchTimer); + searchTimer = setTimeout(reload, 300); + }); } + var sort = $('[data-video-lib-sort]'); + if (sort) sort.addEventListener('change', function () { state.sort = sort.value; reload(); }); + var status = $('[data-video-lib-status]'); + if (status) status.addEventListener('change', function () { state.status = status.value; reload(); }); + var prev = $('[data-video-lib-prev]'); + if (prev) prev.addEventListener('click', function () { + if (state.page > 1) { state.page--; load(); } + }); + var next = $('[data-video-lib-next]'); + if (next) next.addEventListener('click', function () { state.page++; load(); }); } - 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 onScanDone() { if (state.loaded) load(); } function onPageShown(e) { if (!e || e.detail !== PAGE_ID) return; - load(false); + if (!state.loaded) load(); } function init() { buildAlphabet(); wire(); document.addEventListener('soulsync:video-page-shown', onPageShown); - document.addEventListener('soulsync:video-scan-progress', onScanProgress); document.addEventListener('soulsync:video-scan-done', onScanDone); } diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index ba90dad2..63760137 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -209,3 +209,25 @@ body[data-side="video"] .dashboard-header-sweep { color: #fff; background: rgb(var(--accent-rgb, 88 101 242)); } +.video-library-filters { + display: flex; + gap: 10px; + justify-content: center; + margin: 4px 0; +} +/* Resolution chip on a poster card (top-left, above the gradient overlay). */ +.library-artist-card .video-card-badge { + position: absolute; + top: 8px; + left: 8px; + z-index: 3; + padding: 2px 7px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + color: #fff; + background: rgba(0, 0, 0, 0.7); + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 5px; + backdrop-filter: blur(4px); +}