video 'capture everything' (phase 1): stills, genres, ratings, tagline

Captures the richer metadata the media server already exposes (schema v2;
idempotent migrations + CREATE IF NOT EXISTS, so an existing DB upgrades on
restart with no wipe):
- movies: tagline, rating (audience), rating_critic; shows: tagline, rating,
  first/last air date; episodes: still_url + rating.
- Genres as a normalised many-to-many (genres + movie_genres/show_genres link
  tables — no comma-blob), deduped, replace-on-upsert.
- Plex (.genres/.tagline/.audienceRating/.rating/.thumb) + Jellyfin (Genres/
  Taglines/CommunityRating/CriticRating/Premiere+EndDate/episode Primary) both
  extract them; episode stills served via /api/video/poster/episode/<id>.
- Detail payloads return genres/tagline/rating/air-dates + per-episode has_still;
  the billboard shows a tagline, ★ score, genre chips, and episode rows render
  REAL stills (no more orange placeholder once scanned).

Seam tests for genre dedup/replace, show+episode capture, episode still ref.
Cast/crew (people + credits) is the next phase.
This commit is contained in:
BoulderBadgeDad 2026-06-14 17:17:20 -07:00
parent c450fa1f9a
commit e1e0e29432
7 changed files with 210 additions and 22 deletions

View file

