soulsync/core/video/sources.py
BoulderBadgeDad e1e0e29432 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.
2026-06-14 17:17:20 -07:00

521 lines
22 KiB
Python

"""SoulSync — video media-server adapters (Plex / Jellyfin).
Turn the live, already-connected media clients (owned by the shared
MediaServerEngine) into normalized dicts the scanner understands. We REUSE the
shared connection/auth (don't reinvent it) but keep all video-section logic here
so music code is untouched.
NOTE: these talk to real Plex/Jellyfin servers and can only be fully validated
against a live server. The scanner itself is server-agnostic and unit-tested
with a fake source; bugs found here against a real library are localized to
these adapters.
"""
from __future__ import annotations
import re
from utils.logging_config import get_logger
logger = get_logger("video_sources")
# Library scans are bulk operations — a far longer per-request timeout than the
# shared client's interactive one, so big libraries don't read-timeout mid-scan.
PLEX_SCAN_TIMEOUT = 120
def _to_int(val):
if val is None:
return None
m = re.match(r"\d+", str(val))
return int(m.group()) if m else None
def _parse_plex_guids(obj) -> dict:
"""tmdb/imdb/tvdb ids from a Plex item's guids — Plex already matched them."""
out = {"tmdb_id": None, "imdb_id": None, "tvdb_id": None}
try:
for g in (getattr(obj, "guids", None) or []):
gid = getattr(g, "id", "") or ""
if "://" not in gid:
continue
scheme, value = gid.split("://", 1)
scheme = scheme.lower()
if scheme == "imdb":
out["imdb_id"] = (value.split("?")[0] or None)
elif scheme == "tmdb":
out["tmdb_id"] = _to_int(value)
elif scheme == "tvdb":
out["tvdb_id"] = _to_int(value)
except Exception:
pass
return out
def _parse_jf_providers(item) -> dict:
"""tmdb/imdb/tvdb ids from a Jellyfin item's ProviderIds."""
providers = item.get("ProviderIds") or {}
low = {(k or "").lower(): v for k, v in providers.items()}
return {
"imdb_id": low.get("imdb") or None,
"tmdb_id": _to_int(low.get("tmdb")),
"tvdb_id": _to_int(low.get("tvdb")),
}
def _build_source(movies_lib=None, tv_lib=None):
"""Build a media source for the active server, restricted to the named
Movies/TV libraries when given. Reuses the SHARED connection config — but
Plex gets a dedicated long-timeout connection for the bulk scan."""
try:
from config.settings import config_manager
except Exception:
logger.exception("video sources: config unavailable")
return None
server = config_manager.get_active_media_server()
if server == "plex":
cfg = config_manager.get_plex_config() or {}
base_url, token = cfg.get("base_url"), cfg.get("token")
if not base_url or not token:
return None
try:
from plexapi.server import PlexServer
srv = PlexServer(base_url, token, timeout=PLEX_SCAN_TIMEOUT)
return PlexVideoSource(srv, movies_lib=movies_lib, tv_lib=tv_lib)
except Exception:
logger.exception("video sources: Plex connect failed")
return None
if server == "jellyfin":
try:
from core.media_server.engine import get_media_server_engine
engine = get_media_server_engine()
client = engine.client("jellyfin") if engine else None
if client and client.ensure_connection() and getattr(client, "user_id", None):
return JellyfinVideoSource(client, movies_lib=movies_lib, tv_lib=tv_lib)
except Exception:
logger.exception("video sources: Jellyfin connect failed")
return None
return None
def _load_selection():
"""The user's Movies/TV library choice for the active server (or {})."""
try:
from config.settings import config_manager
from database.video_database import VideoDatabase
server = config_manager.get_active_media_server()
return VideoDatabase().get_library_selection(server)
except Exception:
logger.exception("video sources: could not load library selection")
return {}
def get_active_video_source():
"""Source for SCANNING — restricted to the user-mapped Movies/TV libraries.
Falls back to all libraries when nothing is mapped yet."""
sel = _load_selection() or {}
return _build_source(sel.get("movies") or None, sel.get("tv") or None)
def list_video_libraries():
"""Discover the active server's video libraries for the mapping UI:
{'server', 'movies': [{'title'}], 'tv': [{'title'}]} or None."""
src = _build_source()
if src is None:
return None
out = src.available_libraries()
out["server"] = src.server_name
return out
# ── Plex ──────────────────────────────────────────────────────────────────────
class PlexVideoSource:
server_name = "plex"
def __init__(self, server, movies_lib=None, tv_lib=None):
self._server = server
self._movies_lib = movies_lib
self._tv_lib = tv_lib
def _sections(self, kind: str, name=None):
secs = [s for s in self._server.library.sections() if s.type == kind]
if name:
secs = [s for s in secs if s.title == name]
return secs
def available_libraries(self) -> dict:
return {
"movies": [{"title": s.title} for s in self._sections("movie")],
"tv": [{"title": s.title} for s in self._sections("show")],
}
def counts(self, incremental=False) -> dict:
"""Cheap item totals (no full fetch) for the progress bar."""
m = sum(int(getattr(s, "totalSize", 0) or 0) for s in self._sections("movie", self._movies_lib))
sh = sum(int(getattr(s, "totalSize", 0) or 0) for s in self._sections("show", self._tv_lib))
if incremental:
m, sh = min(m, 100), min(sh, 50)
return {"movies": m, "shows": sh}
def iter_movies(self, incremental=False):
for section in self._sections("movie", self._movies_lib):
items = section.search(sort="addedAt:desc", maxresults=100) if incremental else section.all()
for m in items:
try:
yield self._movie(m)
except Exception:
logger.exception("Plex: skipping movie %s", getattr(m, "title", "?"))
def iter_shows(self, incremental=False):
for section in self._sections("show", self._tv_lib):
items = section.search(sort="addedAt:desc", maxresults=50) if incremental else section.all()
for sh in items:
try:
yield self._show(sh)
except Exception:
logger.exception("Plex: skipping show %s", getattr(sh, "title", "?"))
@staticmethod
def _part_file(obj):
try:
media = obj.media[0]
part = media.parts[0]
return {
"relative_path": part.file,
"size_bytes": getattr(part, "size", None),
"resolution": getattr(media, "videoResolution", None),
"video_codec": getattr(media, "videoCodec", None),
"audio_codec": getattr(media, "audioCodec", None),
"runtime_seconds": int(obj.duration / 1000) if getattr(obj, "duration", None) else None,
}
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 = {
"server_id": str(m.ratingKey),
"title": m.title,
"year": getattr(m, "year", None),
"overview": getattr(m, "summary", None),
"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),
}
d.update(_parse_plex_guids(m))
return d
def _episode(self, ep, snum, enum) -> dict:
dur = getattr(ep, "duration", None)
aired = getattr(ep, "originallyAvailableAt", None)
return {
"server_id": str(ep.ratingKey),
"season_number": snum,
"episode_number": enum,
"title": ep.title,
"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),
}
def _show(self, sh) -> dict:
# One episodes() call for the whole show (grouped by season) instead of a
# request per season — far fewer round-trips, much less timeout-prone.
seasons_map = {}
try:
for ep in sh.episodes():
enum = getattr(ep, "index", None)
if enum is None:
# No episode number (unmatched/special) — can't key it; skip.
continue
snum = ep.parentIndex if getattr(ep, "parentIndex", None) is not None else 0
seasons_map.setdefault(snum, []).append(self._episode(ep, snum, enum))
except Exception:
logger.exception("Plex: failed reading episodes for %s", getattr(sh, "title", "?"))
# Season metadata (poster/title/overview) — one extra call per show gives
# real per-season art for the detail page.
season_meta = {}
try:
for se in sh.seasons():
sidx = getattr(se, "index", None)
if sidx is None:
continue
season_meta[sidx] = {
"server_id": str(getattr(se, "ratingKey", "")) or None,
"title": getattr(se, "title", None),
"overview": getattr(se, "summary", None),
"poster_url": getattr(se, "thumb", None),
}
except Exception:
logger.exception("Plex: failed reading seasons for %s", getattr(sh, "title", "?"))
seasons = []
for n, eps in sorted(seasons_map.items()):
meta = season_meta.get(n, {})
seasons.append({"server_id": meta.get("server_id"), "season_number": n,
"title": meta.get("title"), "overview": meta.get("overview"),
"poster_url": meta.get("poster_url"), "episodes": eps})
d = {
"server_id": str(sh.ratingKey),
"title": sh.title,
"year": getattr(sh, "year", None),
"overview": getattr(sh, "summary", None),
"poster_url": getattr(sh, "thumb", None),
"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))
return d
# ── Jellyfin ────────────────────────────────────────────────────────────────
_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:
server_name = "jellyfin"
def __init__(self, client, movies_lib=None, tv_lib=None):
self._c = client
self.uid = client.user_id
self._movies_lib = movies_lib
self._tv_lib = tv_lib
def _req(self, path, params=None):
return self._c._make_request(path, params=params)
def _views(self, collection_type: str, name=None):
resp = self._req(f"/Users/{self.uid}/Views") or {}
views = [v for v in resp.get("Items", [])
if (v.get("CollectionType") or "").lower() == collection_type]
if name:
views = [v for v in views if v.get("Name") == name]
return views
def available_libraries(self) -> dict:
return {
"movies": [{"title": v.get("Name")} for v in self._views("movies")],
"tv": [{"title": v.get("Name")} for v in self._views("tvshows")],
}
def counts(self, incremental=False) -> dict:
def total(view, itype):
resp = self._req(f"/Users/{self.uid}/Items", {
"ParentId": view["Id"], "IncludeItemTypes": itype,
"Recursive": "true", "Limit": "0"}) or {}
return int(resp.get("TotalRecordCount", 0) or 0)
m = sum(total(v, "Movie") for v in self._views("movies", self._movies_lib))
sh = sum(total(v, "Series") for v in self._views("tvshows", self._tv_lib))
if incremental:
m, sh = min(m, 100), min(sh, 50)
return {"movies": m, "shows": sh}
def _paged(self, path, params, page_size=500):
"""Yield items across pages so large libraries aren't capped/truncated."""
start = 0
while True:
p = dict(params)
p.update({"StartIndex": str(start), "Limit": str(page_size)})
resp = self._req(path, p) or {}
batch = resp.get("Items", [])
for it in batch:
yield it
start += len(batch)
total = resp.get("TotalRecordCount")
if not batch or len(batch) < page_size or (total is not None and start >= total):
break
@staticmethod
def _ticks_to_seconds(ticks):
return int(ticks / 10_000_000) if ticks else None
@staticmethod
def _file(item):
sources = item.get("MediaSources") or []
if not sources:
path = item.get("Path")
return {"relative_path": path} if path else None
src = sources[0]
streams = src.get("MediaStreams") or []
vid = next((s for s in streams if s.get("Type") == "Video"), {})
aud = next((s for s in streams if s.get("Type") == "Audio"), {})
return {
"relative_path": src.get("Path") or item.get("Path") or "",
"size_bytes": src.get("Size"),
"resolution": (str(vid.get("Height")) + "p") if vid.get("Height") else None,
"video_codec": vid.get("Codec"),
"audio_codec": aud.get("Codec"),
"runtime_seconds": JellyfinVideoSource._ticks_to_seconds(item.get("RunTimeTicks")),
}
def iter_movies(self, incremental=False):
path = f"/Users/{self.uid}/Items"
for view in self._views("movies", self._movies_lib):
params = {"ParentId": view["Id"], "IncludeItemTypes": "Movie",
"Recursive": "true", "Fields": _JF_MOVIE_FIELDS}
if incremental:
params.update({"SortBy": "DateCreated", "SortOrder": "Descending", "Limit": "100"})
items = (self._req(path, params) or {}).get("Items", [])
else:
items = self._paged(path, params)
for it in items:
try:
yield self._movie(it)
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")
d = {
"server_id": str(it["Id"]),
"title": it.get("Name"),
"year": it.get("ProductionYear"),
"overview": it.get("Overview"),
"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),
}
d.update(_parse_jf_providers(it))
return d
def iter_shows(self, incremental=False):
path = f"/Users/{self.uid}/Items"
for view in self._views("tvshows", self._tv_lib):
params = {"ParentId": view["Id"], "IncludeItemTypes": "Series",
"Recursive": "true", "Fields": _JF_SHOW_FIELDS}
if incremental:
params.update({"SortBy": "DateCreated", "SortOrder": "Descending", "Limit": "50"})
items = (self._req(path, params) or {}).get("Items", [])
else:
items = self._paged(path, params)
for it in items:
try:
yield self._show(it)
except Exception:
logger.exception("Jellyfin: skipping show %s", it.get("Name", "?"))
def _show(self, it) -> dict:
series_id = str(it["Id"])
seasons = []
try:
eps_resp = self._req(f"/Shows/{series_id}/Episodes", {
"UserId": self.uid, "Fields": _JF_EP_FIELDS}) or {}
by_season: dict[int, list] = {}
for ep in eps_resp.get("Items", []):
enum = ep.get("IndexNumber")
if enum is None:
continue # unnumbered/special — can't key it
snum = ep.get("ParentIndexNumber") or 0
aired = ep.get("PremiereDate")
ticks = ep.get("RunTimeTicks")
by_season.setdefault(snum, []).append({
"server_id": str(ep["Id"]),
"season_number": snum,
"episode_number": enum,
"title": ep.get("Name"),
"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),
})
# Season metadata (poster/title/overview) — one extra call per show.
season_meta = {}
try:
seas = self._req(f"/Shows/{series_id}/Seasons",
{"UserId": self.uid, "Fields": "Overview"}) or {}
for se in seas.get("Items", []):
sidx = se.get("IndexNumber")
if sidx is None:
continue
season_meta[sidx] = {
"server_id": str(se["Id"]) if se.get("Id") else None,
"title": se.get("Name"),
"overview": se.get("Overview"),
"poster_url": (se.get("ImageTags") or {}).get("Primary"),
}
except Exception:
logger.exception("Jellyfin: failed reading seasons for %s", it.get("Name", "?"))
for snum, eps in sorted(by_season.items()):
meta = season_meta.get(snum, {})
seasons.append({"server_id": meta.get("server_id"), "season_number": snum,
"title": meta.get("title"), "overview": meta.get("overview"),
"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"),
"year": it.get("ProductionYear"),
"overview": it.get("Overview"),
"poster_url": (it.get("ImageTags") or {}).get("Primary"),
"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))
return d