Proyecto LCX Dispatcharr multicuenta
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
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
This commit is contained in:
@@ -0,0 +1,644 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user