1169 lines
44 KiB
Python
1169 lines
44 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"
|
|
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"),
|
|
"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"])
|
|
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(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["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 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 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.iterdir() if path.is_file()],
|
|
key=lambda path: (path.stat().st_mtime, path.name),
|
|
)
|
|
if not files:
|
|
raise RuntimeError("No downloaded files were found in the staging folder")
|
|
|
|
moved = []
|
|
fallback_episode = 1
|
|
for source in files:
|
|
if media_type == "movie":
|
|
final_name = f"{library_name}{source.suffix}"
|
|
else:
|
|
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))
|
|
|
|
try:
|
|
staging_dir.rmdir()
|
|
except OSError:
|
|
pass
|
|
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 command_for_job(job):
|
|
command = [
|
|
ANI_CLI,
|
|
"-d",
|
|
"-q",
|
|
job["quality"],
|
|
"-e",
|
|
job["episodes"],
|
|
]
|
|
if job["mode"] == "dub":
|
|
command.append("--dub")
|
|
command.append(job["query"])
|
|
return command
|