Use the queued episode spec when finalizing anipy-cli fallback TV downloads so retries keep the requested episode numbers instead of being renumbered from 1, and update tests plus release docs for the fix.
1226 lines
46 KiB
Python
1226 lines
46 KiB
Python
#!/usr/bin/env python3
|
|
|
|
"""Shared constants and helper utilities for ani-cli-web."""
|
|
|
|
import hashlib
|
|
import hmac
|
|
import ipaddress
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
import re
|
|
import secrets
|
|
import shutil
|
|
import threading
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
import urllib.error
|
|
import urllib.request
|
|
from urllib.parse import urlparse
|
|
from uuid import uuid4
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent
|
|
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
|
|
ANIPY_CLI = os.environ.get("ANIPY_CLI_BIN") or shutil.which("anipy-cli") or "anipy-cli"
|
|
APP_NAME = "ani-cli-web"
|
|
VERSION_FILE = PROJECT_ROOT / "VERSION"
|
|
CHANGELOG_FILE = PROJECT_ROOT / "CHANGELOG.md"
|
|
ALLANIME_BASE = "allanime.day"
|
|
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
|
|
ALLANIME_REFERER = "https://allmanga.to"
|
|
ALLMANGA_SITE_BASE = "https://allmanga.to"
|
|
ANIMESCHEDULE_API = "https://animeschedule.net/api/v3"
|
|
ANIMESCHEDULE_IMAGE_BASE = "https://img.animeschedule.net/production/assets/public/img/"
|
|
ANIDB_TITLES_URL = "https://anidb.net/api/anime-titles.xml.gz"
|
|
ANIDB_ANIME_PAGE = "https://anidb.net/anime/{aid}"
|
|
AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0"
|
|
CLIENT_DISCONNECT_ERRORS = (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)
|
|
DEBUG_MODE = False
|
|
|
|
QUALITY_CHOICES = {"best", "1080", "1080p", "720", "720p", "480", "480p", "360", "360p", "worst"}
|
|
MODE_CHOICES = {"sub", "dub"}
|
|
MEDIA_TYPE_CHOICES = {"tv", "movie"}
|
|
WATCHLIST_CATEGORY_LABELS = {
|
|
"watching": "Watching",
|
|
"planned": "Planned",
|
|
"finished": "Finished",
|
|
"dropped": "Dropped",
|
|
}
|
|
WATCHLIST_CATEGORY_CHOICES = tuple(WATCHLIST_CATEGORY_LABELS.keys())
|
|
MAX_LOG_LINES = 240
|
|
MAX_JSON_BODY_BYTES = 12 * 1024 * 1024
|
|
WATCHLIST_REFRESH_REQUEST_TIMEOUT_SECONDS = 8
|
|
WORKER_SHUTDOWN_TIMEOUT_SECONDS = 10
|
|
WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES = 60
|
|
WATCHLIST_AUTO_REFRESH_MIN_MINUTES = 1
|
|
WATCHLIST_AUTO_REFRESH_MAX_MINUTES = 7 * 24 * 60
|
|
WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS = 5
|
|
WATCHLIST_REFRESH_DELAY_MIN_SECONDS = 0
|
|
WATCHLIST_REFRESH_DELAY_MAX_SECONDS = 300
|
|
DISCORD_WEBHOOK_EVENT_CHOICES = (
|
|
"download_completed",
|
|
"jellyfin_sync_success",
|
|
"jellyfin_sync_failed",
|
|
"watchlist_refresh_new_episodes",
|
|
"watchlist_refresh_failed",
|
|
"watchlist_refresh_interrupted",
|
|
"runtime_error",
|
|
)
|
|
DISCORD_WEBHOOK_EVENT_LABELS = {
|
|
"download_completed": "Download completed",
|
|
"jellyfin_sync_success": "Jellyfin move succeeded",
|
|
"jellyfin_sync_failed": "Jellyfin move failed",
|
|
"watchlist_refresh_new_episodes": "Refresh finished with new episodes found",
|
|
"watchlist_refresh_failed": "Refresh failed or finished with refresh errors",
|
|
"watchlist_refresh_interrupted": "Refresh interrupted by shutdown or restart",
|
|
"runtime_error": "Unexpected runtime errors",
|
|
}
|
|
REMOTE_ACCESS_SESSION_TTL_SECONDS = 30 * 24 * 60 * 60
|
|
REMOTE_SESSION_STORE_LOCK = threading.RLock()
|
|
|
|
|
|
def load_app_version(default="0.0.0"):
|
|
try:
|
|
version = VERSION_FILE.read_text(encoding="utf-8").strip()
|
|
except OSError:
|
|
return default
|
|
return version or default
|
|
|
|
|
|
def load_project_text_file(path, default=""):
|
|
try:
|
|
return Path(path).read_text(encoding="utf-8")
|
|
except OSError:
|
|
return default
|
|
|
|
|
|
VERSION = load_app_version()
|
|
|
|
|
|
def env_flag(name, default=False):
|
|
raw = os.environ.get(name)
|
|
if raw is None:
|
|
return default
|
|
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def debug_enabled():
|
|
return DEBUG_MODE or env_flag("ANI_CLI_WEB_DEBUG")
|
|
|
|
|
|
def remote_access_allowed():
|
|
return env_flag("ANI_CLI_WEB_ALLOW_REMOTE")
|
|
|
|
|
|
def remote_access_username(config=None):
|
|
configured = str(os.environ.get("ANI_CLI_WEB_AUTH_USERNAME") or "").strip()
|
|
if configured:
|
|
return configured
|
|
if isinstance(config, dict):
|
|
return str(config.get("auth_username") or "").strip()
|
|
return ""
|
|
|
|
|
|
def remote_access_password(config=None):
|
|
configured = str(os.environ.get("ANI_CLI_WEB_AUTH_PASSWORD") or "").strip()
|
|
if configured:
|
|
return configured
|
|
if isinstance(config, dict):
|
|
return str(config.get("auth_password") or "").strip()
|
|
return ""
|
|
|
|
|
|
def remote_access_credentials_configured(config=None):
|
|
return bool(remote_access_username(config) and remote_access_password(config))
|
|
|
|
|
|
def remote_access_credentials_match(username, password, config=None):
|
|
expected_username = remote_access_username(config)
|
|
expected_password = remote_access_password(config)
|
|
provided_username = str(username or "").strip()
|
|
provided_password = str(password or "").strip()
|
|
return bool(
|
|
expected_username
|
|
and expected_password
|
|
and hmac.compare_digest(provided_username, expected_username)
|
|
and hmac.compare_digest(provided_password, expected_password)
|
|
)
|
|
|
|
|
|
def remote_access_session_value(config=None):
|
|
return create_remote_access_session(config)
|
|
|
|
|
|
def remote_access_session_fingerprint(config=None):
|
|
username = remote_access_username(config)
|
|
password = remote_access_password(config)
|
|
if not username or not password:
|
|
return ""
|
|
return hashlib.sha256(f"{username}\0{password}".encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _remote_access_session_token_digest(value):
|
|
return hashlib.sha256(str(value or "").strip().encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _load_remote_access_sessions_locked():
|
|
data = load_json(REMOTE_SESSIONS_PATH, {})
|
|
if not isinstance(data, dict):
|
|
return {}
|
|
return data
|
|
|
|
|
|
def _write_remote_access_sessions_locked(data):
|
|
write_json(REMOTE_SESSIONS_PATH, data)
|
|
|
|
|
|
def _prune_remote_access_sessions_locked(data, now=None):
|
|
timestamp = int(now if now is not None else time.time())
|
|
changed = False
|
|
for token_digest, payload in list(data.items()):
|
|
try:
|
|
expires_at = int(payload.get("expires_at") or 0)
|
|
except (AttributeError, TypeError, ValueError):
|
|
expires_at = 0
|
|
if expires_at <= timestamp:
|
|
data.pop(token_digest, None)
|
|
changed = True
|
|
return changed
|
|
|
|
|
|
def create_remote_access_session(config=None, ttl_seconds=REMOTE_ACCESS_SESSION_TTL_SECONDS):
|
|
username = remote_access_username(config)
|
|
fingerprint = remote_access_session_fingerprint(config)
|
|
if not username or not fingerprint:
|
|
return ""
|
|
now = int(time.time())
|
|
token = secrets.token_urlsafe(32)
|
|
payload = {
|
|
"username": username,
|
|
"fingerprint": fingerprint,
|
|
"created_at": now_iso(),
|
|
"expires_at": now + max(60, int(ttl_seconds or REMOTE_ACCESS_SESSION_TTL_SECONDS)),
|
|
}
|
|
with REMOTE_SESSION_STORE_LOCK:
|
|
sessions = _load_remote_access_sessions_locked()
|
|
_prune_remote_access_sessions_locked(sessions, now=now)
|
|
sessions[_remote_access_session_token_digest(token)] = payload
|
|
_write_remote_access_sessions_locked(sessions)
|
|
return token
|
|
|
|
|
|
def remote_access_session_matches(value, config=None):
|
|
provided = str(value or "").strip()
|
|
fingerprint = remote_access_session_fingerprint(config)
|
|
username = remote_access_username(config)
|
|
if not provided or not fingerprint or not username:
|
|
return False
|
|
token_digest = _remote_access_session_token_digest(provided)
|
|
with REMOTE_SESSION_STORE_LOCK:
|
|
sessions = _load_remote_access_sessions_locked()
|
|
changed = _prune_remote_access_sessions_locked(sessions)
|
|
payload = sessions.get(token_digest)
|
|
if changed:
|
|
_write_remote_access_sessions_locked(sessions)
|
|
if not isinstance(payload, dict):
|
|
return False
|
|
return bool(
|
|
hmac.compare_digest(str(payload.get("fingerprint") or ""), fingerprint)
|
|
and hmac.compare_digest(str(payload.get("username") or ""), username)
|
|
)
|
|
|
|
|
|
def clear_remote_access_sessions(config=None):
|
|
expected_fingerprint = str(config.get("fingerprint") or "") if isinstance(config, dict) else ""
|
|
with REMOTE_SESSION_STORE_LOCK:
|
|
sessions = _load_remote_access_sessions_locked()
|
|
if expected_fingerprint:
|
|
filtered = {
|
|
token_digest: payload
|
|
for token_digest, payload in sessions.items()
|
|
if str((payload or {}).get("fingerprint") or "") != expected_fingerprint
|
|
}
|
|
else:
|
|
filtered = {}
|
|
if filtered == sessions:
|
|
return False
|
|
_write_remote_access_sessions_locked(filtered)
|
|
return True
|
|
|
|
|
|
def background_workers_enabled():
|
|
return not env_flag("ANI_CLI_WEB_DISABLE_WORKER")
|
|
|
|
|
|
def client_address_is_local(host):
|
|
text = str(host or "").strip()
|
|
if not text:
|
|
return False
|
|
if text == "localhost":
|
|
return True
|
|
try:
|
|
return ipaddress.ip_address(text).is_loopback
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def debug_log(event, **fields):
|
|
if not debug_enabled():
|
|
return
|
|
parts = [f"[debug] {event}"]
|
|
for key, value in fields.items():
|
|
text = str(value)
|
|
if len(text) > 240:
|
|
text = text[:237] + "..."
|
|
parts.append(f"{key}={text}")
|
|
print(" | ".join(parts), flush=True)
|
|
|
|
|
|
def default_download_dir():
|
|
if os.environ.get("ANI_CLI_DOWNLOAD_DIR"):
|
|
return os.environ["ANI_CLI_DOWNLOAD_DIR"]
|
|
home_downloads = Path.home() / "Downloads" / "Anime"
|
|
if os.access(home_downloads.parent if home_downloads.parent.exists() else Path.home(), os.W_OK):
|
|
return str(home_downloads)
|
|
return str(PROJECT_ROOT / "downloads")
|
|
|
|
|
|
DEFAULT_CONFIG = {
|
|
"download_dir": default_download_dir(),
|
|
"mode": os.environ.get("ANI_CLI_MODE", "sub"),
|
|
"quality": os.environ.get("ANI_CLI_QUALITY", "best"),
|
|
"anipy_cli_fallback_enabled": False,
|
|
"watchlist_auto_refresh_enabled": False,
|
|
"watchlist_auto_refresh_minutes": WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES,
|
|
"watchlist_refresh_delay_seconds": WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS,
|
|
"auto_download_enabled": False,
|
|
"auto_download_mode": "dub",
|
|
"auto_download_quality": "best",
|
|
"jellyfin_sync_enabled": False,
|
|
"jellyfin_tv_dir": "",
|
|
"jellyfin_movie_dir": "",
|
|
"discord_webhook_url": "",
|
|
"discord_webhook_events": [],
|
|
"auth_username": str(os.environ.get("ANI_CLI_WEB_AUTH_USERNAME") or "").strip(),
|
|
"auth_password": str(os.environ.get("ANI_CLI_WEB_AUTH_PASSWORD") or "").strip(),
|
|
}
|
|
PRIVATE_CONFIG_KEYS = {"auth_username", "auth_password"}
|
|
|
|
|
|
def now_iso():
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def project_app_root():
|
|
configured = str(os.environ.get("ANI_CLI_WEB_STATE_ROOT") or "").strip()
|
|
if configured:
|
|
return Path(configured).expanduser()
|
|
return PROJECT_ROOT / ".ani-cli-web"
|
|
|
|
|
|
PROJECT_APP_ROOT = project_app_root()
|
|
|
|
|
|
def ensure_project_app_root():
|
|
PROJECT_APP_ROOT.mkdir(parents=True, exist_ok=True)
|
|
return PROJECT_APP_ROOT
|
|
|
|
|
|
def legacy_state_root():
|
|
return Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / APP_NAME
|
|
|
|
|
|
def legacy_config_root():
|
|
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME
|
|
|
|
|
|
def project_state_path(*parts):
|
|
return PROJECT_APP_ROOT.joinpath(*parts)
|
|
|
|
|
|
def project_state_dir(*parts):
|
|
return PROJECT_APP_ROOT.joinpath(*parts)
|
|
|
|
|
|
def migrate_legacy_file(project_path, legacy_path, label):
|
|
if project_path.exists() or not legacy_path.exists():
|
|
return project_path
|
|
project_path.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
shutil.move(str(legacy_path), str(project_path))
|
|
debug_log("state.migrated", label=label, from_path=legacy_path, to_path=project_path)
|
|
except OSError as exc:
|
|
debug_log("state.migrate_failed", label=label, from_path=legacy_path, to_path=project_path, error=exc)
|
|
return project_path
|
|
|
|
|
|
def migrate_legacy_dir(project_dir, legacy_dir, label):
|
|
if not legacy_dir.exists():
|
|
return project_dir
|
|
project_dir.mkdir(parents=True, exist_ok=True)
|
|
moved = 0
|
|
for child in legacy_dir.iterdir():
|
|
target = project_dir / child.name
|
|
if target.exists():
|
|
if child.is_dir() and target.is_dir():
|
|
migrate_legacy_dir(target, child, f"{label}/{child.name}")
|
|
continue
|
|
try:
|
|
shutil.move(str(child), str(target))
|
|
moved += 1
|
|
except OSError as exc:
|
|
debug_log("state.migrate_failed", label=label, from_path=child, to_path=target, error=exc)
|
|
try:
|
|
legacy_dir.rmdir()
|
|
except OSError:
|
|
pass
|
|
if moved:
|
|
debug_log("state.migrated", label=label, from_path=legacy_dir, to_path=project_dir, entries=moved)
|
|
return project_dir
|
|
|
|
|
|
LEGACY_STATE_ROOT = legacy_state_root()
|
|
LEGACY_CONFIG_ROOT = legacy_config_root()
|
|
LEGACY_CONFIG_PATH = LEGACY_CONFIG_ROOT / "config.json"
|
|
LEGACY_QUEUE_PATH = LEGACY_STATE_ROOT / "queue.json"
|
|
LEGACY_QUEUE_DB_PATH = LEGACY_STATE_ROOT / "queue.sqlite3"
|
|
LEGACY_STAGING_ROOT = LEGACY_STATE_ROOT / "staging"
|
|
LEGACY_WATCHLIST_JSON_PATH = LEGACY_STATE_ROOT / "watchlist.json"
|
|
LEGACY_THUMBNAIL_ROOT = LEGACY_STATE_ROOT / "thumbnails"
|
|
LEGACY_ANIDB_TITLES_PATH = LEGACY_STATE_ROOT / "anidb-anime-titles.xml.gz"
|
|
|
|
CONFIG_PATH = project_state_path("config.json")
|
|
QUEUE_PATH = project_state_path("queue.json")
|
|
STATE_DB_PATH = project_state_path("state.sqlite3")
|
|
STAGING_ROOT = project_state_dir("staging")
|
|
WATCHLIST_JSON_PATH = project_state_path("watchlist.json")
|
|
THUMBNAIL_ROOT = project_state_dir("thumbnails")
|
|
THUMBNAIL_RETRY_SECONDS = 15 * 60
|
|
ANIDB_TITLES_PATH = project_state_path("anidb-anime-titles.xml.gz")
|
|
REMOTE_SESSIONS_PATH = project_state_path("remote-sessions.json")
|
|
ANIDB_TITLES_CACHE = {
|
|
"mtime": None,
|
|
"entries": None,
|
|
"title_index": None,
|
|
"token_index": None,
|
|
"match_cache": {},
|
|
}
|
|
|
|
|
|
def migrate_legacy_state_storage():
|
|
ensure_project_app_root()
|
|
migrate_legacy_file(CONFIG_PATH, LEGACY_CONFIG_PATH, "config.json")
|
|
migrate_legacy_file(QUEUE_PATH, LEGACY_QUEUE_PATH, "queue.json")
|
|
migrate_legacy_file(STATE_DB_PATH, PROJECT_APP_ROOT / "queue.sqlite3", "queue.sqlite3")
|
|
migrate_legacy_file(STATE_DB_PATH, LEGACY_QUEUE_DB_PATH, "queue.sqlite3")
|
|
migrate_legacy_file(WATCHLIST_JSON_PATH, LEGACY_WATCHLIST_JSON_PATH, "watchlist.json")
|
|
migrate_legacy_file(ANIDB_TITLES_PATH, LEGACY_ANIDB_TITLES_PATH, "anidb-anime-titles.xml.gz")
|
|
migrate_legacy_dir(STAGING_ROOT, LEGACY_STAGING_ROOT, "staging")
|
|
migrate_legacy_dir(THUMBNAIL_ROOT, LEGACY_THUMBNAIL_ROOT, "thumbnails")
|
|
|
|
def load_json(path, fallback):
|
|
try:
|
|
with path.open("r", encoding="utf-8") as handle:
|
|
data = json.load(handle)
|
|
return data if isinstance(data, type(fallback)) else fallback
|
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
return fallback
|
|
|
|
|
|
def write_json(path, data):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
with tmp.open("w", encoding="utf-8") as handle:
|
|
json.dump(data, handle, indent=2, sort_keys=True)
|
|
tmp.replace(path)
|
|
|
|
|
|
def normalize_config(data):
|
|
config = dict(DEFAULT_CONFIG)
|
|
if isinstance(data, dict):
|
|
config.update({key: data[key] for key in DEFAULT_CONFIG if key in data})
|
|
|
|
mode = str(config.get("mode", "sub")).lower()
|
|
quality = str(config.get("quality", "best")).lower()
|
|
if quality.endswith("p") and quality[:-1].isdigit():
|
|
quality = quality[:-1]
|
|
download_dir = str(config.get("download_dir") or DEFAULT_CONFIG["download_dir"])
|
|
anipy_cli_fallback_enabled = config.get("anipy_cli_fallback_enabled")
|
|
auto_refresh_enabled = config.get("watchlist_auto_refresh_enabled")
|
|
auto_refresh_minutes = config.get("watchlist_auto_refresh_minutes")
|
|
refresh_delay_seconds = config.get("watchlist_refresh_delay_seconds")
|
|
auto_download_enabled = config.get("auto_download_enabled")
|
|
auto_download_mode = str(config.get("auto_download_mode", "dub")).lower()
|
|
auto_download_quality = str(config.get("auto_download_quality", "best")).lower()
|
|
if auto_download_quality.endswith("p") and auto_download_quality[:-1].isdigit():
|
|
auto_download_quality = auto_download_quality[:-1]
|
|
jellyfin_sync_enabled = config.get("jellyfin_sync_enabled")
|
|
jellyfin_tv_dir = str(config.get("jellyfin_tv_dir") or "").strip()
|
|
jellyfin_movie_dir = str(config.get("jellyfin_movie_dir") or "").strip()
|
|
webhook_url = str(config.get("discord_webhook_url") or "").strip()
|
|
webhook_events = config.get("discord_webhook_events")
|
|
auth_username = str(config.get("auth_username") or "").strip()
|
|
auth_password = str(config.get("auth_password") or "").strip()
|
|
|
|
if isinstance(anipy_cli_fallback_enabled, str):
|
|
anipy_cli_fallback_enabled = anipy_cli_fallback_enabled.strip().lower() in {"1", "true", "yes", "on"}
|
|
else:
|
|
anipy_cli_fallback_enabled = bool(anipy_cli_fallback_enabled)
|
|
if isinstance(auto_refresh_enabled, str):
|
|
auto_refresh_enabled = auto_refresh_enabled.strip().lower() in {"1", "true", "yes", "on"}
|
|
else:
|
|
auto_refresh_enabled = bool(auto_refresh_enabled)
|
|
if isinstance(auto_download_enabled, str):
|
|
auto_download_enabled = auto_download_enabled.strip().lower() in {"1", "true", "yes", "on"}
|
|
else:
|
|
auto_download_enabled = bool(auto_download_enabled)
|
|
if isinstance(jellyfin_sync_enabled, str):
|
|
jellyfin_sync_enabled = jellyfin_sync_enabled.strip().lower() in {"1", "true", "yes", "on"}
|
|
else:
|
|
jellyfin_sync_enabled = bool(jellyfin_sync_enabled)
|
|
|
|
try:
|
|
auto_refresh_minutes = int(str(auto_refresh_minutes).strip() or WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES)
|
|
except (TypeError, ValueError):
|
|
auto_refresh_minutes = WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES
|
|
auto_refresh_minutes = max(
|
|
WATCHLIST_AUTO_REFRESH_MIN_MINUTES,
|
|
min(WATCHLIST_AUTO_REFRESH_MAX_MINUTES, auto_refresh_minutes),
|
|
)
|
|
try:
|
|
refresh_delay_seconds = int(str(refresh_delay_seconds).strip() or WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS)
|
|
except (TypeError, ValueError):
|
|
refresh_delay_seconds = WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS
|
|
refresh_delay_seconds = max(
|
|
WATCHLIST_REFRESH_DELAY_MIN_SECONDS,
|
|
min(WATCHLIST_REFRESH_DELAY_MAX_SECONDS, refresh_delay_seconds),
|
|
)
|
|
|
|
config["mode"] = mode if mode in MODE_CHOICES else "sub"
|
|
config["quality"] = quality if quality in QUALITY_CHOICES else "best"
|
|
config["download_dir"] = str(Path(download_dir).expanduser())
|
|
config["anipy_cli_fallback_enabled"] = anipy_cli_fallback_enabled
|
|
config["watchlist_auto_refresh_enabled"] = auto_refresh_enabled
|
|
config["watchlist_auto_refresh_minutes"] = auto_refresh_minutes
|
|
config["watchlist_refresh_delay_seconds"] = refresh_delay_seconds
|
|
config["auto_download_enabled"] = auto_download_enabled
|
|
config["auto_download_mode"] = auto_download_mode if auto_download_mode in MODE_CHOICES else "dub"
|
|
config["auto_download_quality"] = auto_download_quality if auto_download_quality in QUALITY_CHOICES else "best"
|
|
config["jellyfin_sync_enabled"] = jellyfin_sync_enabled
|
|
config["jellyfin_tv_dir"] = str(Path(jellyfin_tv_dir).expanduser()) if jellyfin_tv_dir else ""
|
|
config["jellyfin_movie_dir"] = str(Path(jellyfin_movie_dir).expanduser()) if jellyfin_movie_dir else ""
|
|
if isinstance(webhook_events, str):
|
|
webhook_events = [part.strip() for part in webhook_events.split(",")]
|
|
elif not isinstance(webhook_events, list):
|
|
webhook_events = []
|
|
normalized_events = []
|
|
seen_events = set()
|
|
for event_name in webhook_events:
|
|
normalized_name = str(event_name or "").strip()
|
|
if normalized_name in DISCORD_WEBHOOK_EVENT_CHOICES and normalized_name not in seen_events:
|
|
seen_events.add(normalized_name)
|
|
normalized_events.append(normalized_name)
|
|
config["discord_webhook_url"] = webhook_url
|
|
config["discord_webhook_events"] = normalized_events
|
|
config["auth_username"] = auth_username
|
|
config["auth_password"] = auth_password
|
|
return config
|
|
|
|
|
|
def sanitize_public_config(config):
|
|
source = normalize_config(config or {})
|
|
return {key: value for key, value in source.items() if key not in PRIVATE_CONFIG_KEYS}
|
|
|
|
|
|
def _env_remote_path_roots():
|
|
raw = str(os.environ.get("ANI_CLI_WEB_REMOTE_PATH_ROOTS") or "").strip()
|
|
if not raw:
|
|
return []
|
|
roots = []
|
|
for part in raw.split(os.pathsep):
|
|
text = str(part or "").strip()
|
|
if not text:
|
|
continue
|
|
try:
|
|
resolved = Path(text).expanduser().resolve()
|
|
except OSError:
|
|
continue
|
|
if resolved not in roots:
|
|
roots.append(resolved)
|
|
return roots
|
|
|
|
|
|
def configured_remote_path_roots(config=None):
|
|
normalized = normalize_config(config or {})
|
|
roots = list(_env_remote_path_roots())
|
|
for raw_path in (
|
|
normalized.get("download_dir"),
|
|
normalized.get("jellyfin_tv_dir"),
|
|
normalized.get("jellyfin_movie_dir"),
|
|
):
|
|
text = str(raw_path or "").strip()
|
|
if not text:
|
|
continue
|
|
try:
|
|
resolved = Path(text).expanduser().resolve()
|
|
except OSError:
|
|
continue
|
|
if resolved not in roots:
|
|
roots.append(resolved)
|
|
return roots
|
|
|
|
|
|
def path_is_within_roots(path, roots):
|
|
try:
|
|
candidate = Path(path).expanduser().resolve()
|
|
except OSError:
|
|
return False
|
|
for root in roots or []:
|
|
try:
|
|
root_path = Path(root).expanduser().resolve()
|
|
except OSError:
|
|
continue
|
|
if candidate == root_path or root_path in candidate.parents:
|
|
return True
|
|
return False
|
|
|
|
|
|
def validate_discord_webhook_url(url):
|
|
text = str(url or "").strip()
|
|
if not text:
|
|
return ""
|
|
parsed = urlparse(text)
|
|
if parsed.scheme != "https":
|
|
raise ValueError("Discord webhook URL must use https.")
|
|
host = str(parsed.netloc or "").split("@", 1)[-1].split(":", 1)[0].strip().lower()
|
|
if host not in {"discord.com", "ptb.discord.com", "canary.discord.com", "discordapp.com"}:
|
|
raise ValueError("Discord webhook URL must point to an official Discord webhook host.")
|
|
if not str(parsed.path or "").startswith("/api/webhooks/"):
|
|
raise ValueError("Discord webhook URL must use the Discord /api/webhooks/ path.")
|
|
return text
|
|
|
|
|
|
def discord_webhook_enabled(config, event_name, force=False):
|
|
if not isinstance(config, dict):
|
|
return False
|
|
webhook_url = str(config.get("discord_webhook_url") or "").strip()
|
|
if not webhook_url:
|
|
return False
|
|
if force:
|
|
return True
|
|
return str(event_name or "").strip() in {
|
|
str(name).strip() for name in config.get("discord_webhook_events") or []
|
|
}
|
|
|
|
|
|
def thumbnail_file_path(name):
|
|
filename = Path(str(name or "")).name
|
|
if not filename:
|
|
return None
|
|
project_path = THUMBNAIL_ROOT / filename
|
|
if project_path.exists():
|
|
return project_path
|
|
legacy_path = LEGACY_THUMBNAIL_ROOT / filename
|
|
if legacy_path.exists():
|
|
THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
shutil.move(str(legacy_path), str(project_path))
|
|
debug_log("thumbnail.cache.migrated", from_path=legacy_path, to_path=project_path)
|
|
return project_path
|
|
except OSError as exc:
|
|
debug_log("thumbnail.cache.migrate_failed", from_path=legacy_path, to_path=project_path, error=exc)
|
|
return legacy_path
|
|
return project_path
|
|
|
|
|
|
def _discord_file_part(boundary, field_name, attachment_name, content_type, payload):
|
|
safe_name = str(attachment_name or "attachment.bin").replace('"', "")
|
|
header = (
|
|
f"--{boundary}\r\n"
|
|
f'Content-Disposition: form-data; name="{field_name}"; filename="{safe_name}"\r\n'
|
|
f"Content-Type: {content_type}\r\n\r\n"
|
|
).encode("utf-8")
|
|
return header + payload + b"\r\n"
|
|
|
|
|
|
def _discord_text_part(boundary, field_name, value):
|
|
return (
|
|
f"--{boundary}\r\n"
|
|
f'Content-Disposition: form-data; name="{field_name}"\r\n\r\n'
|
|
f"{value}\r\n"
|
|
).encode("utf-8")
|
|
|
|
|
|
def _post_discord_webhook(url, payload, attachments=None, timeout=10):
|
|
attachments = attachments or []
|
|
headers = {"User-Agent": AGENT}
|
|
if attachments:
|
|
boundary = f"ani-cli-web-{uuid4().hex}"
|
|
body = bytearray()
|
|
body.extend(_discord_text_part(boundary, "payload_json", json.dumps(payload)))
|
|
for index, attachment in enumerate(attachments):
|
|
path = attachment.get("path")
|
|
if path is None or not Path(path).exists():
|
|
continue
|
|
data = Path(path).read_bytes()
|
|
if not data:
|
|
continue
|
|
filename = str(attachment.get("filename") or Path(path).name or f"attachment-{index}")
|
|
content_type = str(attachment.get("content_type") or mimetypes.guess_type(filename)[0] or "application/octet-stream")
|
|
body.extend(_discord_file_part(boundary, f"files[{index}]", filename, content_type, data))
|
|
body.extend(f"--{boundary}--\r\n".encode("utf-8"))
|
|
headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
|
|
request_data = bytes(body)
|
|
else:
|
|
headers["Content-Type"] = "application/json"
|
|
request_data = json.dumps(payload).encode("utf-8")
|
|
request = urllib.request.Request(url, data=request_data, headers=headers, method="POST")
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
response.read()
|
|
return getattr(response, "status", 204)
|
|
except urllib.error.HTTPError as exc:
|
|
details = exc.read().decode("utf-8", errors="replace").strip()
|
|
if details:
|
|
raise RuntimeError(f"Discord webhook rejected the request: HTTP {exc.code} {details}") from exc
|
|
raise RuntimeError(f"Discord webhook rejected the request: HTTP {exc.code}") from exc
|
|
except urllib.error.URLError as exc:
|
|
raise RuntimeError(f"Could not reach Discord webhook: {exc}") from exc
|
|
except TimeoutError as exc:
|
|
raise RuntimeError("Discord webhook request timed out") from exc
|
|
|
|
|
|
def _chunk_discord_lines(lines, max_chars=3800):
|
|
chunks = []
|
|
current = []
|
|
current_length = 0
|
|
for raw_line in lines or []:
|
|
line = str(raw_line or "").strip()
|
|
if not line:
|
|
continue
|
|
added_length = len(line) + (1 if current else 0)
|
|
if current and current_length + added_length > max_chars:
|
|
chunks.append(current)
|
|
current = [line]
|
|
current_length = len(line)
|
|
continue
|
|
current.append(line)
|
|
current_length += added_length
|
|
if current:
|
|
chunks.append(current)
|
|
return chunks
|
|
|
|
|
|
def _post_discord_embeds_with_file_list(webhook_url, content, summary_embed, file_lines, attachments=None):
|
|
chunks = _chunk_discord_lines(file_lines)
|
|
if not chunks:
|
|
_post_discord_webhook(webhook_url, {"content": content, "embeds": [summary_embed]}, attachments=attachments)
|
|
return
|
|
total_chunks = len(chunks)
|
|
first_batch = chunks[:9]
|
|
request_embeds = [summary_embed]
|
|
for index, chunk in enumerate(first_batch, start=1):
|
|
request_embeds.append(
|
|
{
|
|
"title": f"Saved files ({index}/{total_chunks})",
|
|
"description": "\n".join(chunk)[:4096],
|
|
"color": summary_embed.get("color", 5763719),
|
|
}
|
|
)
|
|
_post_discord_webhook(webhook_url, {"content": content, "embeds": request_embeds}, attachments=attachments)
|
|
sent_chunks = len(first_batch)
|
|
while sent_chunks < total_chunks:
|
|
batch = chunks[sent_chunks : sent_chunks + 10]
|
|
embeds = []
|
|
for offset, chunk in enumerate(batch, start=1):
|
|
chunk_index = sent_chunks + offset
|
|
embeds.append(
|
|
{
|
|
"title": f"Saved files ({chunk_index}/{total_chunks})",
|
|
"description": "\n".join(chunk)[:4096],
|
|
"color": summary_embed.get("color", 5763719),
|
|
}
|
|
)
|
|
_post_discord_webhook(webhook_url, {"content": content, "embeds": embeds})
|
|
sent_chunks += len(batch)
|
|
|
|
|
|
def send_discord_webhook_event(config, event_name, payload=None, force=False):
|
|
normalized = normalize_config(config or {})
|
|
event = str(event_name or "").strip()
|
|
if not discord_webhook_enabled(normalized, event, force=force):
|
|
return {"sent": False, "reason": "disabled"}
|
|
webhook_url = validate_discord_webhook_url(normalized["discord_webhook_url"])
|
|
payload = payload or {}
|
|
|
|
if event == "test":
|
|
request_payload = {
|
|
"content": "ani-cli-web webhook test",
|
|
"embeds": [
|
|
{
|
|
"title": "Webhook test",
|
|
"description": "The Discord webhook is configured and reachable from ani-cli-web.",
|
|
"color": 5814783,
|
|
"footer": {"text": f"{APP_NAME} {VERSION}"},
|
|
}
|
|
],
|
|
}
|
|
_post_discord_webhook(webhook_url, request_payload)
|
|
return {"sent": True, "event": event}
|
|
|
|
if event == "watchlist_refresh_new_episodes":
|
|
items = payload.get("items") if isinstance(payload.get("items"), list) else []
|
|
if not items:
|
|
return {"sent": False, "reason": "no_items"}
|
|
embeds = []
|
|
attachments = []
|
|
for index, item in enumerate(items[:10]):
|
|
title = str(item.get("title") or item.get("show_id") or "Unknown anime").strip()
|
|
sub_count = int(item.get("sub_count") or 0)
|
|
dub_count = int(item.get("dub_count") or 0)
|
|
sub_delta = int(item.get("sub_delta") or 0)
|
|
dub_delta = int(item.get("dub_delta") or 0)
|
|
expected_count = int(item.get("expected_count") or 0)
|
|
airing_status = str(item.get("airing_status") or "").strip()
|
|
description_lines = [
|
|
f"Sub: {sub_count}" + (f" (+{sub_delta})" if sub_delta > 0 else ""),
|
|
f"Dub: {dub_count}" + (f" (+{dub_delta})" if dub_delta > 0 else ""),
|
|
]
|
|
if expected_count > 0:
|
|
description_lines.append(f"Expected episodes: {expected_count}")
|
|
if airing_status:
|
|
description_lines.append(f"Status: {airing_status}")
|
|
embed = {
|
|
"title": title[:256],
|
|
"description": "\n".join(description_lines)[:4096],
|
|
"color": 5763719,
|
|
}
|
|
thumb_path = thumbnail_file_path(item.get("thumbnail_path"))
|
|
if thumb_path and thumb_path.exists():
|
|
attachment_name = f"thumb-{index}{thumb_path.suffix or '.jpg'}"
|
|
attachments.append({"path": thumb_path, "filename": attachment_name})
|
|
embed["thumbnail"] = {"url": f"attachment://{attachment_name}"}
|
|
embeds.append(embed)
|
|
extra_count = max(0, len(items) - len(embeds))
|
|
request_payload = {
|
|
"content": (
|
|
f"Watchlist refresh found new episodes for {len(items)} anime."
|
|
+ (f" Showing the first {len(embeds)} entries." if extra_count else "")
|
|
),
|
|
"embeds": embeds,
|
|
}
|
|
_post_discord_webhook(webhook_url, request_payload, attachments=attachments)
|
|
return {"sent": True, "event": event, "count": len(items)}
|
|
|
|
if event == "download_completed":
|
|
title = str(payload.get("title") or payload.get("show_id") or "Unknown anime").strip()
|
|
moved_files = payload.get("moved_files") if isinstance(payload.get("moved_files"), list) else []
|
|
description_lines = []
|
|
if payload.get("mode"):
|
|
description_lines.append(f"Mode: {str(payload.get('mode')).strip()}")
|
|
if payload.get("episodes"):
|
|
description_lines.append(f"Episodes: {str(payload.get('episodes')).strip()}")
|
|
if payload.get("category"):
|
|
description_lines.append(f"Watchlist category: {str(payload.get('category')).strip()}")
|
|
if moved_files:
|
|
description_lines.append(f"Saved files: {len(moved_files)}")
|
|
embed = {
|
|
"title": title[:256],
|
|
"description": "\n".join(description_lines)[:4096] or "Download completed.",
|
|
"color": 5763719,
|
|
}
|
|
attachments = []
|
|
thumb_path = thumbnail_file_path(payload.get("thumbnail_path"))
|
|
if thumb_path and thumb_path.exists():
|
|
attachment_name = f"download-thumb{thumb_path.suffix or '.jpg'}"
|
|
attachments.append({"path": thumb_path, "filename": attachment_name})
|
|
embed["thumbnail"] = {"url": f"attachment://{attachment_name}"}
|
|
_post_discord_embeds_with_file_list(
|
|
webhook_url,
|
|
"ani-cli-web finished a download.",
|
|
embed,
|
|
moved_files,
|
|
attachments=attachments,
|
|
)
|
|
return {"sent": True, "event": event}
|
|
|
|
if event == "jellyfin_sync_success":
|
|
title = str(payload.get("title") or payload.get("show_id") or "Unknown anime").strip()
|
|
moved_files = payload.get("moved_files") if isinstance(payload.get("moved_files"), list) else []
|
|
description_lines = [
|
|
f"Media type: {str(payload.get('media_type') or 'tv').strip()}",
|
|
f"Source: {str(payload.get('source') or 'manual').strip()}",
|
|
]
|
|
if payload.get("target"):
|
|
description_lines.append(f"Target: {str(payload.get('target')).strip()}")
|
|
if moved_files:
|
|
description_lines.append(f"Moved files: {len(moved_files)}")
|
|
embed = {
|
|
"title": f"Jellyfin handoff: {title[:236]}",
|
|
"description": "\n".join(description_lines)[:4096],
|
|
"color": 5763719,
|
|
}
|
|
_post_discord_embeds_with_file_list(
|
|
webhook_url,
|
|
"ani-cli-web moved a finished library into Jellyfin.",
|
|
embed,
|
|
moved_files,
|
|
)
|
|
return {"sent": True, "event": event}
|
|
|
|
if event == "jellyfin_sync_failed":
|
|
title = str(payload.get("title") or payload.get("show_id") or "Unknown anime").strip()
|
|
reason = str(payload.get("reason") or "unknown").strip()
|
|
description_lines = [
|
|
f"Reason: {reason}",
|
|
f"Media type: {str(payload.get('media_type') or 'tv').strip()}",
|
|
f"Source: {str(payload.get('source') or 'manual').strip()}",
|
|
]
|
|
if payload.get("error"):
|
|
description_lines.append(f"Error: {str(payload.get('error')).strip()}")
|
|
if payload.get("source_path"):
|
|
description_lines.append(f"Source path: {str(payload.get('source_path')).strip()}")
|
|
if payload.get("target"):
|
|
description_lines.append(f"Target path: {str(payload.get('target')).strip()}")
|
|
request_payload = {
|
|
"content": "ani-cli-web could not move a finished library into Jellyfin.",
|
|
"embeds": [
|
|
{
|
|
"title": f"Jellyfin handoff failed: {title[:229]}",
|
|
"description": "\n".join(description_lines)[:4096],
|
|
"color": 15158332,
|
|
}
|
|
],
|
|
}
|
|
_post_discord_webhook(webhook_url, request_payload)
|
|
return {"sent": True, "event": event}
|
|
|
|
if event == "watchlist_refresh_failed":
|
|
error_lines = []
|
|
for item in (payload.get("items") if isinstance(payload.get("items"), list) else [])[:8]:
|
|
title = str(item.get("title") or item.get("show_id") or "Unknown anime").strip()
|
|
error_text = str(item.get("error") or "").strip()
|
|
if title and error_text:
|
|
error_lines.append(f"{title}: {error_text}")
|
|
if payload.get("error") and not error_lines:
|
|
error_lines.append(str(payload["error"]).strip())
|
|
request_payload = {
|
|
"content": "Watchlist refresh reported errors.",
|
|
"embeds": [
|
|
{
|
|
"title": "Refresh errors",
|
|
"description": "\n".join(error_lines[:8])[:4096] or "The refresh completed with errors.",
|
|
"color": 15158332,
|
|
"fields": [
|
|
{"name": "Completed", "value": str(int(payload.get("completed") or 0)), "inline": True},
|
|
{"name": "Total", "value": str(int(payload.get("total") or 0)), "inline": True},
|
|
{"name": "Errors", "value": str(int(payload.get("errors") or 0)), "inline": True},
|
|
],
|
|
"footer": {"text": f"Source: {str(payload.get('source') or 'manual')}"},
|
|
}
|
|
],
|
|
}
|
|
_post_discord_webhook(webhook_url, request_payload)
|
|
return {"sent": True, "event": event}
|
|
|
|
if event == "watchlist_refresh_interrupted":
|
|
request_payload = {
|
|
"content": "Watchlist refresh was interrupted.",
|
|
"embeds": [
|
|
{
|
|
"title": "Refresh interrupted",
|
|
"description": str(payload.get("message") or "The watchlist refresh did not finish.").strip()[:4096],
|
|
"color": 16763904,
|
|
"fields": [
|
|
{"name": "Completed", "value": str(int(payload.get("completed") or 0)), "inline": True},
|
|
{"name": "Total", "value": str(int(payload.get("total") or 0)), "inline": True},
|
|
],
|
|
"footer": {"text": f"Source: {str(payload.get('source') or 'manual')}"},
|
|
}
|
|
],
|
|
}
|
|
_post_discord_webhook(webhook_url, request_payload)
|
|
return {"sent": True, "event": event}
|
|
|
|
if event == "runtime_error":
|
|
source = str(payload.get("source") or "runtime").strip()
|
|
error_text = str(payload.get("error") or "Unknown error").strip()
|
|
details = str(payload.get("details") or "").strip()
|
|
description = error_text if not details else f"{error_text}\n{details}"
|
|
request_payload = {
|
|
"content": "ani-cli-web logged an unexpected error.",
|
|
"embeds": [
|
|
{
|
|
"title": source[:256],
|
|
"description": description[:4096],
|
|
"color": 15158332,
|
|
}
|
|
],
|
|
}
|
|
_post_discord_webhook(webhook_url, request_payload)
|
|
return {"sent": True, "event": event}
|
|
|
|
raise ValueError(f"Unsupported Discord webhook event: {event}")
|
|
|
|
|
|
def strip_control(value):
|
|
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", str(value)).replace("\r", "\n")
|
|
|
|
|
|
def cli_executable_exists(command):
|
|
text = str(command or "").strip()
|
|
if not text:
|
|
return False
|
|
return bool(shutil.which(text) or (Path(text).exists() and os.access(text, os.X_OK)))
|
|
|
|
|
|
def printable_command(command):
|
|
return " ".join(sh_quote(part) for part in command)
|
|
|
|
|
|
def sh_quote(value):
|
|
if re.match(r"^[A-Za-z0-9_./:=+-]+$", value):
|
|
return value
|
|
return "'" + value.replace("'", "'\"'\"'") + "'"
|
|
|
|
|
|
def validate_episode_spec(value):
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
raise ValueError("Choose at least one episode")
|
|
if not re.match(r"^[0-9.\-\s]+$", text):
|
|
raise ValueError("Episodes can contain numbers, dots, spaces, and ranges")
|
|
return re.sub(r"\s+", " ", text)
|
|
|
|
|
|
def sanitize_path_component(value, fallback="Anime"):
|
|
text = str(value or "").strip()
|
|
text = re.sub(r'[\\/:*?"<>|]+', "-", text)
|
|
text = re.sub(r"\s+", " ", text).strip(" .")
|
|
return text or fallback
|
|
|
|
|
|
def normalize_season(value):
|
|
text = str(value or "1").strip()
|
|
if not re.match(r"^[0-9]+$", text):
|
|
raise ValueError("Season must be a positive number")
|
|
number = int(text, 10)
|
|
if number < 1:
|
|
raise ValueError("Season must be a positive number")
|
|
return str(number)
|
|
|
|
|
|
def normalize_episode_offset(value):
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
return None
|
|
if not re.match(r"^[0-9]+$", text):
|
|
raise ValueError("Episode offset must be a positive number")
|
|
number = int(text, 10)
|
|
if number < 1:
|
|
raise ValueError("Episode offset must be a positive number")
|
|
return str(number)
|
|
|
|
|
|
def normalize_media_type(value, default="tv"):
|
|
media_type = str(value or default).strip().lower()
|
|
return media_type if media_type in MEDIA_TYPE_CHOICES else default
|
|
|
|
|
|
def job_output_dir(job):
|
|
anime_name = sanitize_path_component(job.get("anime_name") or job.get("title"))
|
|
media_type = normalize_media_type(job.get("media_type"))
|
|
library_root = Path(job["download_dir"]).expanduser() / media_type / anime_name
|
|
if media_type == "movie":
|
|
return library_root
|
|
return library_root / f"Season {season_label(job)}"
|
|
|
|
|
|
def job_staging_dir(job):
|
|
return STAGING_ROOT / job["id"]
|
|
|
|
|
|
def season_label(job):
|
|
return f"{int(job.get('season') or 1):02d}"
|
|
|
|
|
|
def episode_label(value):
|
|
text = str(value or "").strip()
|
|
match = re.match(r"^0*([0-9]+)(.*)$", text)
|
|
if match:
|
|
number = int(match.group(1) or "0", 10)
|
|
suffix = re.sub(r"[^0-9A-Za-z.]+", "-", match.group(2)).strip("-")
|
|
return f"{number:02d}{suffix}"
|
|
return re.sub(r"[^0-9A-Za-z.]+", "-", text).strip("-") or "00"
|
|
|
|
|
|
def apply_episode_offset(value, offset):
|
|
normalized_offset = normalize_episode_offset(offset)
|
|
text = str(value or "").strip()
|
|
if not normalized_offset or not text:
|
|
return text
|
|
match = re.match(r"^0*([0-9]+)(.*)$", text)
|
|
if not match:
|
|
return text
|
|
number = int(match.group(1) or "0", 10)
|
|
if number < 1:
|
|
return text
|
|
suffix = match.group(2) or ""
|
|
return f"{int(normalized_offset, 10) + number - 1}{suffix}"
|
|
|
|
|
|
def extract_episode_from_filename(path):
|
|
match = re.search(r"\bEpisode\s+([0-9][0-9A-Za-z.\-]*)\s*$", path.stem)
|
|
return match.group(1) if match else None
|
|
|
|
|
|
def episode_values_from_spec(value):
|
|
spec = str(value or "").strip()
|
|
if not spec:
|
|
return []
|
|
values = []
|
|
for part in re.split(r"\s+", spec):
|
|
if not part:
|
|
continue
|
|
if "-" in part:
|
|
start_text, end_text = [piece.strip() for piece in part.split("-", 1)]
|
|
if start_text.isdigit() and end_text.isdigit():
|
|
start = int(start_text, 10)
|
|
end = int(end_text, 10)
|
|
if start <= end:
|
|
values.extend(str(number) for number in range(start, end + 1))
|
|
continue
|
|
values.append(part)
|
|
return values
|
|
|
|
|
|
def unique_destination(path):
|
|
if not path.exists():
|
|
return path
|
|
counter = 2
|
|
while True:
|
|
candidate = path.with_name(f"{path.stem} ({counter}){path.suffix}")
|
|
if not candidate.exists():
|
|
return candidate
|
|
counter += 1
|
|
|
|
|
|
def finalize_library_files(job):
|
|
staging_dir = job_staging_dir(job)
|
|
target_dir = job_output_dir(job)
|
|
library_name = sanitize_path_component(job.get("anime_name") or job.get("title"))
|
|
media_type = normalize_media_type(job.get("media_type"))
|
|
season = season_label(job)
|
|
episode_offset = normalize_episode_offset(job.get("episode_offset"))
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
files = sorted(
|
|
[path for path in staging_dir.rglob("*") if path.is_file()],
|
|
key=lambda path: (path.stat().st_mtime, str(path.relative_to(staging_dir)).lower()),
|
|
)
|
|
if not files:
|
|
raise RuntimeError("No downloaded files were found in the staging folder")
|
|
|
|
moved = []
|
|
fallback_episode = 1
|
|
requested_episode_values = episode_values_from_spec(job.get("episodes"))
|
|
backend = str(job.get("download_backend") or "").strip().lower()
|
|
for source in files:
|
|
if media_type == "movie":
|
|
final_name = f"{library_name}{source.suffix}"
|
|
else:
|
|
ep = None
|
|
if backend == "anipy-cli" and requested_episode_values:
|
|
ep = requested_episode_values.pop(0)
|
|
if ep is None:
|
|
ep = extract_episode_from_filename(source)
|
|
if ep is None:
|
|
ep = str(fallback_episode)
|
|
fallback_episode += 1
|
|
ep = apply_episode_offset(ep, episode_offset)
|
|
final_name = f"{library_name} - S{season}E{episode_label(ep)}{source.suffix}"
|
|
destination = unique_destination(target_dir / final_name)
|
|
shutil.move(str(source), str(destination))
|
|
moved.append(str(destination))
|
|
|
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
return moved
|
|
|
|
|
|
def build_job(payload, config):
|
|
if not isinstance(payload, dict):
|
|
raise ValueError("Expected a JSON object")
|
|
query = str(payload.get("query") or payload.get("title") or "").strip()
|
|
title = str(payload.get("title") or query).strip()
|
|
anime_name = sanitize_path_component(payload.get("anime_name") or title)
|
|
if not query:
|
|
raise ValueError("Missing search query")
|
|
|
|
mode = str(payload.get("mode") or config["mode"]).lower()
|
|
quality = str(payload.get("quality") or config["quality"]).lower()
|
|
download_dir = str(payload.get("download_dir") or config["download_dir"]).strip()
|
|
media_type = normalize_media_type(payload.get("media_type"), default="tv")
|
|
if mode not in MODE_CHOICES:
|
|
raise ValueError("Mode must be sub or dub")
|
|
if quality not in QUALITY_CHOICES:
|
|
raise ValueError("Unknown quality")
|
|
|
|
return {
|
|
"id": uuid4().hex,
|
|
"show_id": str(payload.get("show_id") or "").strip(),
|
|
"title": title,
|
|
"anime_name": anime_name,
|
|
"media_type": media_type,
|
|
"season": normalize_season(payload.get("season")),
|
|
"episode_offset": normalize_episode_offset(payload.get("episode_offset")),
|
|
"query": query,
|
|
"result_index": None,
|
|
"mode": mode,
|
|
"quality": quality,
|
|
"episodes": validate_episode_spec(payload.get("episodes")),
|
|
"download_dir": str(Path(download_dir).expanduser()),
|
|
"status": "pending",
|
|
"created_at": now_iso(),
|
|
"updated_at": now_iso(),
|
|
"started_at": None,
|
|
"finished_at": None,
|
|
"exit_code": None,
|
|
"pid": None,
|
|
"log": [],
|
|
}
|
|
|
|
|
|
def anipy_search_spec(job):
|
|
query = re.sub(r"\s+", " ", str(job.get("query") or "").replace(":", " ")).strip()
|
|
mode = "dub" if str(job.get("mode") or "").strip().lower() == "dub" else "sub"
|
|
return f"{query}:{job['episodes']}:{mode}"
|
|
|
|
|
|
def command_for_job(job, backend="ani-cli", download_path=None):
|
|
normalized_backend = str(backend or "ani-cli").strip().lower()
|
|
if normalized_backend == "anipy-cli":
|
|
command = [
|
|
ANIPY_CLI,
|
|
"-D",
|
|
"-s",
|
|
anipy_search_spec(job),
|
|
"-q",
|
|
job["quality"],
|
|
]
|
|
if download_path:
|
|
command.extend(["-l", str(download_path)])
|
|
return command
|
|
command = [
|
|
ANI_CLI,
|
|
"-d",
|
|
"-q",
|
|
job["quality"],
|
|
"-e",
|
|
job["episodes"],
|
|
]
|
|
if job["mode"] == "dub":
|
|
command.append("--dub")
|
|
command.append(job["query"])
|
|
return command
|