@ -195,6 +195,23 @@ class PlexVideoSource:
except Exception:
return None
@staticmethod
def _tags(seq) -> list:
"""Tag names from a Plex tag list (genres/etc.)."""
out = []
for t in (seq or []):
tag = getattr(t, "tag", None)
if tag:
out.append(tag)
return out
@staticmethod
def _date(val):
try:
return val.date().isoformat() if val else None
except Exception:
return None
def _movie(self, m) -> dict:
dur = getattr(m, "duration", None)
d = {
@ -205,6 +222,10 @@ class PlexVideoSource:
"poster_url": getattr(m, "thumb", None),
"content_rating": getattr(m, "contentRating", None),
"studio": getattr(m, "studio", None),
"tagline": getattr(m, "tagline", None),
"rating": getattr(m, "audienceRating", None),
"rating_critic": getattr(m, "rating", None),
"genres": self._tags(getattr(m, "genres", None)),
"runtime_minutes": int(dur / 60000) if dur else None,
"file": self._part_file(m),
}
@ -222,6 +243,8 @@ class PlexVideoSource:
"overview": getattr(ep, "summary", None),
"air_date": aired.date().isoformat() if aired else None,
"runtime_minutes": int(dur / 60000) if dur else None,
"still_url": getattr(ep, "thumb", None),
"rating": getattr(ep, "audienceRating", None),
"tvdb_id": _parse_plex_guids(ep).get("tvdb_id"),
"file": self._part_file(ep),
}
@ -271,6 +294,11 @@ class PlexVideoSource:
"status": None,
"network": getattr(sh, "network", None),
"content_rating": getattr(sh, "contentRating", None),
"tagline": getattr(sh, "tagline", None),
"rating": getattr(sh, "audienceRating", None),
"first_air_date": self._date(getattr(sh, "originallyAvailableAt", None)),
"last_air_date": None,
"genres": self._tags(getattr(sh, "genres", None)),
"seasons": seasons,
}
d.update(_parse_plex_guids(sh))
@ -278,8 +306,12 @@ class PlexVideoSource:
# ── Jellyfin ────────────────────────────────────────────────────────────────
_JF_MOVIE_FIELDS = "Overview,Path,MediaSources,ProductionYear,OfficialRating,RunTimeTicks,Studios,ProviderIds"
_JF_EP_FIELDS = "Overview,Path,MediaSources,PremiereDate,RunTimeTicks,IndexNumber,ParentIndexNumber,ProviderIds"
_JF_MOVIE_FIELDS = ("Overview,Path,MediaSources,ProductionYear,OfficialRating,RunTimeTicks,Studios,"
"ProviderIds,Genres,Taglines,CommunityRating,CriticRating")
_JF_EP_FIELDS = ("Overview,Path,MediaSources,PremiereDate,RunTimeTicks,IndexNumber,ParentIndexNumber,"
"ProviderIds,CommunityRating")
_JF_SHOW_FIELDS = ("Overview,ProductionYear,OfficialRating,ProviderIds,Genres,Taglines,CommunityRating,"
"PremiereDate,EndDate")
class JellyfinVideoSource:
@ -374,6 +406,11 @@ class JellyfinVideoSource:
except Exception:
logger.exception("Jellyfin: skipping movie %s", it.get("Name", "?"))
@staticmethod
def _first(seq):
seq = seq or []
return seq[0] if seq else None
def _movie(self, it) -> dict:
studios = it.get("Studios") or []
ticks = it.get("RunTimeTicks")
@ -385,6 +422,10 @@ class JellyfinVideoSource:
"poster_url": (it.get("ImageTags") or {}).get("Primary"),
"content_rating": it.get("OfficialRating"),
"studio": studios[0].get("Name") if studios else None,
"tagline": self._first(it.get("Taglines")),
"rating": it.get("CommunityRating"),
"rating_critic": it.get("CriticRating"),
"genres": it.get("Genres") or [],
"runtime_minutes": int(ticks / 600_000_000) if ticks else None,
"file": self._file(it),
}
@ -395,7 +436,7 @@ class JellyfinVideoSource:
path = f"/Users/{self.uid}/Items"
for view in self._views("tvshows", self._tv_lib):
params = {"ParentId": view["Id"], "IncludeItemTypes": "Series",
"Recursive": "true", "Fields": "Overview,ProductionYear,OfficialRating,ProviderIds"}
"Recursive": "true", "Fields": _JF_SHOW_FIELDS}
if incremental:
params.update({"SortBy": "DateCreated", "SortOrder": "Descending", "Limit": "50"})
items = (self._req(path, params) or {}).get("Items", [])
@ -429,6 +470,8 @@ class JellyfinVideoSource:
"overview": ep.get("Overview"),
"air_date": aired[:10] if aired else None,
"runtime_minutes": int(ticks / 600_000_000) if ticks else None,
"still_url": (ep.get("ImageTags") or {}).get("Primary"),
"rating": ep.get("CommunityRating"),
"tvdb_id": _parse_jf_providers(ep).get("tvdb_id"),
"file": self._file(ep),
})
@ -456,6 +499,8 @@ class JellyfinVideoSource:
"poster_url": meta.get("poster_url"), "episodes": eps})
except Exception:
logger.exception("Jellyfin: failed reading episodes for %s", it.get("Name", "?"))
premiere = it.get("PremiereDate")
end = it.get("EndDate")
d = {
"server_id": series_id,
"title": it.get("Name"),
@ -465,6 +510,11 @@ class JellyfinVideoSource:
"status": None,
"network": None,
"content_rating": it.get("OfficialRating"),
"tagline": self._first(it.get("Taglines")),
"rating": it.get("CommunityRating"),
"first_air_date": premiere[:10] if premiere else None,
"last_air_date": end[:10] if end else None,
"genres": it.get("Genres") or [],
"seasons": seasons,
}
d.update(_parse_jf_providers(it))

View file

