soulsync/core/video/sources.py
BoulderBadgeDad 13e03a624c video scan: capture the provider IDs the server already has (tmdb/imdb/tvdb)
The servers already matched everything to their agents — we were dropping the
IDs. Now we store them:
- Plex: parse item.guids (imdb://, tmdb://, tvdb://); Jellyfin: parse
  item.ProviderIds (added ProviderIds to the requested Fields).
- Stored on movies (tmdb_id, imdb_id), shows (tvdb_id, tmdb_id, imdb_id), and
  episodes (tvdb_id) via the upserts.
- Dropped the over-strict UNIQUE on movies.tmdb_id / shows.tvdb_id (same title
  can legitimately live in two libraries; we dedupe on server_id). Scanner now
  wraps each upsert in try/except so one bad item can't abort a scan.
Tests: guid/ProviderIds parsing + IDs persisted. 38 video-DB/scanner tests green.
2026-06-14 11:04:54 -07:00

434 lines
18 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
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),
"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,
"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", "?"))
seasons = [{"server_id": None, "season_number": n, "title": None,
"overview": None, "poster_url": None, "episodes": eps}
for n, eps in sorted(seasons_map.items())]
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),
"seasons": seasons,
}
d.update(_parse_plex_guids(sh))
return d
# ── Jellyfin ────────────────────────────────────────────────────────────────
_JF_MOVIE_FIELDS = "Overview,Path,MediaSources,ProductionYear,OfficialRating,RunTimeTicks,Studios,ProviderIds"
_JF_EP_FIELDS = "Overview,Path,MediaSources,PremiereDate,RunTimeTicks,IndexNumber,ParentIndexNumber,ProviderIds"
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", "?"))
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,
"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": "Overview,ProductionYear,OfficialRating,ProviderIds"}
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,
"tvdb_id": _parse_jf_providers(ep).get("tvdb_id"),
"file": self._file(ep),
})
for snum, eps in sorted(by_season.items()):
seasons.append({"server_id": None, "season_number": snum,
"title": None, "overview": None,
"poster_url": None, "episodes": eps})
except Exception:
logger.exception("Jellyfin: failed reading episodes for %s", it.get("Name", "?"))
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"),
"seasons": seasons,
}
d.update(_parse_jf_providers(it))
return d