video Library: server-side paging + sort/filter + card badges (music parity)

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.
This commit is contained in:
BoulderBadgeDad 2026-06-14 08:57:25 -07:00
parent 12789b6be8
commit 68582af374
8 changed files with 289 additions and 102 deletions

View file

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

View file

@ -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 + AZ + 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."""

View file

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

View file

@ -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():

View file

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

View file

@ -729,6 +729,18 @@
<input type="text" class="library-search-input" data-video-lib-search placeholder="Search movies...">
<div class="library-search-icon">&#128269;</div>
</div>
<div class="video-library-filters">
<select class="library-source-filter-select" data-video-lib-sort title="Sort">
<option value="title">Title</option>
<option value="year">Year</option>
<option value="added">Recently Added</option>
</select>
<select class="library-source-filter-select" data-video-lib-status title="Filter">
<option value="all">All</option>
<option value="owned">Owned</option>
<option value="wanted">Wanted</option>
</select>
</div>
<div class="alphabet-selector">
<div class="alphabet-selector-inner" data-video-lib-alphabet></div>
</div>
@ -744,6 +756,11 @@
<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 class="library-pagination hidden" data-video-lib-pagination>
<button class="pagination-btn" type="button" data-video-lib-prev disabled><span>&larr; Previous</span></button>
<div class="pagination-info"><span data-video-lib-pageinfo>Page 1 of 1</span></div>
<button class="pagination-btn" type="button" data-video-lib-next disabled><span>Next &rarr;</span></button>
</div>
</div>
</div>
</div>

View file

@ -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 AZ 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 / AZ /
* 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, '&quot;').replace(/'/g, '&#39;');
}
// 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 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
? '<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>';
fallback + '</div>\'"></div>'
: '<div class="library-artist-image"><div class="library-artist-image-fallback">' + fallback + '</div></div>';
var badge = '';
if (kind === 'movie') {
var rl = resLabel(it.resolution);
if (rl) badge = '<div class="video-card-badge">' + rl + '</div>';
}
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 '<div class="library-artist-card">' + img +
return '<div class="library-artist-card">' + img + badge +
'<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 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);
}

View file

@ -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);
}