#!/usr/bin/env python3 """ Sincroniza metadatos Jellyfin (descripción, género, valoración, duración, carátulas) y enlaces M3U → Dispatcharr para películas y series + episodios. Ejecución: **dentro del LXC Dispatcharr** (usa ORM Django; la API pública VOD es solo lectura). Ejemplo: set -a; source /opt/dispatcharr/.env; set +a # Opcional: DISPATCHARR_JELLYFIN_API_KEY en /etc/dispatcharr-jellyfin.env (se carga sola) cd /opt/dispatcharr && .venv/bin/python /opt/dispatcharr/scripts/jellyfin_vod_sync_dispatcharr.py Opciones: --dry-run --movies-only / --series-only --m3u-account "Biblioteca Jellyfin (sandbox)" --movie-category / --series-category nombres de VODCategory existentes (se crean si no existen) """ from __future__ import annotations import argparse import os import sys from pathlib import Path from typing import Any, Optional import httpx # --- Django bootstrap --- _REPO = Path("/opt/dispatcharr") if str(_REPO) not in sys.path: sys.path.insert(0, str(_REPO)) os.chdir(_REPO) os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dispatcharr.settings") def _load_env_file(path: Path) -> None: if not path.is_file(): return for raw in path.read_text().splitlines(): line = raw.strip() if not line or line.startswith("#") or "=" not in line: continue k, v = line.split("=", 1) k, v = k.strip(), v.strip().strip('"').strip("'") os.environ.setdefault(k, v) _load_env_file(Path("/opt/dispatcharr/.env")) _load_env_file(Path("/etc/dispatcharr-jellyfin.env")) import django # noqa: E402 django.setup() from django.db import transaction # noqa: E402 from django.utils import timezone # noqa: E402 from apps.m3u.models import M3UAccount # noqa: E402 from apps.vod.models import ( # noqa: E402 Episode, M3UEpisodeRelation, M3UMovieRelation, M3USeriesRelation, M3UVODCategoryRelation, Movie, Series, VODCategory, VODLogo, ) class JellyfinSyncClient: def __init__(self, base_url: str, api_key: str, *, user_id: Optional[str] = None): self.base_url = base_url.rstrip("/") self.api_key = api_key self.user_id = user_id self._c = httpx.Client(timeout=120.0) def close(self) -> None: self._c.close() def _h(self) -> dict[str, str]: return {"X-Emby-Token": self.api_key} def resolve_user_id(self) -> str: if self.user_id: return self.user_id r = self._c.get(f"{self.base_url}/Users", headers=self._h()) r.raise_for_status() users = r.json() for u in users: if u.get("Policy", {}).get("IsAdministrator") and u.get("Id"): return u["Id"] if users and users[0].get("Id"): return users[0]["Id"] raise RuntimeError("Sin usuario Jellyfin") def get_item(self, uid: str, item_id: str) -> dict[str, Any]: r = self._c.get( f"{self.base_url}/Users/{uid}/Items/{item_id}", headers=self._h(), params={ "Fields": ( "Overview,Genres,ProductionYear,PremiereDate,RunTimeTicks," "CommunityRating,OfficialRating,ParentIndexNumber,IndexNumber," "SeasonId,SeriesId,Type,Name,ImageTags,MediaSources,Container,IsFolder" ) }, ) r.raise_for_status() return r.json() def primary_image_url(self, item_id: str, image_tags: Optional[dict[str, str]]) -> Optional[str]: from urllib.parse import quote tags = image_tags or {} tag = tags.get("Primary") q = "maxHeight=600&quality=90" if tag: q += f"&tag={tag}" key_q = quote(self.api_key, safe="") return f"{self.base_url}/Items/{item_id}/Images/Primary?{q}&api_key={key_q}" def iter_movies(self, uid: str, *, page_size: int = 100): start = 0 fields = ( "Overview,Genres,ProductionYear,PremiereDate,RunTimeTicks," "CommunityRating,OfficialRating,Container,ImageTags,Name" ) while True: r = self._c.get( f"{self.base_url}/Users/{uid}/Items", headers=self._h(), params={ "Recursive": "true", "IncludeItemTypes": "Movie", "Limit": page_size, "StartIndex": start, "Fields": fields, }, ) r.raise_for_status() data = r.json() items = data.get("Items") or [] yield from items total = data.get("TotalRecordCount") or 0 start += len(items) if start >= total or not items: break def iter_series(self, uid: str, *, page_size: int = 50): start = 0 fields = ( "Overview,Genres,ProductionYear,PremiereDate,RunTimeTicks," "CommunityRating,OfficialRating,ImageTags,Name,ChildCount" ) while True: r = self._c.get( f"{self.base_url}/Users/{uid}/Items", headers=self._h(), params={ "Recursive": "true", "IncludeItemTypes": "Series", "Limit": page_size, "StartIndex": start, "Fields": fields, }, ) r.raise_for_status() data = r.json() items = data.get("Items") or [] yield from items total = data.get("TotalRecordCount") or 0 start += len(items) if start >= total or not items: break def iter_episodes(self, uid: str, series_id: str, *, page_size: int = 100): start = 0 fields = ( "Overview,PremiereDate,RunTimeTicks,CommunityRating,OfficialRating," "Container,ImageTags,Name,ParentIndexNumber,IndexNumber,SeasonId,SeriesId," "Type,IsFolder" ) while True: r = self._c.get( f"{self.base_url}/Users/{uid}/Items", headers=self._h(), params={ "ParentId": series_id, "IncludeItemTypes": "Episode", "Recursive": "true", "Limit": page_size, "StartIndex": start, "Fields": fields, }, ) r.raise_for_status() data = r.json() items = data.get("Items") or [] yield from items total = data.get("TotalRecordCount") or 0 start += len(items) if start >= total or not items: break def ticks_to_secs(ticks: Any) -> Optional[int]: if ticks is None: return None try: return int(ticks) // 10_000_000 except (TypeError, ValueError): return None def genres_str(genres: Any) -> Optional[str]: if not genres: return None parts: list[str] = [] if isinstance(genres, list): for g in genres: if isinstance(g, dict): n = g.get("Name") or g.get("Id") if n: parts.append(str(n)) else: parts.append(str(g)) else: parts.append(str(genres)) out = ", ".join(parts) return out[:255] if out else None def rating_str(item: dict[str, Any]) -> Optional[str]: cr = item.get("CommunityRating") if cr is not None: try: return f"{float(cr):.1f}"[:10] except (TypeError, ValueError): pass off = item.get("OfficialRating") if off: return str(off)[:10] return None def year_from_item(item: dict[str, Any]) -> Optional[int]: y = item.get("ProductionYear") if y is not None: try: return int(y) except (TypeError, ValueError): pass pd = item.get("PremiereDate") if pd and isinstance(pd, str) and len(pd) >= 4: try: return int(pd[:4]) except ValueError: pass return None def merge_cp(existing: Optional[dict], jellyfin_id: str) -> dict: base = dict(existing or {}) base["jellyfin_id"] = jellyfin_id base["jellyfin_sync_at"] = timezone.now().isoformat() return base def upsert_logo(url: Optional[str], *, short_id: str, dry_run: bool) -> Optional[VODLogo]: if not url: return None name = f"JF-{short_id}"[:255] if dry_run: print(f" [dry-run] VODLogo name={name!r} url={url[:80]}...") return None logo, _ = VODLogo.objects.get_or_create(url=url, defaults={"name": name}) if logo.name != name: logo.name = name logo.save(update_fields=["name"]) return logo def ensure_category_relation(acc: M3UAccount, cat: VODCategory, *, dry_run: bool) -> None: if dry_run: return rel, _ = M3UVODCategoryRelation.objects.get_or_create(m3u_account=acc, category=cat) if not rel.enabled: rel.enabled = True rel.save(update_fields=["enabled"]) def sync_movie( detail: dict[str, Any], *, acc: M3UAccount, cat: VODCategory, jf: JellyfinSyncClient, dry_run: bool, ) -> str: jid = detail["Id"] short = jid[:12] if not detail.get("Overview"): # lista incompleta uid = jf.resolve_user_id() detail = jf.get_item(uid, jid) logo_url = jf.primary_image_url(jid, detail.get("ImageTags")) logo = upsert_logo(logo_url, short_id=short, dry_run=dry_run) desc = (detail.get("Overview") or "").strip() or None year = year_from_item(detail) genre = genres_str(detail.get("Genres")) rating = rating_str(detail) duration_secs = ticks_to_secs(detail.get("RunTimeTicks")) container = (detail.get("Container") or "mp4").lower().lstrip(".")[:10] title = (detail.get("Name") or "Sin título").strip()[:255] movie = Movie.objects.filter(custom_properties__jellyfin_id=jid).first() if not movie: mq = Movie.objects.filter(name=title) if year is not None: movie = mq.filter(year=year).first() else: movie = mq.first() action = "skip" if dry_run: print(f"MOVIE {jid} {title!r} year={year} logo={'yes' if logo_url else 'no'}") return "dry" if movie: movie.name = title movie.description = desc movie.year = year movie.genre = genre movie.rating = rating movie.duration_secs = duration_secs movie.custom_properties = merge_cp(movie.custom_properties, jid) if logo: movie.logo = logo movie.save() action = "updated" else: movie = Movie.objects.create( name=title, description=desc, year=year, genre=genre, rating=rating, duration_secs=duration_secs, logo=logo, custom_properties=merge_cp(None, jid), ) action = "created" M3UMovieRelation.objects.update_or_create( m3u_account=acc, stream_id=jid, defaults={ "movie": movie, "category": cat, "container_extension": container, "custom_properties": {"source": "jellyfin_sync"}, "last_seen": timezone.now(), }, ) return action def jellyfin_item_is_episode_row(ed: dict[str, Any]) -> bool: """Descarta carpetas de temporada (Type Season / IsFolder) que Jellyfin puede mezclar en listados.""" if ed.get("IsFolder"): return False return ed.get("Type") == "Episode" def sync_series_tree( row: dict[str, Any], *, acc: M3UAccount, cat: VODCategory, jf: JellyfinSyncClient, uid: str, dry_run: bool, ) -> tuple[int, int]: """Devuelve (episodios_procesados, episodios_nuevos_o_actualizados).""" jid = row["Id"] detail = row if row.get("Overview") is not None else jf.get_item(uid, jid) logo_url = jf.primary_image_url(jid, detail.get("ImageTags")) logo = upsert_logo(logo_url, short_id=jid[:12], dry_run=dry_run) desc = (detail.get("Overview") or "").strip() or None year = year_from_item(detail) genre = genres_str(detail.get("Genres")) rating = rating_str(detail) title = (detail.get("Name") or "Sin título").strip()[:255] if dry_run: print(f"SERIES {jid} {title!r} episodes≈{detail.get('ChildCount')} logo={'yes' if logo_url else 'no'}") ep_count = 0 for ep in jf.iter_episodes(uid, jid): eid = ep["Id"] tl = ep.get("Type") if tl not in (None, "Episode"): continue if tl is None or ep.get("Overview") is None: ed = jf.get_item(uid, eid) else: ed = ep if not jellyfin_item_is_episode_row(ed): continue ep_count += 1 if ep_count <= 3: print(f" ep {eid[:8]} S{ed.get('ParentIndexNumber')}E{ed.get('IndexNumber')} {ed.get('Name')!r}") return ep_count, ep_count series_obj = Series.objects.filter(custom_properties__jellyfin_id=jid).first() if not series_obj: sq = Series.objects.filter(name=title) if year is not None: series_obj = sq.filter(year=year).first() else: series_obj = sq.first() if series_obj: series_obj.name = title series_obj.description = desc series_obj.year = year series_obj.genre = genre series_obj.rating = rating series_obj.custom_properties = merge_cp(series_obj.custom_properties, jid) if logo: series_obj.logo = logo series_obj.save() else: series_obj = Series.objects.create( name=title, description=desc, year=year, genre=genre, rating=rating, logo=logo, custom_properties=merge_cp(None, jid), ) sr, _ = M3USeriesRelation.objects.update_or_create( m3u_account=acc, external_series_id=jid, defaults={ "series": series_obj, "category": cat, "last_seen": timezone.now(), }, ) processed = 0 touched = 0 synced_stream_ids: set[str] = set() for ep in jf.iter_episodes(uid, jid): eid = ep["Id"] tl = ep.get("Type") if tl not in (None, "Episode"): continue # Detalle si falta Type en lista o Overview (Season suele venir sin overview de episodio). if tl is None or ep.get("Overview") is None: ed = jf.get_item(uid, eid) else: ed = ep if not jellyfin_item_is_episode_row(ed): continue synced_stream_ids.add(eid) processed += 1 season = ed.get("ParentIndexNumber") epnum = ed.get("IndexNumber") if season is None: season = 0 if epnum is None: epnum = 0 name = (ed.get("Name") or f"E{eid[:6]}").strip()[:255] ep_desc = (ed.get("Overview") or "").strip() or None dur = ticks_to_secs(ed.get("RunTimeTicks")) rating_ep = rating_str(ed) air_raw = ed.get("PremiereDate") air_date = None if air_raw and isinstance(air_raw, str) and len(air_raw) >= 10: air_date = air_raw[:10] container = (ed.get("Container") or "mp4").lower().lstrip(".")[:10] episode_obj, ep_created = Episode.objects.update_or_create( series=series_obj, season_number=season, episode_number=epnum, defaults={ "name": name, "description": ep_desc, "duration_secs": dur, "rating": rating_ep, "air_date": air_date, "custom_properties": merge_cp(None, eid), }, ) _, rel_created = M3UEpisodeRelation.objects.update_or_create( m3u_account=acc, stream_id=eid, defaults={ "episode": episode_obj, "series_relation": sr, "container_extension": container, "custom_properties": {"source": "jellyfin_sync"}, "last_seen": timezone.now(), }, ) if ep_created or rel_created: touched += 1 # Quitar relaciones que ya no son episodios reales en Jellyfin (p. ej. nodos Season sincronizados antes). existing_stream_ids = set( M3UEpisodeRelation.objects.filter(m3u_account=acc, series_relation=sr).values_list("stream_id", flat=True) ) for sid in existing_stream_ids - synced_stream_ids: try: meta = jf.get_item(uid, sid) except httpx.HTTPStatusError as ex: if ex.response.status_code != 404: raise meta = None delete_fake = meta is None or meta.get("Type") != "Episode" or meta.get("IsFolder") if not delete_fake: continue for rel in M3UEpisodeRelation.objects.filter(m3u_account=acc, series_relation=sr, stream_id=sid): ep_row = rel.episode rel.delete() if not ep_row.m3u_relations.exists(): ep_row.delete() # Evita que xc_get_series_info llame a refresh_series_episodes (API Xtream): borraría # las relaciones Jellyfin como «stale». Ver apps/output/views.py xc_get_series_info. cp_sr = dict(sr.custom_properties or {}) cp_sr["source"] = "jellyfin_sync" cp_sr["episodes_fetched"] = True cp_sr["detailed_fetched"] = True sr.custom_properties = cp_sr sr.last_episode_refresh = timezone.now() sr.save(update_fields=["custom_properties", "last_episode_refresh"]) return processed, touched def main() -> int: p = argparse.ArgumentParser(description="Sync Jellyfin → Dispatcharr VOD (ORM)") p.add_argument("--dry-run", action="store_true") p.add_argument("--movies-only", action="store_true") p.add_argument("--series-only", action="store_true") p.add_argument("--m3u-account", default=os.environ.get("JELLYFIN_SYNC_M3U_ACCOUNT", "Biblioteca Jellyfin (sandbox)")) p.add_argument("--movie-category", default=os.environ.get("JELLYFIN_SYNC_MOVIE_CAT", "Biblioteca propia (sandbox)")) p.add_argument("--series-category", default=os.environ.get("JELLYFIN_SYNC_SERIES_CAT", "Biblioteca propia series (sandbox)")) p.add_argument("--max-movies", type=int, default=0, help="0 = sin límite") p.add_argument("--max-series", type=int, default=0, help="0 = sin límite") args = p.parse_args() acc = M3UAccount.objects.filter(name=args.m3u_account).first() if not acc: print( f"No existe cuenta M3U {args.m3u_account!r}; sincronización omitida " f"(crea la cuenta o define JELLYFIN_SYNC_M3U_ACCOUNT / --m3u-account).", file=sys.stderr, ) return 0 base = os.environ.get("JELLYFIN_SYNC_URL") or os.environ.get("JELLYFIN_BASE_URL") if not base and acc.server_url: base = str(acc.server_url).rstrip("/") key = os.environ.get("JELLYFIN_SYNC_API_KEY") or os.environ.get("DISPATCHARR_JELLYFIN_API_KEY") or os.environ.get( "JELLYFIN_API_KEY" ) if not base or not key: print("Falta URL Jellyfin (JELLYFIN_SYNC_URL / server_url de la cuenta M3U) y/o API key.", file=sys.stderr) print("Clave: JELLYFIN_SYNC_API_KEY, DISPATCHARR_JELLYFIN_API_KEY (p. ej. /etc/dispatcharr-jellyfin.env).", file=sys.stderr) return 1 cat_movie, _ = VODCategory.objects.get_or_create( name=args.movie_category, defaults={"category_type": "movie"}, ) cat_series, _ = VODCategory.objects.get_or_create( name=args.series_category, defaults={"category_type": "series"}, ) jf = JellyfinSyncClient(base, key) uid = jf.resolve_user_id() print(f"Jellyfin user={uid[:8]}… cuenta_m3u={acc.name!r}") try: do_movies = not args.series_only do_series = not args.movies_only if do_movies: ensure_category_relation(acc, cat_movie, dry_run=args.dry_run) stats = {"movie_created": 0, "movie_updated": 0} n = 0 for row in jf.iter_movies(uid): det = row if row.get("Overview") is not None else jf.get_item(uid, row["Id"]) act = sync_movie(det, acc=acc, cat=cat_movie, jf=jf, dry_run=args.dry_run) if act == "created": stats["movie_created"] += 1 elif act == "updated": stats["movie_updated"] += 1 n += 1 if args.max_movies and n >= args.max_movies: break print("Películas:", stats) if do_series: ensure_category_relation(acc, cat_series, dry_run=args.dry_run) total_eps = 0 total_touch = 0 ns = 0 for row in jf.iter_series(uid): with transaction.atomic(): pe, pt = sync_series_tree(row, acc=acc, cat=cat_series, jf=jf, uid=uid, dry_run=args.dry_run) total_eps += pe total_touch += pt ns += 1 if args.max_series and ns >= args.max_series: break print(f"Series episodios procesados={total_eps} relaciones tocadas≈{total_touch}") finally: jf.close() return 0 if __name__ == "__main__": raise SystemExit(main())