"""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 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 get_active_video_source(): """Return a media source for the active server, or None when the active server has no video support (Navidrome/standalone) or isn't configured. Reuses the SHARED connection config (config_manager) — we don't reinvent auth — but Plex gets a dedicated, long-timeout connection for the bulk scan so it never inherits the shared client's short interactive timeout.""" 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) 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) except Exception: logger.exception("video sources: Jellyfin connect failed") return None return None # ── Plex ────────────────────────────────────────────────────────────────────── class PlexVideoSource: server_name = "plex" def __init__(self, server): self._server = server def _sections(self, kind: str): return [s for s in self._server.library.sections() if s.type == kind] def iter_movies(self, incremental=False): for section in self._sections("movie"): 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"): 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) return { "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), } def _episode(self, ep, snum) -> dict: dur = getattr(ep, "duration", None) aired = getattr(ep, "originallyAvailableAt", None) return { "server_id": str(ep.ratingKey), "season_number": snum, "episode_number": getattr(ep, "index", 0), "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, "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(): snum = ep.parentIndex if getattr(ep, "parentIndex", None) is not None else 0 seasons_map.setdefault(snum, []).append(self._episode(ep, snum)) 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())] return { "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, } # ── Jellyfin ──────────────────────────────────────────────────────────────── _JF_MOVIE_FIELDS = "Overview,Path,MediaSources,ProductionYear,OfficialRating,RunTimeTicks,Studios" _JF_EP_FIELDS = "Overview,Path,MediaSources,PremiereDate,RunTimeTicks,IndexNumber,ParentIndexNumber" class JellyfinVideoSource: server_name = "jellyfin" def __init__(self, client): self._c = client self.uid = client.user_id def _req(self, path, params=None): return self._c._make_request(path, params=params) def _views(self, collection_type: str): resp = self._req(f"/Users/{self.uid}/Views") or {} return [v for v in resp.get("Items", []) if (v.get("CollectionType") or "").lower() == collection_type] @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): for view in self._views("movies"): params = {"ParentId": view["Id"], "IncludeItemTypes": "Movie", "Recursive": "true", "Fields": _JF_MOVIE_FIELDS} if incremental: params.update({"SortBy": "DateCreated", "SortOrder": "Descending", "Limit": "100"}) resp = self._req(f"/Users/{self.uid}/Items", params) or {} for it in resp.get("Items", []): yield self._movie(it) def _movie(self, it) -> dict: studios = it.get("Studios") or [] ticks = it.get("RunTimeTicks") return { "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), } def iter_shows(self, incremental=False): for view in self._views("tvshows"): params = {"ParentId": view["Id"], "IncludeItemTypes": "Series", "Recursive": "true", "Fields": "Overview,ProductionYear,OfficialRating"} if incremental: params.update({"SortBy": "DateCreated", "SortOrder": "Descending", "Limit": "50"}) resp = self._req(f"/Users/{self.uid}/Items", params) or {} for it in resp.get("Items", []): yield self._show(it) 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", []): 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": ep.get("IndexNumber") or 0, "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, "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", "?")) return { "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, }