"""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