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
645 lines
21 KiB
Python
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())
|