@ -29,7 +29,7 @@ logger = get_logger("video_database")
# Bump when video_schema.sql changes in a way worth recording. Stored in
# PRAGMA user_version as a backstop indicator (nothing gates on it yet).
SCHEMA_VERSION = 1
SCHEMA_VERSION = 2
_DEFAULT_DB_PATH = "database/video_library.db"
_SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql"
@ -76,6 +76,16 @@ _COLUMN_MIGRATIONS = [
("shows", "tmdb_last_attempted", "TEXT"),
("shows", "tvdb_match_status", "TEXT"),
("shows", "tvdb_last_attempted", "TEXT"),
# "capture everything" — richer metadata from the server.
("movies", "tagline", "TEXT"),
("movies", "rating", "REAL"),
("movies", "rating_critic", "REAL"),
("shows", "tagline", "TEXT"),
("shows", "rating", "REAL"),
("shows", "first_air_date", "TEXT"),
("shows", "last_air_date", "TEXT"),
("episodes", "still_url", "TEXT"),
("episodes", "rating", "REAL"),
]
@ -398,23 +408,40 @@ class VideoDatabase:
file.get("runtime_seconds")),
)
@staticmethod
def _set_genres(conn, link_table: str, owner_col: str, owner_id: int, names) -> None:
"""Replace the genre links for one owner (normalised; dedup names in the
shared genres table). owner_col/link_table are internal, never user input."""
conn.execute(f"DELETE FROM {link_table} WHERE {owner_col}=?", (owner_id,))
for raw in (names or []):
name = (raw or "").strip()
if not name:
continue
conn.execute("INSERT OR IGNORE INTO genres (name) VALUES (?)", (name,))
gid = conn.execute("SELECT id FROM genres WHERE name=? COLLATE NOCASE", (name,)).fetchone()["id"]
conn.execute(f"INSERT OR IGNORE INTO {link_table} ({owner_col}, genre_id) VALUES (?, ?)",
(owner_id, gid))
def upsert_movie(self, server_source: str, item: dict) -> int:
"""Insert/update one movie (keyed on server id) and its file. Returns row id."""
conn = self._get_connection()
try:
conn.execute(
"INSERT INTO movies (server_source, server_id, title, sort_title, year, overview, "
"runtime_minutes, content_rating, studio, poster_url, tmdb_id, imdb_id, has_file, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) "
"runtime_minutes, content_rating, studio, tagline, rating, rating_critic, "
"poster_url, tmdb_id, imdb_id, has_file, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) "
"ON CONFLICT(server_source, server_id) DO UPDATE SET "
"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, "
"tagline=excluded.tagline, rating=excluded.rating, rating_critic=excluded.rating_critic, "
"poster_url=excluded.poster_url, tmdb_id=excluded.tmdb_id, imdb_id=excluded.imdb_id, "
"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"),
item.get("content_rating"), item.get("studio"), item.get("tagline"),
item.get("rating"), item.get("rating_critic"), item.get("poster_url"),
item.get("tmdb_id"), item.get("imdb_id"), 1 if item.get("file") else 0),
)
movie_id = conn.execute(
@ -422,6 +449,7 @@ class VideoDatabase:
(server_source, item["server_id"]),
).fetchone()["id"]
self._set_media_file(conn, "movie_id", movie_id, item.get("file"))
self._set_genres(conn, "movie_genres", "movie_id", movie_id, item.get("genres"))
conn.commit()
return movie_id
finally:
@ -435,23 +463,28 @@ class VideoDatabase:
try:
conn.execute(
"INSERT INTO shows (server_source, server_id, title, sort_title, year, overview, status, "
"network, runtime_minutes, content_rating, poster_url, tvdb_id, tmdb_id, imdb_id, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) "
"network, runtime_minutes, content_rating, tagline, rating, first_air_date, last_air_date, "
"poster_url, tvdb_id, tmdb_id, imdb_id, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) "
"ON CONFLICT(server_source, server_id) DO UPDATE SET "
"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, "
"tagline=excluded.tagline, rating=excluded.rating, first_air_date=excluded.first_air_date, "
"last_air_date=excluded.last_air_date, "
"poster_url=excluded.poster_url, tvdb_id=excluded.tvdb_id, tmdb_id=excluded.tmdb_id, "
"imdb_id=excluded.imdb_id, 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("status"), item.get("network"),
item.get("runtime_minutes"), item.get("content_rating"), item.get("poster_url"),
item.get("tvdb_id"), item.get("tmdb_id"), item.get("imdb_id")),
item.get("runtime_minutes"), item.get("content_rating"), item.get("tagline"),
item.get("rating"), item.get("first_air_date"), item.get("last_air_date"),
item.get("poster_url"), item.get("tvdb_id"), item.get("tmdb_id"), item.get("imdb_id")),
)
show_id = conn.execute(
"SELECT id FROM shows WHERE server_source=? AND server_id=?",
(server_source, item["server_id"]),
).fetchone()["id"]
self._set_genres(conn, "show_genres", "show_id", show_id, item.get("genres"))
seen_seasons: set[int] = set()
seen_eps: set[tuple[int, int]] = set()
@ -479,17 +512,18 @@ class VideoDatabase:
conn.execute(
"INSERT INTO episodes (show_id, season_id, server_source, server_id, "
"season_number, episode_number, title, overview, air_date, "
"runtime_minutes, tvdb_id, has_file) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"runtime_minutes, still_url, rating, tvdb_id, has_file) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(show_id, season_number, episode_number) DO UPDATE SET "
"season_id=excluded.season_id, server_source=excluded.server_source, "
"server_id=excluded.server_id, title=excluded.title, "
"overview=excluded.overview, air_date=excluded.air_date, "
"runtime_minutes=excluded.runtime_minutes, tvdb_id=excluded.tvdb_id, "
"has_file=excluded.has_file",
"runtime_minutes=excluded.runtime_minutes, still_url=excluded.still_url, "
"rating=excluded.rating, tvdb_id=excluded.tvdb_id, has_file=excluded.has_file",
(show_id, season_id, server_source, ep.get("server_id"), snum, enum,
ep.get("title"), ep.get("overview"), ep.get("air_date"),
ep.get("runtime_minutes"), ep.get("tvdb_id"), 1 if ep.get("file") else 0),
ep.get("runtime_minutes"), ep.get("still_url"), ep.get("rating"),
ep.get("tvdb_id"), 1 if ep.get("file") else 0),
)
ep_id = conn.execute(
"SELECT id FROM episodes WHERE show_id=? AND season_number=? AND episode_number=?",
@ -617,6 +651,14 @@ class VideoDatabase:
"FROM seasons se JOIN shows sh ON sh.id = se.show_id WHERE se.id=?",
(item_id,)).fetchone()
return dict(row) if row else None
if kind == "episode":
if art != "poster":
return None
# Episode still; episodes carry their own server_source + the still path.
row = conn.execute(
"SELECT server_source, server_id, still_url AS poster_url "
"FROM episodes WHERE id=?", (item_id,)).fetchone()
return dict(row) if row else None
table = {"movie": "movies", "show": "shows"}.get(kind)
col = {"poster": "poster_url", "backdrop": "backdrop_url"}.get(art)
if not table or not col:
@ -629,6 +671,13 @@ class VideoDatabase:
conn.close()
# ── detail payloads (drill-in pages) ──────────────────────────────────────
@staticmethod
def _genres_for(conn, link_table: str, owner_col: str, owner_id: int) -> list:
rows = conn.execute(
f"SELECT g.name FROM {link_table} lt JOIN genres g ON g.id = lt.genre_id "
f"WHERE lt.{owner_col}=? ORDER BY g.name", (owner_id,)).fetchall()
return [r["name"] for r in rows]
def show_detail(self, show_id: int) -> dict | None:
"""Full TV-show detail: the show + its seasons → episodes tree, with
owned/total roll-ups. Drives the (isolated) video show-detail page."""
@ -637,13 +686,15 @@ class VideoDatabase:
show = conn.execute("SELECT * FROM shows WHERE id=?", (show_id,)).fetchone()
if not show:
return None
genres = self._genres_for(conn, "show_genres", "show_id", show_id)
seasons = conn.execute(
"SELECT id, season_number, title, overview, "
"(poster_url IS NOT NULL AND poster_url<>'') AS has_poster "
"FROM seasons WHERE show_id=? ORDER BY season_number", (show_id,)).fetchall()
eps = conn.execute(
"SELECT id, season_number, episode_number, title, overview, air_date, "
"runtime_minutes, monitored, has_file FROM episodes WHERE show_id=? "
"runtime_minutes, rating, monitored, has_file, "
"(still_url IS NOT NULL AND still_url<>'') AS has_still FROM episodes WHERE show_id=? "
"ORDER BY season_number, episode_number", (show_id,)).fetchall()
finally:
conn.close()
@ -653,7 +704,8 @@ class VideoDatabase:
by_season.setdefault(e["season_number"], []).append({
"id": e["id"], "episode_number": e["episode_number"],
"title": e["title"], "overview": e["overview"], "air_date": e["air_date"],
"runtime_minutes": e["runtime_minutes"],
"runtime_minutes": e["runtime_minutes"], "rating": e["rating"],
"has_still": bool(e["has_still"]),
"monitored": bool(e["monitored"]), "owned": bool(e["has_file"]),
})
@ -686,6 +738,9 @@ class VideoDatabase:
"kind": "show", "id": show["id"], "title": show["title"], "year": show["year"],
"overview": show["overview"], "status": show["status"], "network": show["network"],
"content_rating": show["content_rating"], "runtime_minutes": show["runtime_minutes"],
"tagline": show["tagline"], "rating": show["rating"],
"first_air_date": show["first_air_date"], "last_air_date": show["last_air_date"],
"genres": genres,
"tmdb_id": show["tmdb_id"], "tvdb_id": show["tvdb_id"], "imdb_id": show["imdb_id"],
"has_poster": bool(show["poster_url"]), "has_backdrop": bool(show["backdrop_url"]),
"monitored": bool(show["monitored"]),
@ -717,6 +772,7 @@ class VideoDatabase:
m = conn.execute("SELECT * FROM movies WHERE id=?", (movie_id,)).fetchone()
if not m:
return None
genres = self._genres_for(conn, "movie_genres", "movie_id", movie_id)
f = conn.execute(
"SELECT resolution, quality, video_codec, audio_codec, size_bytes "
"FROM media_files WHERE movie_id=? ORDER BY size_bytes DESC LIMIT 1",
@ -727,7 +783,8 @@ class VideoDatabase:
"kind": "movie", "id": m["id"], "title": m["title"], "year": m["year"],
"overview": m["overview"], "status": m["status"], "studio": m["studio"],
"release_date": m["release_date"], "runtime_minutes": m["runtime_minutes"],
"content_rating": m["content_rating"],
"content_rating": m["content_rating"], "tagline": m["tagline"],
"rating": m["rating"], "rating_critic": m["rating_critic"], "genres": genres,
"tmdb_id": m["tmdb_id"], "imdb_id": m["imdb_id"],
"has_poster": bool(m["poster_url"]), "has_backdrop": bool(m["backdrop_url"]),
"owned": bool(m["has_file"]), "monitored": bool(m["monitored"]),

View file

@ -77,6 +77,9 @@ CREATE TABLE IF NOT EXISTS movies (
digital_release_date TEXT,
studio TEXT,
content_rating TEXT, -- e.g. PG-13
tagline TEXT,
rating REAL, -- audience score (0-10)
rating_critic REAL, -- critic score (0-100) when offered
poster_url TEXT,
backdrop_url TEXT,
monitored INTEGER NOT NULL DEFAULT 1, -- tracked for acquisition
@ -114,6 +117,10 @@ CREATE TABLE IF NOT EXISTS shows (
network TEXT,
runtime_minutes INTEGER,
content_rating TEXT,
tagline TEXT,
rating REAL, -- audience score (0-10)
first_air_date TEXT,
last_air_date TEXT,
poster_url TEXT,
backdrop_url TEXT,
monitored INTEGER NOT NULL DEFAULT 1, -- "following" (watchlist)
@ -152,6 +159,8 @@ CREATE TABLE IF NOT EXISTS episodes (
overview TEXT,
air_date TEXT, -- ISO date — drives the Calendar
runtime_minutes INTEGER,
still_url TEXT, -- per-episode thumbnail (server image path)
rating REAL, -- audience score (0-10)
tvdb_id INTEGER,
monitored INTEGER NOT NULL DEFAULT 1,
has_file INTEGER NOT NULL DEFAULT 0,
@ -161,6 +170,24 @@ CREATE INDEX IF NOT EXISTS idx_episodes_show ON episodes(show_id);
CREATE INDEX IF NOT EXISTS idx_episodes_air ON episodes(air_date);
CREATE INDEX IF NOT EXISTS idx_episodes_wanted ON episodes(monitored, has_file);
-- ── Genres (normalised many-to-many; no comma-blob) ─────────────────────────
CREATE TABLE IF NOT EXISTS genres (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE COLLATE NOCASE
);
CREATE TABLE IF NOT EXISTS movie_genres (
movie_id INTEGER NOT NULL REFERENCES movies(id) ON DELETE CASCADE,
genre_id INTEGER NOT NULL REFERENCES genres(id) ON DELETE CASCADE,
PRIMARY KEY (movie_id, genre_id)
);
CREATE TABLE IF NOT EXISTS show_genres (
show_id INTEGER NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
genre_id INTEGER NOT NULL REFERENCES genres(id) ON DELETE CASCADE,
PRIMARY KEY (show_id, genre_id)
);
CREATE INDEX IF NOT EXISTS idx_movie_genres_genre ON movie_genres(genre_id);
CREATE INDEX IF NOT EXISTS idx_show_genres_genre ON show_genres(genre_id);
-- ── Content: YouTube (channels → videos) ────────────────────────────────────
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY,

View file

@ -22,6 +22,7 @@ EXPECTED_TABLES = {
"meta", "root_folders", "quality_profiles", "video_settings",
"movies", "shows", "seasons", "episodes", "channels", "channel_videos",
"media_files", "downloads", "activity",
"genres", "movie_genres", "show_genres",
}
EXPECTED_VIEWS = {"v_watchlist", "v_wishlist", "v_calendar"}
@ -272,6 +273,36 @@ def test_show_detail_returns_none_for_missing(db):
assert db.show_detail(999999) is None
def test_capture_everything_movie(db):
mid = db.upsert_movie("plex", {
"server_id": "m1", "title": "Dune", "tagline": "Fear is the mind-killer",
"rating": 8.4, "rating_critic": 83, "genres": ["Sci-Fi", "Adventure", "Sci-Fi"]})
d = db.movie_detail(mid)
assert d["tagline"] == "Fear is the mind-killer" and d["rating"] == 8.4 and d["rating_critic"] == 83
assert d["genres"] == ["Adventure", "Sci-Fi"] # deduped + sorted
# Re-upsert with different genres replaces the links (no stale rows).
db.upsert_movie("plex", {"server_id": "m1", "title": "Dune", "genres": ["Drama"]})
assert db.movie_detail(mid)["genres"] == ["Drama"]
def test_capture_everything_show_and_episode_still(db):
sid = db.upsert_show_tree("plex", {
"server_id": "s1", "title": "Show", "tagline": "Tick tock", "rating": 9.1,
"first_air_date": "2015-01-16", "genres": ["Drama", "Mystery"], "seasons": [
{"season_number": 1, "episodes": [
{"episode_number": 1, "title": "Pilot", "still_url": "/ep1.jpg", "rating": 8.0}]}]})
d = db.show_detail(sid)
assert d["tagline"] == "Tick tock" and d["rating"] == 9.1 and d["first_air_date"] == "2015-01-16"
assert d["genres"] == ["Drama", "Mystery"]
ep = d["seasons"][0]["episodes"][0]
assert ep["has_still"] is True and ep["rating"] == 8.0
# Episode still resolves through the image proxy ref (server source from the episode row).
with db.connect() as c:
eid = c.execute("SELECT id FROM episodes WHERE title='Pilot'").fetchone()["id"]
ref = db.get_art_ref("episode", eid, "poster")
assert ref["poster_url"] == "/ep1.jpg" and ref["server_source"] == "plex"
def test_movie_detail_includes_owned_and_file(db):
mid = db.upsert_movie("plex", {
"server_id": "m1", "title": "Dune", "year": 2021, "overview": "Sand",

View file

@ -829,6 +829,7 @@
<div class="vd-bb-fade" aria-hidden="true"></div>
<div class="vd-bb-content">
<h1 class="vd-title" data-vd-title></h1>
<div class="vd-tagline" data-vd-tagline hidden></div>
<div class="vd-meta" data-vd-meta></div>
<!-- External-source links reuse the artist-hero-badge style. -->
<div class="artist-hero-badges vd-links" data-vd-links></div>

View file

@ -110,9 +110,13 @@
poster.src = '/api/video/poster/show/' + d.id;
}
var tl = q('[data-vd-tagline]');
if (tl) { tl.textContent = d.tagline || ''; tl.hidden = !d.tagline; }
var ownedPct = d.episode_total ? Math.round(d.episode_owned / d.episode_total * 100) : 0;
var meta = [];
meta.push('<span class="vd-match">' + ownedPct + '% in library</span>');
if (d.rating) meta.push('<span class="vd-score">★ ' + (Math.round(d.rating * 10) / 10) + '</span>');
if (d.year) meta.push('<span>' + esc(d.year) + '</span>');
if (d.content_rating) meta.push('<span class="vd-meta-rating">' + esc(d.content_rating) + '</span>');
meta.push('<span>' + d.season_count + ' Season' + (d.season_count === 1 ? '' : 's') + '</span>');
@ -133,7 +137,12 @@
if (d.tvdb_id) badges.push(badge(TVDB_LOGO, 'TVDB', 'TVDB', 'https://thetvdb.com/?id=' + d.tvdb_id + '&tab=series'));
l.innerHTML = badges.join('');
}
var g = q('[data-vd-genres]'); if (g) g.innerHTML = '';
var g = q('[data-vd-genres]');
if (g) {
g.innerHTML = (d.genres || []).slice(0, 6).map(function (gn) {
return '<span class="vd-genre">' + esc(gn) + '</span>';
}).join('');
}
}
function renderActions(d) {
@ -225,9 +234,12 @@
var meta = [];
var rt = runtimeLabel(ep.runtime_minutes); if (rt) meta.push(rt);
if (ep.air_date) meta.push(ep.air_date);
var still = ep.has_still
? '<img class="vd-ep-still" src="/api/video/poster/episode/' + ep.id + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">'
: '';
return '<div class="vd-ep ' + owned + '">' +
'<div class="vd-ep-index">' + (ep.episode_number != null ? ep.episode_number : '') + '</div>' +
'<div class="vd-ep-thumb"><span class="vd-ep-thumb-ic">▶</span></div>' +
'<div class="vd-ep-thumb">' + still + '<span class="vd-ep-thumb-ic">▶</span></div>' +
'<div class="vd-ep-info"><div class="vd-ep-top"><span class="vd-ep-title">' +
esc(ep.title || 'Episode ' + ep.episode_number) + '</span>' +
(meta.length ? '<span class="vd-ep-rt">' + esc(meta.join(' · ')) + '</span>' : '') + '</div>' +

View file

@ -485,6 +485,13 @@ body[data-side="video"] .dashboard-header-sweep {
.vd-meta > span + span::before { content: ''; position: absolute; left: -8px; top: 50%; width: 3px; height: 3px;
border-radius: 50%; background: rgba(255,255,255,0.4); transform: translateY(-50%); }
.vd-match { color: #4ade80 !important; font-weight: 800; }
.vd-score { color: #fbbf24 !important; font-weight: 800; }
.vd-tagline { font-size: 16px; font-style: italic; color: rgba(255,255,255,0.7); margin: -8px 0 14px;
text-shadow: 0 2px 12px rgba(0,0,0,0.6); }
.vd-genres { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.vd-genre { padding: 5px 13px; border-radius: 999px; font-size: 12.5px; font-weight: 600;
color: rgba(255,255,255,0.85); background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14);
backdrop-filter: blur(6px); }
.vd-meta-rating { padding: 1px 7px; border: 1px solid rgba(255,255,255,0.35); border-radius: 4px; font-size: 12px; }
.vd-status { color: rgb(var(--vd-accent-rgb)); }
.vd-overview { font-size: 16px; line-height: 1.6; color: rgba(255,255,255,0.86); margin: 0 0 22px; max-width: 640px;
@ -624,8 +631,11 @@ body[data-side="video"] .dashboard-header-sweep {
display: flex; align-items: center; justify-content: center;
box-shadow: 0 4px 14px rgba(0,0,0,0.4);
}
.vd-ep-thumb-ic { font-size: 20px; color: rgba(255,255,255,0.55); opacity: 0; transform: scale(0.7); transition: all 0.25s ease; }
.vd-ep-still { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.vd-ep-thumb-ic { position: relative; z-index: 1; font-size: 20px; color: #fff; opacity: 0; transform: scale(0.7);
transition: all 0.25s ease; text-shadow: 0 2px 10px rgba(0,0,0,0.7); }
.vd-ep:hover .vd-ep-thumb-ic { opacity: 1; transform: scale(1); }
.vd-ep:hover .vd-ep-still { filter: brightness(0.7); transition: filter 0.25s ease; }
.vd-ep-info { min-width: 0; }
.vd-ep-top { display: flex; align-items: baseline; gap: 12px; margin-bottom: 5px; }
.vd-ep-title { font-size: 15.5px; font-weight: 700; color: #fff; }