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

This commit is contained in:
root
2026-05-09 21:24:50 +02:00
commit f56b088643
721 changed files with 177870 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python
"""
Bumps the version number according to semantic versioning.
Usage: python bump_version.py [major|minor|patch]
"""
import re
import sys
from pathlib import Path
def bump_version(version_type='patch'):
version_file = Path(__file__).parent.parent / "version.py"
content = version_file.read_text()
# Extract version
version_match = re.search(r"__version__ = '(\d+)\.(\d+)\.(\d+)'", content)
if not version_match:
print("Could not find version number in version.py")
return
major, minor, patch = map(int, version_match.groups())
# Update version based on type
if version_type == 'major':
major += 1
minor = 0
patch = 0
elif version_type == 'minor':
minor += 1
patch = 0
else: # patch
patch += 1
new_version = f"{major}.{minor}.{patch}"
# Update version in file
new_content = re.sub(
r"__version__ = '\d+\.\d+\.\d+'",
f"__version__ = '{new_version}'",
content
)
# Reset build number
new_content = re.sub(
r"__build__ = '\d+'",
"__build__ = '0'",
new_content
)
version_file.write_text(new_content)
print(f"Version bumped to {new_version}")
return new_version
if __name__ == "__main__":
version_type = 'patch'
if len(sys.argv) > 1:
version_type = sys.argv[1].lower()
if version_type not in ('major', 'minor', 'patch'):
print("Invalid version type. Use major, minor, or patch.")
sys.exit(1)
bump_version(version_type)
+103
View File
@@ -0,0 +1,103 @@
"""
Debug wrapper for the WSGI application.
This module initializes debugpy and then imports the actual application.
"""
import sys
import os
import time
import logging
import inspect
# Configure logging to output to both console and file
os.makedirs('/app/debugpy_logs', exist_ok=True)
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s [%(levelname)s] %(name)s - %(message)s',
handlers=[
logging.FileHandler('/app/debugpy_logs/debug_wrapper.log'),
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger('debug_wrapper')
# Log system info
logger.info(f"Python version: {sys.version}")
logger.info(f"Current directory: {os.getcwd()}")
logger.info(f"Files in current directory: {os.listdir()}")
logger.info(f"Python path: {sys.path}")
# Default timeout in seconds
DEBUG_TIMEOUT = int(os.environ.get('DEBUG_TIMEOUT', '60')) # Increased default timeout
# Whether to wait for debugger to attach
WAIT_FOR_DEBUGGER = os.environ.get('WAIT_FOR_DEBUGGER', 'false').lower() == 'true'
logger.info(f"DEBUG_TIMEOUT: {DEBUG_TIMEOUT}")
logger.info(f"WAIT_FOR_DEBUGGER: {WAIT_FOR_DEBUGGER}")
try:
import debugpy
from debugpy import configure
logger.info("Successfully imported debugpy")
# Critical: Configure debugpy to use regular Python for the adapter, not uwsgi
python_path = '/dispatcharrpy/bin/python'
if os.path.exists(python_path):
logger.info(f"Setting debugpy adapter to use Python interpreter: {python_path}")
debugpy.configure(python=python_path)
else:
logger.warning(f"Python path {python_path} not found. Using system default.")
# Don't wait for connection, just set up the debugging session
logger.info("Initializing debugpy on 0.0.0.0:5678...")
try:
# Configure debugpy to listen without socket timeout initially
debugpy.listen(("0.0.0.0", 5678))
logger.info("debugpy now listening on 0.0.0.0:5678")
if WAIT_FOR_DEBUGGER:
logger.info(f"Waiting for debugger to attach (timeout: {DEBUG_TIMEOUT}s)...")
start_time = time.time()
# Use a more reliable approach for checking connection
while not debugpy.is_client_connected() and (time.time() - start_time < DEBUG_TIMEOUT):
time.sleep(1)
if (time.time() - start_time) % 5 == 0: # Log only every 5 seconds to reduce spam
logger.info(f"Still waiting for debugger connection... ({int(time.time() - start_time)}s)")
if debugpy.is_client_connected():
logger.info("Debugger attached successfully!")
else:
logger.warning(f"Debugger not attached after {DEBUG_TIMEOUT}s, continuing anyway...")
except RuntimeError as re:
if "already in use" in str(re):
logger.warning(f"Port 5678 already in use. This might indicate another debugging session is active.")
logger.info("Continuing without debugging...")
elif "timed out waiting for adapter to connect" in str(re):
logger.warning(f"debugpy.listen timed out after {DEBUG_TIMEOUT}s. This is normal in some environments.")
logger.info("Continuing without debugging...")
else:
logger.error(f"RuntimeError with debugpy.listen: {re}", exc_info=True)
logger.info("Continuing without debugging...")
except Exception as e:
logger.error(f"Error with debugpy.listen: {e}", exc_info=True)
logger.info("Continuing without debugging...")
except ImportError:
logger.error("debugpy not installed, continuing without debugging support")
except Exception as e:
logger.error(f"Failed to initialize debugpy: {e}", exc_info=True)
logger.info("Continuing without debugging support")
# Now import the actual WSGI application
logger.info("Loading WSGI application...")
try:
from dispatcharr.wsgi import application
logger.info("WSGI application loaded successfully")
# Log the application details
logger.info(f"Application type: {type(application)}")
logger.info(f"Application callable: {inspect.isfunction(application) or inspect.ismethod(application)}")
except Exception as e:
logger.error(f"Error loading WSGI application: {e}", exc_info=True)
raise
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python
"""
Increments the build number in version.py
"""
import re
import os
from pathlib import Path
def increment_build():
version_file = Path(__file__).parent.parent / "version.py"
content = version_file.read_text()
# Extract build number
build_match = re.search(r"__build__ = '(\d+)'", content)
if not build_match:
print("Could not find build number in version.py")
return
build = int(build_match.group(1))
new_build = str(build + 1)
# Update build number
new_content = re.sub(
r"__build__ = '\d+'",
f"__build__ = '{new_build}'",
content
)
version_file.write_text(new_content)
print(f"Build number incremented to {new_build}")
if __name__ == "__main__":
increment_build()
+644
View File
@@ -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())
+93
View File
@@ -0,0 +1,93 @@
"""
Enlaza streams de la cuenta secundaria (Javi) como respaldo en canales que solo tienen la principal (Boss).
Uso (después de refrescar las listas M3U en Dispatcharr):
cd /opt/dispatcharr && set -a && . ./.env && set +a && \\
./.venv/bin/python manage.py shell < scripts/link_backup_streams_boss_javi.py
Empareja por tvg-id; si falta, por nombre normalizado. Idempotente (no duplica enlaces).
IDs de cuenta: revisa en UI M3U Accounts si cambian (por defecto Boss=2, Javi=4).
"""
import re
from collections import defaultdict
from apps.channels.models import Channel, ChannelStream, Stream
BOSS_ACCOUNT_ID = 2
BACKUP_ACCOUNT_ID = 4
def norm_name(s):
if not s:
return ""
return re.sub(r"\s+", " ", str(s).strip().lower())
def run():
by_tvg = {}
by_name = defaultdict(list)
javi_count = 0
for s in Stream.objects.filter(
m3u_account_id=BACKUP_ACCOUNT_ID, is_stale=False
).iterator(chunk_size=500):
javi_count += 1
tid = (s.tvg_id or "").strip()
if tid and tid not in by_tvg:
by_tvg[tid] = s
by_name[norm_name(s.name)].append(s)
linked = 0
skipped_has_backup = 0
skipped_primary_not_boss = 0
missing = 0
for ch in Channel.objects.prefetch_related("channelstream_set__stream").iterator(
chunk_size=400
):
css = sorted(ch.channelstream_set.all(), key=lambda x: x.order)
if not css:
continue
if any(cs.stream.m3u_account_id == BACKUP_ACCOUNT_ID for cs in css):
skipped_has_backup += 1
continue
boss = css[0].stream
if boss.m3u_account_id != BOSS_ACCOUNT_ID:
skipped_primary_not_boss += 1
continue
match = None
tid = (boss.tvg_id or "").strip()
if tid and tid in by_tvg:
match = by_tvg[tid]
if match is None:
cands = by_name.get(norm_name(boss.name))
if cands:
match = cands[0]
if match:
max_order = max(cs.order for cs in css)
_, created = ChannelStream.objects.get_or_create(
channel=ch,
stream=match,
defaults={"order": max_order + 1},
)
if created:
linked += 1
else:
missing += 1
print(
"[link_backup_streams] Javi streams indexados:",
javi_count,
"| nuevos enlaces:",
linked,
"| ya tenían backup:",
skipped_has_backup,
"| primer stream no es Boss:",
skipped_primary_not_boss,
"| sin pareja:",
missing,
)
run()
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# Ejecutar desde cron en el LXC Dispatcharr (p. ej. cada 5 minutos).
# flock evita solapar dos sync si uno tarda más que el intervalo del cron.
set -euo pipefail
cd /opt/dispatcharr
set -a
[ -f .env ] && . ./.env
[ -f /etc/dispatcharr-jellyfin.env ] && . /etc/dispatcharr-jellyfin.env
[ -f /etc/jellyfin-sync-opts.env ] && . /etc/jellyfin-sync-opts.env
set +a
LOG=/var/log/jellyfin-vod-sync.log
flock -n /var/lock/jellyfin-vod-sync.lock \
.venv/bin/python scripts/jellyfin_vod_sync_dispatcharr.py >> "$LOG" 2>&1
+36
View File
@@ -0,0 +1,36 @@
"""
Standalone debug entry point for the Django application.
This provides a cleaner way to debug without uWSGI complications.
Run this directly with Python to debug:
python standalone_debug.py
"""
import os
import sys
import debugpy
import logging
# Configure basic logging
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s [%(levelname)s] %(name)s - %(message)s'
)
logger = logging.getLogger('standalone_debug')
# Setup Django environment
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dispatcharr.settings')
# Setup debugpy and wait for connection
logger.info("Setting up debugpy...")
debugpy.listen(("0.0.0.0", 5678))
logger.info("Waiting for debugger to attach... Connect to 0.0.0.0:5678")
debugpy.wait_for_client()
logger.info("Debugger attached!")
# Import Django and run the development server
logger.info("Starting Django development server...")
import django
django.setup()
from django.core.management import execute_from_command_line
execute_from_command_line(['manage.py', 'runserver', '0.0.0.0:8000'])
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python
"""
Updates the CHANGELOG.md file for a new release.
Renames [Unreleased] section to the new version with current date.
Usage: python update_changelog.py <version>
"""
import re
import sys
from datetime import datetime
from pathlib import Path
def update_changelog(version):
"""Update CHANGELOG.md with new version and date."""
changelog_file = Path(__file__).parent.parent / "CHANGELOG.md"
if not changelog_file.exists():
print("CHANGELOG.md not found")
sys.exit(1)
content = changelog_file.read_text(encoding='utf-8')
# Check if there's an Unreleased section
if '## [Unreleased]' not in content:
print("No [Unreleased] section found in CHANGELOG.md")
sys.exit(1)
# Get current date in YYYY-MM-DD format
today = datetime.now().strftime('%Y-%m-%d')
# Replace [Unreleased] with new version and date, and add new [Unreleased] section
# This pattern preserves everything after [Unreleased] until the next version or end
new_content = re.sub(
r'## \[Unreleased\]',
f'## [Unreleased]\n\n## [{version}] - {today}',
content,
count=1
)
if new_content == content:
print("Failed to update CHANGELOG.md")
sys.exit(1)
changelog_file.write_text(new_content, encoding='utf-8')
print(f"CHANGELOG.md updated for version {version} ({today})")
return True
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python update_changelog.py <version>")
print("Example: python update_changelog.py 0.13.0")
sys.exit(1)
version = sys.argv[1]
# Remove 'v' prefix if present
version = version.lstrip('v')
update_changelog(version)
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python
"""
Helper script to wait for Redis to be available before starting the application.
"""
import redis
import time
import os
import sys
import logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Key prefixes used by Celery's broker (Kombu) and result backend.
# These must be preserved in modular mode where Celery runs independently.
_CELERY_KEY_PREFIXES = ('celery', '_kombu', 'unacked')
def _flush_non_celery_keys(client):
"""Delete all Redis keys except those belonging to Celery."""
cursor = '0'
deleted = 0
while True:
cursor, keys = client.scan(cursor=cursor, count=500)
to_delete = [
k for k in keys
if not k.decode('utf-8', errors='replace').startswith(_CELERY_KEY_PREFIXES)
]
if to_delete:
deleted += client.delete(*to_delete)
if cursor == 0:
break
logger.info(f"Modular mode: selectively cleared {deleted} non-Celery Redis key(s)")
def _build_ssl_kwargs():
"""Build Redis SSL kwargs from environment variables.
Validates that any configured certificate file paths exist on disk
and logs a summary of the active TLS configuration.
"""
ssl_kwargs = {}
if os.environ.get("REDIS_SSL", "false").lower() == "true":
import ssl as _ssl
ssl_kwargs["ssl"] = True
ssl_kwargs["ssl_cert_reqs"] = (
_ssl.CERT_REQUIRED if os.environ.get("REDIS_SSL_VERIFY", "true").lower() == "true"
else _ssl.CERT_NONE
)
cert_paths = {
"REDIS_SSL_CA_CERT": os.environ.get("REDIS_SSL_CA_CERT", ""),
"REDIS_SSL_CERT": os.environ.get("REDIS_SSL_CERT", ""),
"REDIS_SSL_KEY": os.environ.get("REDIS_SSL_KEY", ""),
}
for env_var, path in cert_paths.items():
if path and not os.path.isfile(path):
logger.error(
f"Redis TLS: {env_var}={path!r} — file not found. "
f"Check that the certificate file exists and the volume is mounted correctly."
)
sys.exit(1)
if cert_paths["REDIS_SSL_CA_CERT"]:
ssl_kwargs["ssl_ca_certs"] = cert_paths["REDIS_SSL_CA_CERT"]
if cert_paths["REDIS_SSL_CERT"]:
ssl_kwargs["ssl_certfile"] = cert_paths["REDIS_SSL_CERT"]
if cert_paths["REDIS_SSL_KEY"]:
ssl_kwargs["ssl_keyfile"] = cert_paths["REDIS_SSL_KEY"]
logger.info("Redis TLS: enabled")
return ssl_kwargs
def wait_for_redis(host='localhost', port=6379, db=0, password='', username='', max_retries=30, retry_interval=2):
"""Wait for Redis to become available"""
redis_client = None
retry_count = 0
ssl_kwargs = _build_ssl_kwargs()
logger.info(f"Waiting for Redis at {host}:{port}/{db}...")
while retry_count < max_retries:
try:
redis_client = redis.Redis(
host=host,
port=port,
db=db,
password=password if password else None,
username=username if username else None,
socket_timeout=2,
socket_connect_timeout=2,
**ssl_kwargs
)
redis_client.ping()
# Clear stale state on startup. In AIO mode, every service restarts
# together so a full flush is safe. In modular mode, Celery has its
# own lifecycle — preserve its broker/result keys and only wipe
# application state (stream locks, proxy metadata, etc.).
if os.environ.get('DISPATCHARR_ENV') == 'modular':
_flush_non_celery_keys(redis_client)
else:
redis_client.flushdb()
logger.info(f"Flushed Redis database")
logger.info(f"✅ Redis at {host}:{port}/{db} is now available!")
return True
except (redis.exceptions.ConnectionError, redis.exceptions.TimeoutError) as e:
retry_count += 1
_tls_hint = " (TLS is enabled — verify certificate paths and that Redis is configured for TLS)" if ssl_kwargs else ""
if retry_count >= max_retries:
logger.error(f"❌ Failed to connect to Redis after {max_retries} attempts: {e}{_tls_hint}")
return False
logger.info(f"⏳ Redis not available yet, retrying in {retry_interval}s... ({retry_count}/{max_retries})")
time.sleep(retry_interval)
except Exception as e:
_tls_hint = " (TLS is enabled — verify certificate paths and that Redis is configured for TLS)" if ssl_kwargs else ""
logger.error(f"❌ Unexpected error connecting to Redis: {e}{_tls_hint}")
return False
return False
if __name__ == "__main__":
host = os.environ.get('REDIS_HOST', 'localhost')
port = int(os.environ.get('REDIS_PORT', 6379))
db = int(os.environ.get('REDIS_DB', 0))
password = os.environ.get('REDIS_PASSWORD', '')
username = os.environ.get('REDIS_USER', '')
max_retries = int(os.environ.get('REDIS_WAIT_RETRIES', 30))
retry_interval = int(os.environ.get('REDIS_WAIT_INTERVAL', 2))
logger.info(f"Starting Redis availability check at {host}:{port}/{db}")
if wait_for_redis(host, port, db, password, username, max_retries, retry_interval):
sys.exit(0)
else:
sys.exit(1)