Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1696d5c65b | |||
| c8b386f47a | |||
| 3888da352a | |||
| 06e104db42 | |||
| d4594bd1d9 | |||
| d866e836f6 | |||
| 195dae13cb | |||
| 51be777e7d | |||
| 7930e49701 | |||
| 75c22fe296 | |||
| 7bcd0600d5 | |||
| a333329ae2 | |||
| 363f7899f8 | |||
| a08a8f7408 | |||
| 54ac5e9ab7 | |||
| c93ac3e7b8 | |||
| 68c4335348 | |||
| be87f2e230 | |||
| c56e0f507d |
@@ -1 +1 @@
|
||||
v1.1.1
|
||||
v1.1.9
|
||||
|
||||
@@ -72,9 +72,11 @@ setup_killswitch() {
|
||||
iptables -A INPUT -i "$INTERFACE" -j ACCEPT
|
||||
iptables -A OUTPUT -o "$INTERFACE" -j ACCEPT
|
||||
|
||||
# Allow DNS to the VPN DNS server (through wg0)
|
||||
iptables -A OUTPUT -o "$INTERFACE" -p udp --dport 53 -j ACCEPT
|
||||
iptables -A OUTPUT -o "$INTERFACE" -p tcp --dport 53 -j ACCEPT
|
||||
# Allow DNS (VPN DNS servers are routed through wg0; allow before routing decision)
|
||||
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
|
||||
iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT
|
||||
iptables -A INPUT -p udp --sport 53 -j ACCEPT
|
||||
iptables -A INPUT -p tcp --sport 53 -j ACCEPT
|
||||
|
||||
# Allow DHCP (for container networking)
|
||||
iptables -A OUTPUT -p udp --dport 67:68 -j ACCEPT
|
||||
@@ -128,46 +130,46 @@ start_vpn() {
|
||||
ip link add "$INTERFACE" type wireguard
|
||||
|
||||
# Apply the WireGuard config (keys, peer, endpoint)
|
||||
# We filter out Address/DNS/MTU/PreUp/PostUp/PreDown/PostDown/SaveConfig
|
||||
# AllowedIPs is kept because WireGuard needs it to know which traffic to tunnel.
|
||||
# We remove the auto-created default route afterwards and set our own.
|
||||
wg setconf "$INTERFACE" <(grep -v -i '^\(Address\|DNS\|MTU\|PreUp\|PostUp\|PreDown\|PostDown\|SaveConfig\)' "$CONFIG_FILE")
|
||||
# Filter out wg-quick directives that wg setconf doesn't understand
|
||||
wg setconf "$INTERFACE" <(grep -v -i '^\(Address\|DNS\|MTU\|Table\|PreUp\|PostUp\|PreDown\|PostDown\|SaveConfig\)' "$CONFIG_FILE")
|
||||
|
||||
# Log public key so it can be verified against the server's peer list
|
||||
local PUBKEY
|
||||
PUBKEY=$(wg show "$INTERFACE" public-key 2>/dev/null || echo "unknown")
|
||||
echo "[vpn] Public key: ${PUBKEY}"
|
||||
|
||||
# Assign the address
|
||||
ip -4 address add "$VPN_ADDRESS" dev "$INTERFACE"
|
||||
|
||||
# Set MTU
|
||||
# Set MTU and bring up
|
||||
ip link set mtu 1420 up dev "$INTERFACE"
|
||||
|
||||
# Remove the auto-created default route by wg setconf (if AllowedIPs = 0.0.0.0/0)
|
||||
# We set our own routes manually to avoid breaking the endpoint connection
|
||||
# ── fwmark-based routing (mirrors wg-quick behavior) ──
|
||||
# WireGuard marks its own encapsulated UDP packets with this fwmark.
|
||||
# Policy rules then ensure:
|
||||
# - Normal packets (no mark) → VPN routing table → wg0
|
||||
# - WireGuard-encapsulated packets (marked) → main table → eth0
|
||||
local FW_MARK=51820
|
||||
local FW_TABLE=51820
|
||||
wg set "$INTERFACE" fwmark "$FW_MARK"
|
||||
|
||||
# Remove any auto-created default route on wg0
|
||||
ip route del default dev "$INTERFACE" 2>/dev/null || true
|
||||
|
||||
# Find default gateway/interface for the endpoint route
|
||||
# VPN routing table: send everything through the tunnel
|
||||
ip -4 route add default dev "$INTERFACE" table "$FW_TABLE"
|
||||
|
||||
# Policy rules:
|
||||
# 1. Packets NOT marked by WireGuard use the VPN table (→ wg0)
|
||||
# 2. suppress_prefixlength 0: ignore bare default routes in main table,
|
||||
# but keep more-specific routes (e.g. LAN, endpoint) working
|
||||
ip -4 rule add not fwmark "$FW_MARK" table "$FW_TABLE"
|
||||
ip -4 rule add table main suppress_prefixlength 0
|
||||
|
||||
# Find default gateway/interface
|
||||
DEFAULT_GW=$(ip route | grep '^default' | head -1 | awk '{print $3}')
|
||||
DEFAULT_IF=$(ip route | grep '^default' | head -1 | awk '{print $5}')
|
||||
|
||||
# Route VPN endpoint through the container's default gateway
|
||||
if [ -n "$DEFAULT_GW" ] && [ -n "$DEFAULT_IF" ]; then
|
||||
ip route add "$VPN_ENDPOINT/32" via "$DEFAULT_GW" dev "$DEFAULT_IF" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Parse AllowedIPs from config and add routes dynamically
|
||||
ALLOWED_IPS=$(grep -i '^AllowedIPs' "$CONFIG_FILE" | head -1 | sed 's/.*= *//;s/ //g')
|
||||
|
||||
if [ -n "$ALLOWED_IPS" ]; then
|
||||
for ip in $(echo "$ALLOWED_IPS" | tr ',' ' '); do
|
||||
if [ "$ip" = "0.0.0.0/0" ]; then
|
||||
# Use the split route trick to avoid overriding the default route
|
||||
# (which would break the endpoint connection)
|
||||
ip route add 0.0.0.0/1 dev "$INTERFACE" 2>/dev/null || true
|
||||
ip route add 128.0.0.0/1 dev "$INTERFACE" 2>/dev/null || true
|
||||
else
|
||||
ip route add "$ip" dev "$INTERFACE" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Policy routing: ensure responses to incoming LAN traffic go back via eth0 ──
|
||||
if [ -n "$DEFAULT_GW" ] && [ -n "$DEFAULT_IF" ]; then
|
||||
# Get the container's eth0 IP address (BusyBox-compatible, no grep -P)
|
||||
@@ -194,6 +196,14 @@ start_vpn() {
|
||||
fi
|
||||
|
||||
echo "[vpn] WireGuard interface ${INTERFACE} is up."
|
||||
echo "[vpn] Main routes:"
|
||||
ip route show | sed 's/^/[vpn] /'
|
||||
echo "[vpn] VPN table ($FW_TABLE):"
|
||||
ip route show table "$FW_TABLE" 2>/dev/null | sed 's/^/[vpn] /'
|
||||
echo "[vpn] Policy rules:"
|
||||
ip rule show | sed 's/^/[vpn] /'
|
||||
echo "[vpn] WireGuard status:"
|
||||
wg show "$INTERFACE" 2>/dev/null | sed 's/^/[vpn] /'
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -202,23 +212,19 @@ start_vpn() {
|
||||
stop_vpn() {
|
||||
echo "[vpn] Stopping WireGuard interface ${INTERFACE}..."
|
||||
|
||||
# Remove routes added for AllowedIPs
|
||||
ALLOWED_IPS=$(grep -i '^AllowedIPs' "$CONFIG_FILE" | head -1 | sed 's/.*= *//;s/ //g')
|
||||
if [ -n "$ALLOWED_IPS" ]; then
|
||||
for ip in $(echo "$ALLOWED_IPS" | tr ',' ' '); do
|
||||
if [ "$ip" = "0.0.0.0/0" ]; then
|
||||
ip route del 0.0.0.0/1 dev "$INTERFACE" 2>/dev/null || true
|
||||
ip route del 128.0.0.0/1 dev "$INTERFACE" 2>/dev/null || true
|
||||
else
|
||||
ip route del "$ip" dev "$INTERFACE" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
local FW_MARK=51820
|
||||
local FW_TABLE=51820
|
||||
|
||||
# Remove endpoint route
|
||||
if [ -n "$VPN_ENDPOINT" ]; then
|
||||
ip route del "$VPN_ENDPOINT/32" 2>/dev/null || true
|
||||
fi
|
||||
# Remove fwmark-based policy rules
|
||||
ip -4 rule del not fwmark "$FW_MARK" table "$FW_TABLE" 2>/dev/null || true
|
||||
ip -4 rule del table main suppress_prefixlength 0 2>/dev/null || true
|
||||
|
||||
# Flush VPN routing table
|
||||
ip -4 route flush table "$FW_TABLE" 2>/dev/null || true
|
||||
|
||||
# Remove LAN policy routing
|
||||
ip -4 rule del table 100 2>/dev/null || true
|
||||
ip -4 route flush table 100 2>/dev/null || true
|
||||
|
||||
ip link del "$INTERFACE" 2>/dev/null || true
|
||||
}
|
||||
@@ -235,14 +241,19 @@ health_loop() {
|
||||
while true; do
|
||||
sleep "$CHECK_INTERVAL"
|
||||
|
||||
if curl -sf --max-time 5 "http://$CHECK_HOST" > /dev/null 2>&1; then
|
||||
if ping -c 1 -W 5 "$CHECK_HOST" > /dev/null 2>&1; then
|
||||
if [ "$failures" -gt 0 ]; then
|
||||
echo "[health] VPN recovered."
|
||||
failures=0
|
||||
fi
|
||||
else
|
||||
failures=$((failures + 1))
|
||||
echo "[health] Ping failed ($failures/$max_failures)"
|
||||
echo "[health] Check failed ($failures/$max_failures) — ping ${CHECK_HOST} failed"
|
||||
# Dump WireGuard stats to show if handshake is stale and how much data flows
|
||||
echo "[health] wg stats:"
|
||||
wg show "$INTERFACE" 2>/dev/null | grep -E 'latest handshake|transfer|endpoint' | sed 's/^/[health] /' || echo "[health] wg0 not found"
|
||||
echo "[health] routes:"
|
||||
ip route show | grep -E 'wg0|default' | sed 's/^/[health] /'
|
||||
|
||||
if [ "$failures" -ge "$max_failures" ]; then
|
||||
echo "[health] VPN appears down. Restarting WireGuard..."
|
||||
@@ -271,8 +282,81 @@ cleanup() {
|
||||
|
||||
trap cleanup SIGTERM SIGINT
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Startup connectivity checks — diagnose issues early
|
||||
# ──────────────────────────────────────────────
|
||||
check_vpn_connectivity() {
|
||||
echo "[check] ── Startup connectivity checks ──"
|
||||
|
||||
# 1. Wait for WireGuard handshake (up to 15s)
|
||||
local elapsed=0
|
||||
local handshake_ts=0
|
||||
echo "[check] Waiting for WireGuard handshake (up to 15s)..."
|
||||
while [ "$elapsed" -lt 15 ]; do
|
||||
handshake_ts=$(wg show "$INTERFACE" latest-handshakes 2>/dev/null | awk '{print $2}' | head -1)
|
||||
if [ -n "$handshake_ts" ] && [ "$handshake_ts" != "0" ]; then
|
||||
local age=$(( $(date +%s) - handshake_ts ))
|
||||
echo "[check] OK Handshake established ${age}s ago"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
if [ "$elapsed" -ge 15 ]; then
|
||||
echo "[check] FAIL No WireGuard handshake after 15s — tunnel is not up"
|
||||
echo "[check] This container's public key (must be on the server):"
|
||||
echo "[check] PublicKey = $(wg show "$INTERFACE" public-key 2>/dev/null || echo 'unknown')"
|
||||
echo "[check] AllowedIPs = ${VPN_ADDRESS}"
|
||||
echo "[check] Verify on server: wg show"
|
||||
fi
|
||||
|
||||
# 2. Check whether traffic actually flows through the tunnel
|
||||
echo "[check] Testing traffic through tunnel (ping ${CHECK_HOST})..."
|
||||
local rx_before
|
||||
rx_before=$(wg show "$INTERFACE" transfer 2>/dev/null | awk '{print $2}' | head -1)
|
||||
|
||||
if ping -c 1 -W 8 "${CHECK_HOST}" > /dev/null 2>&1; then
|
||||
echo "[check] OK Traffic flows — tunnel is fully working"
|
||||
else
|
||||
local rx_after
|
||||
rx_after=$(wg show "$INTERFACE" transfer 2>/dev/null | awk '{print $2}' | head -1)
|
||||
echo "[check] FAIL ping ${CHECK_HOST} unreachable through tunnel"
|
||||
|
||||
if [ -n "$rx_before" ] && [ -n "$rx_after" ]; then
|
||||
if [ "$rx_after" -le "$rx_before" ]; then
|
||||
echo "[check] RX bytes unchanged (${rx_before} → ${rx_after})"
|
||||
echo "[check] Server receives packets but does NOT route them back"
|
||||
echo "[check] Fix on VPN server (${VPN_ENDPOINT}):"
|
||||
echo "[check] sysctl net.ipv4.ip_forward # must output 1"
|
||||
echo "[check] iptables -t nat -L POSTROUTING -v -n # must have MASQUERADE"
|
||||
echo "[check] wg show # check peer + AllowedIPs"
|
||||
else
|
||||
echo "[check] RX increased (${rx_before} → ${rx_after}) — tunnel passes data"
|
||||
echo "[check] Issue may be specific to ${CHECK_HOST} or DNS"
|
||||
fi
|
||||
fi
|
||||
|
||||
local transfer
|
||||
transfer=$(wg show "$INTERFACE" transfer 2>/dev/null | awk '{printf "rx=%s tx=%s", $2, $3}')
|
||||
echo "[check] wg transfer: ${transfer}"
|
||||
fi
|
||||
|
||||
# 3. DNS check
|
||||
echo "[check] Testing DNS resolution..."
|
||||
if nslookup 1.1.1.1 > /dev/null 2>&1 || nslookup google.com > /dev/null 2>&1; then
|
||||
echo "[check] OK DNS resolves"
|
||||
else
|
||||
echo "[check] FAIL DNS resolution failed"
|
||||
echo "[check] resolv.conf: $(tr '\n' ' ' < /etc/resolv.conf)"
|
||||
echo "[check] Check that DNS servers are reachable through wg0"
|
||||
fi
|
||||
|
||||
echo "[check] ── End of checks ──"
|
||||
}
|
||||
|
||||
# ── Main ──
|
||||
enable_forwarding
|
||||
setup_killswitch
|
||||
start_vpn
|
||||
check_vpn_connectivity
|
||||
health_loop
|
||||
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- SYS_MODULE
|
||||
- NET_RAW
|
||||
sysctls:
|
||||
- net.ipv4.ip_forward=1
|
||||
- net.ipv4.conf.all.src_valid_mark=1
|
||||
|
||||
@@ -117,7 +117,7 @@ bash "${SCRIPT_DIR}/push.sh" "${TARGET}"
|
||||
# ---------------------------------------------------------------------------
|
||||
cd "${SCRIPT_DIR}/.."
|
||||
git add Docker/VERSION package.json pyproject.toml
|
||||
git commit -m "chore: release ${NEW_TAG}"
|
||||
git commit -m "chore: bump version"
|
||||
git tag -a "${NEW_TAG}" -m "Release ${NEW_TAG}"
|
||||
echo "Local git commit + tag ${NEW_TAG} created."
|
||||
|
||||
|
||||
@@ -13,4 +13,5 @@ PostDown = ip route del 192.168.178.0/24 via 192.168.178.1 dev wlp4s0f0
|
||||
PublicKey = KgTUh3KLijVluDvNpzDCJJfrJ7EyLzYLmdHCksG4sRg=
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 91.148.236.64:51820
|
||||
PersistentKeepalive = 25
|
||||
|
||||
|
||||
47
docs/key
47
docs/key
@@ -2,3 +2,50 @@ API key : 299ae8f630a31bda814263c551361448
|
||||
|
||||
/mnt/server/serien/Serien/
|
||||
|
||||
{
|
||||
"name": "Aniworld",
|
||||
"data_dir": "data",
|
||||
"scheduler": {
|
||||
"enabled": true,
|
||||
"interval_minutes": 60,
|
||||
"schedule_time": "03:00",
|
||||
"schedule_days": [
|
||||
"mon",
|
||||
"tue",
|
||||
"wed",
|
||||
"thu",
|
||||
"fri",
|
||||
"sat",
|
||||
"sun"
|
||||
],
|
||||
"auto_download_after_rescan": true,
|
||||
"folder_scan_enabled": true
|
||||
},
|
||||
"logging": {
|
||||
"level": "INFO",
|
||||
"file": null,
|
||||
"max_bytes": null,
|
||||
"backup_count": 3
|
||||
},
|
||||
"backup": {
|
||||
"enabled": false,
|
||||
"path": "data/backups",
|
||||
"keep_days": 30
|
||||
},
|
||||
"nfo": {
|
||||
"tmdb_api_key": "9bc3e547caff878615cbdba2cc421d37",
|
||||
"auto_create": true,
|
||||
"update_on_scan": true,
|
||||
"download_poster": true,
|
||||
"download_logo": true,
|
||||
"download_fanart": true,
|
||||
"image_size": "original"
|
||||
},
|
||||
"other": {
|
||||
"master_password_hash": "$pbkdf2-sha256$29000$HQNASKk1xpgTAgAgJGRMaQ$73TOCCM0UEZONyNXQEPa3SmIoXeG6C1l5mMFDNgYfMQ",
|
||||
"anime_directory": "/data"
|
||||
},
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aniworld-web",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.9",
|
||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -445,9 +445,12 @@ class SeriesApp:
|
||||
try:
|
||||
def download_progress_handler(progress_info):
|
||||
"""Handle download progress events from loader."""
|
||||
logger.debug(
|
||||
"download_progress_handler called with: %s", progress_info
|
||||
)
|
||||
# Throttle progress logging to avoid spam
|
||||
status = progress_info.get("status", "")
|
||||
if status in ("downloading", "finished"):
|
||||
logger.debug(
|
||||
"download_progress_handler called with: %s", progress_info
|
||||
)
|
||||
|
||||
downloaded = progress_info.get('downloaded_bytes', 0)
|
||||
total_bytes = (
|
||||
|
||||
@@ -271,7 +271,11 @@ class Serie:
|
||||
'Dororo (2025)'
|
||||
"""
|
||||
if self._year:
|
||||
return f"{self._name} ({self._year})"
|
||||
import re
|
||||
year_suffix = f" ({self._year})"
|
||||
# Strip ALL trailing year suffixes before appending to prevent duplication
|
||||
clean_name = re.sub(r'(\s*\(\d{4}\))+\s*$', '', self._name).strip()
|
||||
return f"{clean_name}{year_suffix}"
|
||||
return self._name
|
||||
|
||||
@property
|
||||
|
||||
@@ -331,6 +331,7 @@ class AniworldLoader(Loader):
|
||||
'no_warnings': True,
|
||||
'progress_with_newline': False,
|
||||
'nocheckcertificate': True,
|
||||
'logger': logger,
|
||||
'progress_hooks': [events_progress_hook],
|
||||
}
|
||||
|
||||
@@ -339,7 +340,7 @@ class AniworldLoader(Loader):
|
||||
logger.debug("Using custom headers for download")
|
||||
|
||||
try:
|
||||
logger.debug("Starting YoutubeDL download")
|
||||
logger.info("Starting download: %s", output_file)
|
||||
logger.debug("Download link: %s...", link[:100])
|
||||
logger.debug("YDL options: %s", ydl_opts)
|
||||
|
||||
|
||||
@@ -566,6 +566,7 @@ class EnhancedAniWorldLoader(Loader):
|
||||
"nocheckcertificate": True,
|
||||
"socket_timeout": self.download_timeout,
|
||||
"http_chunk_size": 1024 * 1024, # 1MB chunks
|
||||
"logger": self.logger,
|
||||
}
|
||||
if headers:
|
||||
ydl_opts['http_headers'] = headers
|
||||
|
||||
@@ -120,6 +120,37 @@ def nfo_needs_repair(nfo_path: Path) -> bool:
|
||||
return bool(find_missing_tags(nfo_path))
|
||||
|
||||
|
||||
def _read_tmdb_id(nfo_path: Path) -> int | None:
|
||||
"""Return the TMDB ID stored in an existing NFO, or ``None``.
|
||||
|
||||
Checks both ``<tmdbid>`` and ``<uniqueid type="tmdb">`` elements.
|
||||
|
||||
Args:
|
||||
nfo_path: Absolute path to the ``tvshow.nfo`` file.
|
||||
|
||||
Returns:
|
||||
Integer TMDB ID, or ``None`` if not found or not parseable.
|
||||
"""
|
||||
if not nfo_path.exists():
|
||||
return None
|
||||
try:
|
||||
root = etree.parse(str(nfo_path)).getroot()
|
||||
|
||||
for uniqueid in root.findall(".//uniqueid"):
|
||||
if uniqueid.get("type") == "tmdb" and uniqueid.text:
|
||||
return int(uniqueid.text)
|
||||
|
||||
tmdbid_elem = root.find(".//tmdbid")
|
||||
if tmdbid_elem is not None and tmdbid_elem.text:
|
||||
return int(tmdbid_elem.text)
|
||||
|
||||
except (etree.XMLSyntaxError, ValueError):
|
||||
pass
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class NfoRepairService:
|
||||
"""Service that detects and repairs incomplete tvshow.nfo files.
|
||||
|
||||
|
||||
@@ -83,11 +83,12 @@ class NFOService:
|
||||
>>> _extract_year_from_name("Attack on Titan")
|
||||
("Attack on Titan", None)
|
||||
"""
|
||||
# Match year in parentheses at the end: (YYYY)
|
||||
# Match the last year in parentheses at the end: (YYYY)
|
||||
match = re.search(r'\((\d{4})\)\s*$', serie_name)
|
||||
if match:
|
||||
year = int(match.group(1))
|
||||
clean_name = serie_name[:match.start()].strip()
|
||||
# Strip ALL trailing year suffixes to get a fully clean name
|
||||
clean_name = re.sub(r'(\s*\(\d{4}\))+\s*$', '', serie_name).strip()
|
||||
return clean_name, year
|
||||
return serie_name, None
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ Example:
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -63,6 +64,11 @@ class TMDBClient:
|
||||
self.max_connections = max_connections
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
self._cache: Dict[str, Any] = {}
|
||||
# TMDB allows ~40 req/s; use 30 concurrent + per-second throttle to stay safe
|
||||
self._semaphore = asyncio.Semaphore(30)
|
||||
self._rate_limit_lock = asyncio.Lock()
|
||||
self._request_timestamps: List[float] = []
|
||||
self._max_requests_per_second = 35 # Stay under TMDB's ~40/s limit
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
@@ -83,7 +89,7 @@ class TMDBClient:
|
||||
self,
|
||||
endpoint: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
max_retries: int = 3
|
||||
max_retries: int = 5
|
||||
) -> Dict[str, Any]:
|
||||
"""Make an async request to TMDB API with retries.
|
||||
|
||||
@@ -110,58 +116,82 @@ class TMDBClient:
|
||||
logger.debug("Cache hit for %s", endpoint)
|
||||
return self._cache[cache_key]
|
||||
|
||||
delay = 1
|
||||
delay = 2
|
||||
last_error = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Re-ensure session before each attempt in case it was closed
|
||||
await self._ensure_session()
|
||||
|
||||
if self.session is None:
|
||||
raise TMDBAPIError("Session is not available")
|
||||
|
||||
logger.debug("TMDB API request: %s (attempt %s)", endpoint, attempt + 1)
|
||||
async with self.session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
|
||||
if resp.status == 401:
|
||||
raise TMDBAPIError("Invalid TMDB API key")
|
||||
elif resp.status == 404:
|
||||
raise TMDBAPIError(f"Resource not found: {endpoint}")
|
||||
elif resp.status == 429:
|
||||
# Rate limit - wait longer
|
||||
retry_after = int(resp.headers.get('Retry-After', delay * 2))
|
||||
logger.warning("Rate limited, waiting %ss", retry_after)
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
self._cache[cache_key] = data
|
||||
return data
|
||||
|
||||
except asyncio.TimeoutError as e:
|
||||
last_error = e
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning("Request timeout (attempt %s), retrying in %ss", attempt + 1, delay)
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
else:
|
||||
logger.error("Request timed out after %s attempts", max_retries)
|
||||
|
||||
except (aiohttp.ClientError, AttributeError) as e:
|
||||
last_error = e
|
||||
# If connector/session was closed, try to recreate it
|
||||
if "Connector is closed" in str(e) or isinstance(e, AttributeError):
|
||||
logger.warning("Session issue detected, recreating session: %s", e)
|
||||
self.session = None
|
||||
# Rate limiting: ensure we don't exceed ~35 requests/second
|
||||
async with self._rate_limit_lock:
|
||||
now = time.monotonic()
|
||||
# Remove timestamps older than 1 second
|
||||
self._request_timestamps = [
|
||||
ts for ts in self._request_timestamps if now - ts < 1.0
|
||||
]
|
||||
if len(self._request_timestamps) >= self._max_requests_per_second:
|
||||
sleep_time = 1.0 - (now - self._request_timestamps[0])
|
||||
if sleep_time > 0:
|
||||
logger.debug("Rate throttling: waiting %.2fs", sleep_time)
|
||||
await asyncio.sleep(sleep_time)
|
||||
self._request_timestamps.append(time.monotonic())
|
||||
|
||||
async with self._semaphore:
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Re-ensure session before each attempt in case it was closed
|
||||
await self._ensure_session()
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning("Request failed (attempt %s): %s, retrying in %ss", attempt + 1, e, delay)
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
else:
|
||||
logger.error("Request failed after %s attempts: %s", max_retries, e)
|
||||
|
||||
if self.session is None:
|
||||
raise TMDBAPIError("Session is not available")
|
||||
|
||||
logger.debug("TMDB API request: %s (attempt %s)", endpoint, attempt + 1)
|
||||
async with self.session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
|
||||
if resp.status == 401:
|
||||
raise TMDBAPIError("Invalid TMDB API key")
|
||||
elif resp.status == 404:
|
||||
raise TMDBAPIError(f"Resource not found: {endpoint}")
|
||||
elif resp.status == 429:
|
||||
# Rate limit - wait longer with exponential backoff
|
||||
retry_after = int(resp.headers.get('Retry-After', max(delay * 2, 10)))
|
||||
logger.warning("Rate limited, waiting %ss", retry_after)
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
self._cache[cache_key] = data
|
||||
return data
|
||||
|
||||
except asyncio.TimeoutError as e:
|
||||
last_error = e
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning("Request timeout (attempt %s), retrying in %ss", attempt + 1, delay)
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, 30)
|
||||
else:
|
||||
logger.error("Request timed out after %s attempts", max_retries)
|
||||
|
||||
except (aiohttp.ClientError, AttributeError) as e:
|
||||
last_error = e
|
||||
# If connector/session was closed, try to recreate it
|
||||
if "Connector is closed" in str(e) or isinstance(e, AttributeError):
|
||||
logger.warning("Session issue detected, recreating session: %s", e)
|
||||
self.session = None
|
||||
await self._ensure_session()
|
||||
|
||||
# DNS / host-unreachable errors are not transient — abort immediately
|
||||
error_str = str(e)
|
||||
if "name resolution" in error_str.lower() or (
|
||||
isinstance(e, aiohttp.ClientConnectorError) and
|
||||
"Cannot connect to host" in error_str
|
||||
):
|
||||
logger.error("Non-transient connection error, aborting retries: %s", e)
|
||||
raise TMDBAPIError(f"Request failed after {attempt + 1} attempts: {e}") from e
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning("Request failed (attempt %s): %s, retrying in %ss", attempt + 1, e, delay)
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, 30)
|
||||
else:
|
||||
logger.error("Request failed after %s attempts: %s", max_retries, e)
|
||||
|
||||
raise TMDBAPIError(f"Request failed after {max_retries} attempts: {last_error}")
|
||||
|
||||
|
||||
@@ -730,7 +730,11 @@ async def add_series(
|
||||
|
||||
# Create folder name with year if available
|
||||
if year:
|
||||
folder_name_with_year = f"{name} ({year})"
|
||||
year_suffix = f" ({year})"
|
||||
if name.endswith(year_suffix):
|
||||
folder_name_with_year = name
|
||||
else:
|
||||
folder_name_with_year = f"{name}{year_suffix}"
|
||||
else:
|
||||
folder_name_with_year = name
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ def get_settings() -> Union[DevelopmentSettings, ProductionSettings]:
|
||||
Example:
|
||||
>>> settings = get_settings()
|
||||
>>> print(settings.log_level)
|
||||
DEBUG
|
||||
INFO
|
||||
"""
|
||||
if ENVIRONMENT in {"development", "testing"}:
|
||||
return get_development_settings()
|
||||
|
||||
@@ -215,7 +215,7 @@ class DevelopmentSettings(BaseSettings):
|
||||
@property
|
||||
def debug_enabled(self) -> bool:
|
||||
"""Check if debug mode is enabled."""
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def reload_enabled(self) -> bool:
|
||||
|
||||
@@ -66,6 +66,9 @@ def _parse_nfo_title_and_year(nfo_path: Path) -> Tuple[Optional[str], Optional[s
|
||||
def _compute_expected_folder_name(title: str, year: str) -> str:
|
||||
"""Compute the expected folder name from title and year.
|
||||
|
||||
Removes any existing year suffixes (e.g., "(2021)") before adding the
|
||||
canonical one to prevent duplication across multiple folder rename runs.
|
||||
|
||||
Args:
|
||||
title: Series title from NFO.
|
||||
year: Release year from NFO.
|
||||
@@ -73,7 +76,15 @@ def _compute_expected_folder_name(title: str, year: str) -> str:
|
||||
Returns:
|
||||
Sanitised folder name in the format ``"{title} ({year})"``.
|
||||
"""
|
||||
raw_name = f"{title} ({year})"
|
||||
import re
|
||||
|
||||
# Remove all trailing year suffixes to prevent duplication.
|
||||
# This handles cases where the title already contains one or more years.
|
||||
# Regex pattern: matches one or more " (YYYY)" at the end of the string
|
||||
clean_title = re.sub(r'(\s*\(\d{4}\))+\s*$', '', title).strip()
|
||||
|
||||
year_suffix = f" ({year})"
|
||||
raw_name = f"{clean_title}{year_suffix}"
|
||||
return sanitize_folder_name(raw_name)
|
||||
|
||||
|
||||
|
||||
@@ -334,6 +334,25 @@ async def test_add_series_sanitizes_folder_name(authenticated_client):
|
||||
assert "?" not in folder
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_series_does_not_duplicate_year(authenticated_client):
|
||||
"""Test that add_series doesn't duplicate year when name already contains it."""
|
||||
response = await authenticated_client.post(
|
||||
"/api/anime/add",
|
||||
json={
|
||||
"link": "https://aniworld.to/anime/stream/eighty-six",
|
||||
"name": "86 Eighty Six (2021)"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
|
||||
# Folder should contain year only once
|
||||
folder = data["folder"]
|
||||
assert folder.count("(2021)") == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_series_returns_missing_episodes(authenticated_client):
|
||||
"""Test that add_series returns loading progress info."""
|
||||
|
||||
314
tests/integration/test_add_anime_nfo_content.py
Normal file
314
tests/integration/test_add_anime_nfo_content.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""Integration test: add an anime and verify NFO contains required information.
|
||||
|
||||
This test adds 'Sacrificial Princess And The King Of Beasts' and verifies
|
||||
that the generated tvshow.nfo contains all required tags including plot,
|
||||
outline, title, year, etc.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from lxml import etree
|
||||
|
||||
from src.core.services.nfo_service import NFOService
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock TMDB data for "Sacrificial Princess And The King Of Beasts"
|
||||
# ---------------------------------------------------------------------------
|
||||
MOCK_TMDB_DATA = {
|
||||
"id": 222093,
|
||||
"name": "Sacrificial Princess and the King of Beasts",
|
||||
"original_name": "贄姫と獣の王",
|
||||
"overview": (
|
||||
"A girl is offered as a sacrifice to a beastly king, "
|
||||
"but instead of being eaten, she becomes his bride."
|
||||
),
|
||||
"tagline": "A tale of love between a sacrifice and a beast king.",
|
||||
"first_air_date": "2023-04-20",
|
||||
"vote_average": 7.5,
|
||||
"vote_count": 150,
|
||||
"status": "Ended",
|
||||
"episode_run_time": [24],
|
||||
"genres": [
|
||||
{"id": 16, "name": "Animation"},
|
||||
{"id": 10749, "name": "Romance"},
|
||||
],
|
||||
"networks": [{"id": 1, "name": "TBS"}],
|
||||
"origin_country": ["JP"],
|
||||
"poster_path": "/poster.jpg",
|
||||
"backdrop_path": "/backdrop.jpg",
|
||||
"external_ids": {"imdb_id": "tt19896734", "tvdb_id": 421737},
|
||||
"credits": {
|
||||
"cast": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Test Actor",
|
||||
"character": "Sariphi",
|
||||
"profile_path": "/actor.jpg",
|
||||
}
|
||||
]
|
||||
},
|
||||
"images": {"logos": [{"file_path": "/logo.png"}]},
|
||||
"seasons": [{"season_number": 1, "name": "Season 1"}],
|
||||
}
|
||||
|
||||
MOCK_CONTENT_RATINGS = {
|
||||
"results": [
|
||||
{"iso_3166_1": "DE", "rating": "12"},
|
||||
{"iso_3166_1": "US", "rating": "TV-14"},
|
||||
]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Required XML tags that must exist and be non-empty after creation
|
||||
# ---------------------------------------------------------------------------
|
||||
REQUIRED_SINGLE_TAGS = [
|
||||
"title",
|
||||
"originaltitle",
|
||||
"sorttitle",
|
||||
"year",
|
||||
"plot",
|
||||
"outline",
|
||||
"runtime",
|
||||
"premiered",
|
||||
"status",
|
||||
"tmdbid",
|
||||
"imdbid",
|
||||
"tvdbid",
|
||||
"dateadded",
|
||||
"watched",
|
||||
"mpaa",
|
||||
"tagline",
|
||||
]
|
||||
|
||||
REQUIRED_MULTI_TAGS = [
|
||||
"genre",
|
||||
"studio",
|
||||
"country",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anime_dir(tmp_path: Path) -> Path:
|
||||
"""Temporary anime root directory."""
|
||||
d = tmp_path / "anime"
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nfo_service(anime_dir: Path) -> NFOService:
|
||||
"""NFOService pointing at the temp directory."""
|
||||
return NFOService(
|
||||
tmdb_api_key="test_api_key",
|
||||
anime_directory=str(anime_dir),
|
||||
image_size="w500",
|
||||
auto_create=True,
|
||||
)
|
||||
|
||||
|
||||
class TestAddAnimeNFOContent:
|
||||
"""Test that adding an anime produces an NFO with required information."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_anime_nfo_contains_required_tags(
|
||||
self,
|
||||
nfo_service: NFOService,
|
||||
anime_dir: Path,
|
||||
) -> None:
|
||||
"""Add 'Sacrificial Princess And The King Of Beasts' and verify NFO.
|
||||
|
||||
Steps:
|
||||
1. Create the series folder on disk.
|
||||
2. Mock TMDB API responses.
|
||||
3. Call create_tvshow_nfo to generate the NFO.
|
||||
4. Parse the resulting XML and assert every required tag is present
|
||||
and non-empty.
|
||||
"""
|
||||
series_key = "sacrificial-princess-and-the-king-of-beasts"
|
||||
series_name = "Sacrificial Princess And The King Of Beasts"
|
||||
series_folder = f"{series_name} (2023)"
|
||||
|
||||
# Step 1: Create series folder
|
||||
series_path = anime_dir / series_folder
|
||||
series_path.mkdir()
|
||||
|
||||
# Step 2: Mock TMDB API calls
|
||||
with patch.object(
|
||||
nfo_service.tmdb_client,
|
||||
"search_tv_show",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_search, patch.object(
|
||||
nfo_service.tmdb_client,
|
||||
"get_tv_show_details",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_details, patch.object(
|
||||
nfo_service.tmdb_client,
|
||||
"get_tv_show_content_ratings",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_ratings, patch.object(
|
||||
nfo_service.image_downloader,
|
||||
"download_all_media",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_download:
|
||||
|
||||
mock_search.return_value = {
|
||||
"results": [
|
||||
{
|
||||
"id": 222093,
|
||||
"name": series_name,
|
||||
"first_air_date": "2023-04-20",
|
||||
"overview": (
|
||||
"A girl is offered as a sacrifice to a beastly king..."
|
||||
),
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_details.return_value = MOCK_TMDB_DATA
|
||||
mock_ratings.return_value = MOCK_CONTENT_RATINGS
|
||||
mock_download.return_value = {
|
||||
"poster": True,
|
||||
"logo": True,
|
||||
"fanart": True,
|
||||
}
|
||||
|
||||
# Step 3: Create NFO
|
||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||
serie_name=series_name,
|
||||
serie_folder=series_folder,
|
||||
year=2023,
|
||||
download_poster=True,
|
||||
download_logo=True,
|
||||
download_fanart=True,
|
||||
)
|
||||
|
||||
# Verify NFO was created
|
||||
assert nfo_path.exists(), f"NFO file not created at {nfo_path}"
|
||||
assert nfo_path.name == "tvshow.nfo"
|
||||
|
||||
# Step 4: Parse NFO XML and verify required tags
|
||||
nfo_content = nfo_path.read_text(encoding="utf-8")
|
||||
root = etree.fromstring(nfo_content.encode("utf-8"))
|
||||
|
||||
missing: list[str] = []
|
||||
for tag in REQUIRED_SINGLE_TAGS:
|
||||
elem = root.find(f".//{tag}")
|
||||
if elem is None or not (elem.text or "").strip():
|
||||
missing.append(tag)
|
||||
|
||||
for tag in REQUIRED_MULTI_TAGS:
|
||||
elems = root.findall(f".//{tag}")
|
||||
if not elems or not any((e.text or "").strip() for e in elems):
|
||||
missing.append(tag)
|
||||
|
||||
# At least one actor must be present
|
||||
actors = root.findall(".//actor/name")
|
||||
if not actors or not any((a.text or "").strip() for a in actors):
|
||||
missing.append("actor/name")
|
||||
|
||||
assert not missing, (
|
||||
f"Missing or empty required tags in NFO for '{series_name}':\n "
|
||||
+ "\n ".join(missing)
|
||||
+ f"\n\nFull NFO content:\n{nfo_content}"
|
||||
)
|
||||
|
||||
# Verify specific values for the requested anime
|
||||
assert root.findtext(".//title") == "Sacrificial Princess and the King of Beasts"
|
||||
assert root.findtext(".//year") == "2023"
|
||||
assert root.findtext(".//status") == "Ended"
|
||||
assert root.findtext(".//watched") == "false"
|
||||
assert root.findtext(".//tmdbid") == "222093"
|
||||
assert root.findtext(".//imdbid") == "tt19896734"
|
||||
assert root.findtext(".//tvdbid") == "421737"
|
||||
|
||||
# Plot and outline must be non-trivial
|
||||
plot = root.findtext(".//plot") or ""
|
||||
outline = root.findtext(".//outline") or ""
|
||||
assert len(plot) >= 10, f"plot too short: {plot!r}"
|
||||
assert len(outline) >= 10, f"outline too short: {outline!r}"
|
||||
|
||||
# Verify multi-value fields
|
||||
genres = [g.text for g in root.findall(".//genre") if g.text]
|
||||
assert "Animation" in genres
|
||||
assert "Romance" in genres
|
||||
|
||||
studios = [s.text for s in root.findall(".//studio") if s.text]
|
||||
assert "TBS" in studios
|
||||
|
||||
countries = [c.text for c in root.findall(".//country") if c.text]
|
||||
assert "JP" in countries
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_anime_nfo_has_plot_and_outline(
|
||||
self,
|
||||
nfo_service: NFOService,
|
||||
anime_dir: Path,
|
||||
) -> None:
|
||||
"""Specifically verify that plot and outline tags are populated.
|
||||
|
||||
This is a focused regression test ensuring the NFO always contains
|
||||
meaningful plot and outline data.
|
||||
"""
|
||||
series_name = "Sacrificial Princess And The King Of Beasts"
|
||||
series_folder = f"{series_name} (2023)"
|
||||
series_path = anime_dir / series_folder
|
||||
series_path.mkdir()
|
||||
|
||||
with patch.object(
|
||||
nfo_service.tmdb_client,
|
||||
"search_tv_show",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_search, patch.object(
|
||||
nfo_service.tmdb_client,
|
||||
"get_tv_show_details",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_details, patch.object(
|
||||
nfo_service.tmdb_client,
|
||||
"get_tv_show_content_ratings",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_ratings, patch.object(
|
||||
nfo_service.image_downloader,
|
||||
"download_all_media",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_download:
|
||||
|
||||
mock_search.return_value = {
|
||||
"results": [
|
||||
{
|
||||
"id": 222093,
|
||||
"name": series_name,
|
||||
"first_air_date": "2023-04-20",
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_details.return_value = MOCK_TMDB_DATA
|
||||
mock_ratings.return_value = MOCK_CONTENT_RATINGS
|
||||
mock_download.return_value = {"poster": False, "logo": False, "fanart": False}
|
||||
|
||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||
serie_name=series_name,
|
||||
serie_folder=series_folder,
|
||||
year=2023,
|
||||
download_poster=False,
|
||||
download_logo=False,
|
||||
download_fanart=False,
|
||||
)
|
||||
|
||||
assert nfo_path.exists()
|
||||
root = etree.parse(str(nfo_path)).getroot()
|
||||
|
||||
plot_elem = root.find(".//plot")
|
||||
outline_elem = root.find(".//outline")
|
||||
|
||||
assert plot_elem is not None, "<plot> tag missing from NFO"
|
||||
assert outline_elem is not None, "<outline> tag missing from NFO"
|
||||
|
||||
plot_text = (plot_elem.text or "").strip()
|
||||
outline_text = (outline_elem.text or "").strip()
|
||||
|
||||
assert plot_text, "<plot> tag is empty"
|
||||
assert outline_text, "<outline> tag is empty"
|
||||
assert "sacrifice" in plot_text.lower() or "beast" in plot_text.lower(), (
|
||||
f"plot does not contain expected content: {plot_text!r}"
|
||||
)
|
||||
429
tests/integration/test_sacrificial_princess_nfo.py
Normal file
429
tests/integration/test_sacrificial_princess_nfo.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""Integration test: add 'Sacrificial Princess And The King Of Beasts' and verify NFO completeness.
|
||||
|
||||
Simulates the production scenario where this anime is added and validates
|
||||
that the generated tvshow.nfo contains plot, outline, and all other required
|
||||
information. Also tests the repair path for an incomplete NFO.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from lxml import etree
|
||||
|
||||
from src.core.services.nfo_repair_service import (
|
||||
NfoRepairService,
|
||||
_read_tmdb_id,
|
||||
find_missing_tags,
|
||||
nfo_needs_repair,
|
||||
)
|
||||
from src.core.services.nfo_service import NFOService
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TMDB mock data matching production responses for this anime
|
||||
# ---------------------------------------------------------------------------
|
||||
SERIES_KEY = "sacrificial-princess-and-the-king-of-beasts"
|
||||
SERIES_NAME = "Sacrificial Princess And The King Of Beasts"
|
||||
SERIES_FOLDER = "Sacrificial Princess And The King Of Beasts (2023)"
|
||||
TMDB_ID = 222093
|
||||
|
||||
MOCK_TMDB_DETAILS = {
|
||||
"id": TMDB_ID,
|
||||
"name": "Sacrificial Princess and the King of Beasts",
|
||||
"original_name": "贄姫と獣の王",
|
||||
"overview": (
|
||||
"On the outskirts of the Demon King's realm lies a small village of "
|
||||
"humans who offer a sacrifice to the beast king every year. Sariphi, "
|
||||
"the latest sacrificial girl, expects to be devoured — but instead "
|
||||
"her fearless nature catches the king's attention and she becomes "
|
||||
"his unlikely companion."
|
||||
),
|
||||
"tagline": "A tale of love between a sacrifice and a beast king.",
|
||||
"first_air_date": "2023-04-20",
|
||||
"last_air_date": "2023-09-28",
|
||||
"vote_average": 7.5,
|
||||
"vote_count": 150,
|
||||
"status": "Ended",
|
||||
"episode_run_time": [24],
|
||||
"number_of_seasons": 1,
|
||||
"number_of_episodes": 24,
|
||||
"genres": [
|
||||
{"id": 16, "name": "Animation"},
|
||||
{"id": 10749, "name": "Romance"},
|
||||
{"id": 10765, "name": "Sci-Fi & Fantasy"},
|
||||
],
|
||||
"networks": [{"id": 160, "name": "TBS"}],
|
||||
"production_companies": [{"id": 291, "name": "J.C.Staff"}],
|
||||
"origin_country": ["JP"],
|
||||
"poster_path": "/sacrificial_poster.jpg",
|
||||
"backdrop_path": "/sacrificial_backdrop.jpg",
|
||||
"external_ids": {"imdb_id": "tt19896734", "tvdb_id": 421737},
|
||||
"credits": {
|
||||
"cast": [
|
||||
{
|
||||
"id": 2072089,
|
||||
"name": "Kana Hanazawa",
|
||||
"character": "Sariphi",
|
||||
"profile_path": "/hanazawa.jpg",
|
||||
"order": 0,
|
||||
},
|
||||
{
|
||||
"id": 1254783,
|
||||
"name": "Satoshi Hino",
|
||||
"character": "Leonhart",
|
||||
"profile_path": "/hino.jpg",
|
||||
"order": 1,
|
||||
},
|
||||
]
|
||||
},
|
||||
"images": {"logos": [{"file_path": "/sacrificial_logo.png"}]},
|
||||
"seasons": [
|
||||
{"season_number": 0, "name": "Specials"},
|
||||
{"season_number": 1, "name": "Season 1"},
|
||||
],
|
||||
}
|
||||
|
||||
MOCK_CONTENT_RATINGS = {
|
||||
"results": [
|
||||
{"iso_3166_1": "DE", "rating": "12"},
|
||||
{"iso_3166_1": "US", "rating": "TV-14"},
|
||||
]
|
||||
}
|
||||
|
||||
MOCK_SEARCH_RESULTS = {
|
||||
"results": [
|
||||
{
|
||||
"id": TMDB_ID,
|
||||
"name": "Sacrificial Princess and the King of Beasts",
|
||||
"first_air_date": "2023-04-20",
|
||||
"overview": (
|
||||
"On the outskirts of the Demon King's realm lies a small village "
|
||||
"of humans who offer a sacrifice to the beast king every year."
|
||||
),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tags that MUST be present and non-empty in a complete NFO
|
||||
# ---------------------------------------------------------------------------
|
||||
REQUIRED_TAGS = [
|
||||
"title",
|
||||
"originaltitle",
|
||||
"year",
|
||||
"plot",
|
||||
"outline",
|
||||
"runtime",
|
||||
"premiered",
|
||||
"status",
|
||||
"tmdbid",
|
||||
"imdbid",
|
||||
"genre",
|
||||
"studio",
|
||||
"country",
|
||||
"watched",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anime_dir(tmp_path: Path) -> Path:
|
||||
"""Temporary anime directory."""
|
||||
d = tmp_path / "anime"
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nfo_service(anime_dir: Path) -> NFOService:
|
||||
"""NFOService configured for the temp directory."""
|
||||
return NFOService(
|
||||
tmdb_api_key="test_api_key",
|
||||
anime_directory=str(anime_dir),
|
||||
image_size="w500",
|
||||
auto_create=True,
|
||||
)
|
||||
|
||||
|
||||
def _mock_tmdb_calls(nfo_service: NFOService):
|
||||
"""Context manager that patches all TMDB calls with mock data."""
|
||||
return _PatchContext(nfo_service)
|
||||
|
||||
|
||||
class _PatchContext:
|
||||
"""Helper to patch TMDB calls on an NFOService instance."""
|
||||
|
||||
def __init__(self, svc: NFOService):
|
||||
self._svc = svc
|
||||
self._patches = []
|
||||
|
||||
def __enter__(self):
|
||||
p1 = patch.object(
|
||||
self._svc.tmdb_client, "search_tv_show", new_callable=AsyncMock
|
||||
)
|
||||
p2 = patch.object(
|
||||
self._svc.tmdb_client, "get_tv_show_details", new_callable=AsyncMock
|
||||
)
|
||||
p3 = patch.object(
|
||||
self._svc.tmdb_client, "get_tv_show_content_ratings", new_callable=AsyncMock
|
||||
)
|
||||
p4 = patch.object(
|
||||
self._svc.image_downloader, "download_all_media", new_callable=AsyncMock
|
||||
)
|
||||
p5 = patch.object(
|
||||
self._svc.tmdb_client, "_ensure_session", new_callable=AsyncMock
|
||||
)
|
||||
p6 = patch.object(
|
||||
self._svc.tmdb_client, "close", new_callable=AsyncMock
|
||||
)
|
||||
|
||||
self._patches = [p1, p2, p3, p4, p5, p6]
|
||||
mocks = [p.start() for p in self._patches]
|
||||
|
||||
mocks[0].return_value = MOCK_SEARCH_RESULTS
|
||||
mocks[1].return_value = MOCK_TMDB_DETAILS
|
||||
mocks[2].return_value = MOCK_CONTENT_RATINGS
|
||||
mocks[3].return_value = {"poster": True, "logo": True, "fanart": True}
|
||||
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
for p in self._patches:
|
||||
p.stop()
|
||||
|
||||
|
||||
class TestSacrificialPrincessNFO:
|
||||
"""Tests for 'Sacrificial Princess And The King Of Beasts' NFO generation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_anime_creates_complete_nfo(
|
||||
self, nfo_service: NFOService, anime_dir: Path
|
||||
) -> None:
|
||||
"""Adding the anime produces an NFO with all required tags filled."""
|
||||
series_path = anime_dir / SERIES_FOLDER
|
||||
series_path.mkdir()
|
||||
|
||||
with _PatchContext(nfo_service):
|
||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||
serie_name=SERIES_NAME,
|
||||
serie_folder=SERIES_FOLDER,
|
||||
year=2023,
|
||||
download_poster=True,
|
||||
download_logo=True,
|
||||
download_fanart=True,
|
||||
)
|
||||
|
||||
assert nfo_path.exists(), f"NFO not created at {nfo_path}"
|
||||
|
||||
root = etree.parse(str(nfo_path)).getroot()
|
||||
missing = []
|
||||
for tag in REQUIRED_TAGS:
|
||||
elems = root.findall(f".//{tag}")
|
||||
if not elems or not any((e.text or "").strip() for e in elems):
|
||||
missing.append(tag)
|
||||
|
||||
# Actor check
|
||||
actors = root.findall(".//actor/name")
|
||||
if not actors or not any((a.text or "").strip() for a in actors):
|
||||
missing.append("actor/name")
|
||||
|
||||
assert not missing, (
|
||||
f"Missing or empty tags in NFO for '{SERIES_NAME}':\n"
|
||||
f" {', '.join(missing)}\n\n"
|
||||
f"NFO content:\n{nfo_path.read_text(encoding='utf-8')}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nfo_plot_and_outline_are_meaningful(
|
||||
self, nfo_service: NFOService, anime_dir: Path
|
||||
) -> None:
|
||||
"""Plot and outline must contain substantial descriptive text."""
|
||||
series_path = anime_dir / SERIES_FOLDER
|
||||
series_path.mkdir()
|
||||
|
||||
with _PatchContext(nfo_service):
|
||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||
serie_name=SERIES_NAME,
|
||||
serie_folder=SERIES_FOLDER,
|
||||
year=2023,
|
||||
download_poster=False,
|
||||
download_logo=False,
|
||||
download_fanart=False,
|
||||
)
|
||||
|
||||
root = etree.parse(str(nfo_path)).getroot()
|
||||
|
||||
plot = (root.findtext(".//plot") or "").strip()
|
||||
outline = (root.findtext(".//outline") or "").strip()
|
||||
|
||||
assert len(plot) >= 20, f"Plot too short ({len(plot)} chars): {plot!r}"
|
||||
assert len(outline) >= 20, f"Outline too short ({len(outline)} chars): {outline!r}"
|
||||
|
||||
# Should mention relevant keywords from the series
|
||||
combined = (plot + outline).lower()
|
||||
assert any(
|
||||
kw in combined for kw in ("sacrifice", "beast", "king", "sariphi")
|
||||
), f"Plot/outline missing expected content:\n plot={plot!r}\n outline={outline!r}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nfo_specific_values(
|
||||
self, nfo_service: NFOService, anime_dir: Path
|
||||
) -> None:
|
||||
"""Verify specific metadata values match the anime."""
|
||||
series_path = anime_dir / SERIES_FOLDER
|
||||
series_path.mkdir()
|
||||
|
||||
with _PatchContext(nfo_service):
|
||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||
serie_name=SERIES_NAME,
|
||||
serie_folder=SERIES_FOLDER,
|
||||
year=2023,
|
||||
download_poster=False,
|
||||
download_logo=False,
|
||||
download_fanart=False,
|
||||
)
|
||||
|
||||
root = etree.parse(str(nfo_path)).getroot()
|
||||
|
||||
assert root.findtext(".//year") == "2023"
|
||||
assert root.findtext(".//status") == "Ended"
|
||||
assert root.findtext(".//tmdbid") == str(TMDB_ID)
|
||||
assert root.findtext(".//imdbid") == "tt19896734"
|
||||
assert root.findtext(".//watched") == "false"
|
||||
assert root.findtext(".//premiered") == "2023-04-20"
|
||||
|
||||
genres = [g.text for g in root.findall(".//genre") if g.text]
|
||||
assert "Animation" in genres
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incomplete_nfo_detected_as_needing_repair(
|
||||
self, anime_dir: Path
|
||||
) -> None:
|
||||
"""An NFO with only a <title> tag is detected as incomplete."""
|
||||
series_path = anime_dir / SERIES_FOLDER
|
||||
series_path.mkdir()
|
||||
nfo_path = series_path / "tvshow.nfo"
|
||||
|
||||
# Simulate production state: minimal NFO with only title
|
||||
nfo_path.write_text(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tvshow>\n"
|
||||
f" <title>{SERIES_NAME}</title>\n"
|
||||
"</tvshow>\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert nfo_needs_repair(nfo_path) is True
|
||||
|
||||
missing = find_missing_tags(nfo_path)
|
||||
# All these should be detected as missing
|
||||
for tag_label in ["plot", "year", "runtime", "premiered", "genre", "studio"]:
|
||||
assert tag_label in missing, f"'{tag_label}' not detected as missing"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repair_fixes_incomplete_nfo(
|
||||
self, nfo_service: NFOService, anime_dir: Path
|
||||
) -> None:
|
||||
"""NfoRepairService re-fetches and creates a complete NFO from an incomplete one."""
|
||||
series_path = anime_dir / SERIES_FOLDER
|
||||
series_path.mkdir()
|
||||
nfo_path = series_path / "tvshow.nfo"
|
||||
|
||||
# Write an incomplete NFO with a tmdbid so update_tvshow_nfo can work
|
||||
nfo_path.write_text(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tvshow>\n"
|
||||
f" <title>{SERIES_NAME}</title>\n"
|
||||
f" <tmdbid>{TMDB_ID}</tmdbid>\n"
|
||||
"</tvshow>\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert nfo_needs_repair(nfo_path) is True
|
||||
|
||||
# Patch TMDB calls for the update path
|
||||
with patch.object(
|
||||
nfo_service.tmdb_client, "_ensure_session", new_callable=AsyncMock
|
||||
), patch.object(
|
||||
nfo_service.tmdb_client, "get_tv_show_details", new_callable=AsyncMock
|
||||
) as mock_details, patch.object(
|
||||
nfo_service.tmdb_client, "get_tv_show_content_ratings", new_callable=AsyncMock
|
||||
) as mock_ratings, patch.object(
|
||||
nfo_service.tmdb_client, "close", new_callable=AsyncMock
|
||||
):
|
||||
mock_details.return_value = MOCK_TMDB_DETAILS
|
||||
mock_ratings.return_value = MOCK_CONTENT_RATINGS
|
||||
|
||||
repair_service = NfoRepairService(nfo_service)
|
||||
repaired = await repair_service.repair_series(series_path, SERIES_FOLDER)
|
||||
|
||||
assert repaired is True
|
||||
|
||||
# After repair, NFO should be complete
|
||||
assert nfo_needs_repair(nfo_path) is False
|
||||
|
||||
# Verify content
|
||||
root = etree.parse(str(nfo_path)).getroot()
|
||||
plot = (root.findtext(".//plot") or "").strip()
|
||||
assert len(plot) >= 20, f"Plot still incomplete after repair: {plot!r}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repair_recreates_nfo_without_tmdb_id(
|
||||
self, nfo_service: NFOService, anime_dir: Path
|
||||
) -> None:
|
||||
"""If the NFO has no <tmdbid>, repair falls back to create_tvshow_nfo."""
|
||||
series_path = anime_dir / SERIES_FOLDER
|
||||
series_path.mkdir()
|
||||
nfo_path = series_path / "tvshow.nfo"
|
||||
|
||||
# Simulate the production worst-case: only a title, no TMDB ID
|
||||
nfo_path.write_text(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tvshow>\n"
|
||||
f" <title>{SERIES_NAME}</title>\n"
|
||||
"</tvshow>\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert _read_tmdb_id(nfo_path) is None
|
||||
assert nfo_needs_repair(nfo_path) is True
|
||||
|
||||
with _PatchContext(nfo_service):
|
||||
repair_service = NfoRepairService(nfo_service)
|
||||
repaired = await repair_service.repair_series(series_path, SERIES_FOLDER)
|
||||
|
||||
assert repaired is True
|
||||
assert nfo_path.exists()
|
||||
assert nfo_needs_repair(nfo_path) is False
|
||||
|
||||
root = etree.parse(str(nfo_path)).getroot()
|
||||
plot = (root.findtext(".//plot") or "").strip()
|
||||
assert len(plot) >= 20, f"Plot incomplete after recreate: {plot!r}"
|
||||
assert root.findtext(".//tmdbid") == str(TMDB_ID)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_nfo_not_repaired(
|
||||
self, nfo_service: NFOService, anime_dir: Path
|
||||
) -> None:
|
||||
"""A complete NFO should not trigger a repair."""
|
||||
series_path = anime_dir / SERIES_FOLDER
|
||||
series_path.mkdir()
|
||||
|
||||
# First create a complete NFO
|
||||
with _PatchContext(nfo_service):
|
||||
await nfo_service.create_tvshow_nfo(
|
||||
serie_name=SERIES_NAME,
|
||||
serie_folder=SERIES_FOLDER,
|
||||
year=2023,
|
||||
download_poster=False,
|
||||
download_logo=False,
|
||||
download_fanart=False,
|
||||
)
|
||||
|
||||
nfo_path = series_path / "tvshow.nfo"
|
||||
assert nfo_path.exists()
|
||||
assert nfo_needs_repair(nfo_path) is False
|
||||
|
||||
# Repair should be skipped
|
||||
repair_service = NfoRepairService(nfo_service)
|
||||
repaired = await repair_service.repair_series(series_path, SERIES_FOLDER)
|
||||
assert repaired is False
|
||||
@@ -495,6 +495,20 @@ class TestNameWithYearProperty:
|
||||
assert "(2013)" in sanitized
|
||||
assert "Attack on Titan" in sanitized
|
||||
|
||||
def test_name_with_year_does_not_duplicate(self):
|
||||
"""Test that name_with_year doesn't duplicate year."""
|
||||
serie = Serie(
|
||||
key="eighty-six",
|
||||
name="86 Eighty Six (2021)",
|
||||
site="aniworld.to",
|
||||
folder="86 Eighty Six (2021)",
|
||||
episodeDict={},
|
||||
year=2021
|
||||
)
|
||||
|
||||
assert serie.name_with_year == "86 Eighty Six (2021)"
|
||||
assert serie.name_with_year.count("(2021)") == 1
|
||||
|
||||
|
||||
class TestEnsureFolderWithYear:
|
||||
"""Test Serie.ensure_folder_with_year method."""
|
||||
|
||||
@@ -75,6 +75,84 @@ class TestComputeExpectedFolderName:
|
||||
result = _compute_expected_folder_name("A / B", "2021")
|
||||
assert result == "A B (2021)"
|
||||
|
||||
def test_does_not_duplicate_year(self) -> None:
|
||||
result = _compute_expected_folder_name("86 Eighty Six (2021)", "2021")
|
||||
assert result == "86 Eighty Six (2021)"
|
||||
assert result.count("(2021)") == 1
|
||||
|
||||
def test_removes_duplicate_year_suffixes_bug_86_eighty_six(self) -> None:
|
||||
"""Test the bug fix for duplicate year suffixes.
|
||||
|
||||
Issue: "86 Eighty Six (2021) (2021) (2021) (2021) (2021)"
|
||||
should become "86 Eighty Six (2021)"
|
||||
"""
|
||||
result = _compute_expected_folder_name(
|
||||
"86 Eighty Six (2021) (2021) (2021) (2021) (2021)", "2021"
|
||||
)
|
||||
assert result == "86 Eighty Six (2021)"
|
||||
assert result.count("(2021)") == 1
|
||||
|
||||
def test_removes_duplicate_year_suffixes_alma_chan(self) -> None:
|
||||
"""Test the bug fix for duplicate year suffixes with long title.
|
||||
|
||||
Issue: "Alma-chan Wants to Be a Family! (2025) (2025) (2025) (2025) (2025)"
|
||||
should become "Alma-chan Wants to Be a Family! (2025)"
|
||||
"""
|
||||
result = _compute_expected_folder_name(
|
||||
"Alma-chan Wants to Be a Family! (2025) (2025) (2025) (2025) (2025)",
|
||||
"2025",
|
||||
)
|
||||
assert result == "Alma-chan Wants to Be a Family! (2025)"
|
||||
assert result.count("(2025)") == 1
|
||||
|
||||
def test_removes_duplicate_year_suffixes_bogus_skill(self) -> None:
|
||||
"""Test the bug fix for duplicate year suffixes with very long title.
|
||||
|
||||
Issue: Long title with duplicated years should be cleaned.
|
||||
"""
|
||||
result = _compute_expected_folder_name(
|
||||
"Bogus Skill Fruitmaster About That Time I Became Able to Eat "
|
||||
"Unlimited Numbers of Skill Fruits (That Kill You) (2025) (2025)",
|
||||
"2025",
|
||||
)
|
||||
assert "(2025)" in result
|
||||
assert result.count("(2025)") == 1
|
||||
|
||||
def test_removes_multiple_different_year_suffixes(self) -> None:
|
||||
"""Test that old duplicate years are removed and new one added."""
|
||||
result = _compute_expected_folder_name(
|
||||
"Series (2020) (2020) (2020)", "2021"
|
||||
)
|
||||
assert result == "Series (2021)"
|
||||
assert "(2020)" not in result
|
||||
assert result.count("(2021)") == 1
|
||||
|
||||
def test_handles_whitespace_with_duplicate_years(self) -> None:
|
||||
"""Test that extra whitespace is removed along with duplicate years."""
|
||||
result = _compute_expected_folder_name(
|
||||
"Series (2021) (2021) (2021) ", "2021"
|
||||
)
|
||||
assert result == "Series (2021)"
|
||||
assert result.count("(2021)") == 1
|
||||
assert not result.endswith(" ")
|
||||
|
||||
def test_idempotent_multiple_calls(self) -> None:
|
||||
"""Test that calling the function multiple times produces the same result."""
|
||||
title = "86 Eighty Six (2021) (2021) (2021)"
|
||||
year = "2021"
|
||||
|
||||
# First call
|
||||
result1 = _compute_expected_folder_name(title, year)
|
||||
# Second call with the result
|
||||
result2 = _compute_expected_folder_name(result1, year)
|
||||
# Third call with the result
|
||||
result3 = _compute_expected_folder_name(result2, year)
|
||||
|
||||
# All results should be identical
|
||||
assert result1 == result2 == result3
|
||||
assert result1 == "86 Eighty Six (2021)"
|
||||
assert result1.count("(2021)") == 1
|
||||
|
||||
|
||||
class TestIsSeriesBeingDownloaded:
|
||||
"""Tests for _is_series_being_downloaded."""
|
||||
|
||||
Reference in New Issue
Block a user