778 lines
30 KiB
Python
778 lines
30 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.40.4"
|
|
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,
|
|
"auto_download_enabled": False,
|
|
"auto_download_mode": "dub",
|
|
"auto_download_quality": "best",
|
|
"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")
|
|
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]
|
|
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)
|
|
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)
|
|
|
|
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"
|
|
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
|