From e1e0e294327b7d535c259c8bbd1b19ab2cdccdfa Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 14 Jun 2026 17:17:20 -0700 Subject: [PATCH] video 'capture everything' (phase 1): stills, genres, ratings, tagline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/. - 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. --- core/video/sources.py | 56 ++++++++++++++++++- database/video_database.py | 89 ++++++++++++++++++++++++------ database/video_schema.sql | 27 +++++++++ tests/test_video_database.py | 31 +++++++++++ webui/index.html | 1 + webui/static/video/video-detail.js | 16 +++++- webui/static/video/video-side.css | 12 +++- 7 files changed, 210 insertions(+), 22 deletions(-) diff --git a/core/video/sources.py b/core/video/sources.py index 8c9b9939..214cd806 100644 --- a/core/video/sources.py +++ b/core/video/sources.py @@ -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)) diff --git a/database/video_database.py b/database/video_database.py index 6743e779..483f6bf8 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -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"]), diff --git a/database/video_schema.sql b/database/video_schema.sql index df38c446..03ed267a 100644 --- a/database/video_schema.sql +++ b/database/video_schema.sql @@ -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, diff --git a/tests/test_video_database.py b/tests/test_video_database.py index 3a9138f5..96b30297 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -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", diff --git a/webui/index.html b/webui/index.html index bd798b4c..9a184a91 100644 --- a/webui/index.html +++ b/webui/index.html @@ -829,6 +829,7 @@

+
diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index abaf6af0..8a6bc4fb 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -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('' + ownedPct + '% in library'); + if (d.rating) meta.push('★ ' + (Math.round(d.rating * 10) / 10) + ''); if (d.year) meta.push('' + esc(d.year) + ''); if (d.content_rating) meta.push('' + esc(d.content_rating) + ''); meta.push('' + d.season_count + ' Season' + (d.season_count === 1 ? '' : 's') + ''); @@ -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 '' + esc(gn) + ''; + }).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 + ? '' + : ''; return '
' + '
' + (ep.episode_number != null ? ep.episode_number : '') + '
' + - '
' + + '
' + still + '
' + '
' + esc(ep.title || 'Episode ' + ep.episode_number) + '' + (meta.length ? '' + esc(meta.join(' · ')) + '' : '') + '
' + diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 68c13b18..3b5c0eb3 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -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; }