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:
parent
c450fa1f9a
commit
e1e0e29432
7 changed files with 210 additions and 22 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>' +
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue