Files
ani-cli-web/app_support.py
T
2026-05-23 19:14:34 +02:00

763 lines
29 KiB
Python

#!/usr/bin/env python3
"""Shared constants and helper utilities for ani-cli-web."""
import hmac
import ipaddress
import json
import mimetypes
import os
import re
import shutil
from datetime import datetime, timezone
from pathlib import Path
import urllib.error
import urllib.request
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 = "0.37.1"
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"}
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",
"watchlist_refresh_new_episodes",
"watchlist_refresh_failed",
"watchlist_refresh_interrupted",
"runtime_error",
)
DISCORD_WEBHOOK_EVENT_LABELS = {
"download_completed": "Download completed",
"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",
}
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_token():
return str(os.environ.get("ANI_CLI_WEB_REMOTE_TOKEN") or "").strip()
def remote_access_token_configured():
return bool(remote_access_token())
def remote_access_token_matches(value):
provided = str(value or "").strip()
expected = remote_access_token()
return bool(expected and provided and hmac.compare_digest(provided, expected))
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,
"discord_webhook_url": "",
"discord_webhook_events": [],
}
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")
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")
webhook_url = str(config.get("discord_webhook_url") or "").strip()
webhook_events = config.get("discord_webhook_events")
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)
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
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
return config
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 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 = 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)}")
description_lines.extend(str(path).strip() for path in moved_files[:5] if str(path).strip())
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}"}
request_payload = {
"content": "ani-cli-web finished a download.",
"embeds": [embed],
}
_post_discord_webhook(webhook_url, request_payload, attachments=attachments)
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 job_output_dir(job):
anime_name = sanitize_path_component(job.get("anime_name") or job.get("title"))
return Path(job["download_dir"]).expanduser() / anime_name / 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 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"))
season = season_label(job)
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:
ep = extract_episode_from_filename(source)
if ep is None:
ep = str(fallback_episode)
fallback_episode += 1
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")
try:
index = int(payload.get("index", 1))
except (TypeError, ValueError) as exc:
raise ValueError("Invalid result index") from exc
if index < 1:
raise ValueError("Result index must be positive")
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()
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,
"season": normalize_season(payload.get("season")),
"query": query,
"result_index": index,
"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"],
"-S",
str(job["result_index"]),
]
if job["mode"] == "dub":
command.append("--dub")
command.append(job["query"])
return command