Files
LXC-Dispatcharr-modificado-…/scripts/jellyfin_vod_sync_dispatcharr.py
T
root f56b088643
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
Proyecto LCX Dispatcharr multicuenta
2026-05-09 21:24:50 +02:00

645 lines
21 KiB
Python

#!/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())