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,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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
Executable
+13
@@ -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
|
||||
@@ -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'])
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user