Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06e104db42 | |||
| d4594bd1d9 | |||
| d866e836f6 | |||
| 195dae13cb | |||
| 51be777e7d | |||
| 7930e49701 | |||
| 75c22fe296 | |||
| 7bcd0600d5 | |||
| a333329ae2 | |||
| 363f7899f8 | |||
| a08a8f7408 |
@@ -1 +1 @@
|
|||||||
v1.1.4
|
v1.1.7
|
||||||
|
|||||||
@@ -130,10 +130,8 @@ start_vpn() {
|
|||||||
ip link add "$INTERFACE" type wireguard
|
ip link add "$INTERFACE" type wireguard
|
||||||
|
|
||||||
# Apply the WireGuard config (keys, peer, endpoint)
|
# Apply the WireGuard config (keys, peer, endpoint)
|
||||||
# We filter out Address/DNS/MTU/PreUp/PostUp/PreDown/PostDown/SaveConfig
|
# Filter out wg-quick directives that wg setconf doesn't understand
|
||||||
# AllowedIPs is kept because WireGuard needs it to know which traffic to tunnel.
|
wg setconf "$INTERFACE" <(grep -v -i '^\(Address\|DNS\|MTU\|Table\|PreUp\|PostUp\|PreDown\|PostDown\|SaveConfig\)' "$CONFIG_FILE")
|
||||||
# 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")
|
|
||||||
|
|
||||||
# Log public key so it can be verified against the server's peer list
|
# Log public key so it can be verified against the server's peer list
|
||||||
local PUBKEY
|
local PUBKEY
|
||||||
@@ -143,38 +141,35 @@ start_vpn() {
|
|||||||
# Assign the address
|
# Assign the address
|
||||||
ip -4 address add "$VPN_ADDRESS" dev "$INTERFACE"
|
ip -4 address add "$VPN_ADDRESS" dev "$INTERFACE"
|
||||||
|
|
||||||
# Set MTU
|
# Set MTU and bring up
|
||||||
ip link set mtu 1420 up dev "$INTERFACE"
|
ip link set mtu 1420 up dev "$INTERFACE"
|
||||||
|
|
||||||
# Remove the auto-created default route by wg setconf (if AllowedIPs = 0.0.0.0/0)
|
# ── fwmark-based routing (mirrors wg-quick behavior) ──
|
||||||
# We set our own routes manually to avoid breaking the endpoint connection
|
# 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
|
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_GW=$(ip route | grep '^default' | head -1 | awk '{print $3}')
|
||||||
DEFAULT_IF=$(ip route | grep '^default' | head -1 | awk '{print $5}')
|
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 ──
|
# ── Policy routing: ensure responses to incoming LAN traffic go back via eth0 ──
|
||||||
if [ -n "$DEFAULT_GW" ] && [ -n "$DEFAULT_IF" ]; then
|
if [ -n "$DEFAULT_GW" ] && [ -n "$DEFAULT_IF" ]; then
|
||||||
# Get the container's eth0 IP address (BusyBox-compatible, no grep -P)
|
# Get the container's eth0 IP address (BusyBox-compatible, no grep -P)
|
||||||
@@ -201,8 +196,12 @@ start_vpn() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[vpn] WireGuard interface ${INTERFACE} is up."
|
echo "[vpn] WireGuard interface ${INTERFACE} is up."
|
||||||
echo "[vpn] Routes:"
|
echo "[vpn] Main routes:"
|
||||||
ip route show | sed 's/^/[vpn] /'
|
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:"
|
echo "[vpn] WireGuard status:"
|
||||||
wg show "$INTERFACE" 2>/dev/null | sed 's/^/[vpn] /'
|
wg show "$INTERFACE" 2>/dev/null | sed 's/^/[vpn] /'
|
||||||
}
|
}
|
||||||
@@ -213,23 +212,19 @@ start_vpn() {
|
|||||||
stop_vpn() {
|
stop_vpn() {
|
||||||
echo "[vpn] Stopping WireGuard interface ${INTERFACE}..."
|
echo "[vpn] Stopping WireGuard interface ${INTERFACE}..."
|
||||||
|
|
||||||
# Remove routes added for AllowedIPs
|
local FW_MARK=51820
|
||||||
ALLOWED_IPS=$(grep -i '^AllowedIPs' "$CONFIG_FILE" | head -1 | sed 's/.*= *//;s/ //g')
|
local FW_TABLE=51820
|
||||||
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
|
|
||||||
|
|
||||||
# Remove endpoint route
|
# Remove fwmark-based policy rules
|
||||||
if [ -n "$VPN_ENDPOINT" ]; then
|
ip -4 rule del not fwmark "$FW_MARK" table "$FW_TABLE" 2>/dev/null || true
|
||||||
ip route del "$VPN_ENDPOINT/32" 2>/dev/null || true
|
ip -4 rule del table main suppress_prefixlength 0 2>/dev/null || true
|
||||||
fi
|
|
||||||
|
# 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
|
ip link del "$INTERFACE" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
@@ -246,14 +241,14 @@ health_loop() {
|
|||||||
while true; do
|
while true; do
|
||||||
sleep "$CHECK_INTERVAL"
|
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
|
if [ "$failures" -gt 0 ]; then
|
||||||
echo "[health] VPN recovered."
|
echo "[health] VPN recovered."
|
||||||
failures=0
|
failures=0
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
failures=$((failures + 1))
|
failures=$((failures + 1))
|
||||||
echo "[health] Check failed ($failures/$max_failures) — curl http://${CHECK_HOST} timed out"
|
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
|
# Dump WireGuard stats to show if handshake is stale and how much data flows
|
||||||
echo "[health] wg stats:"
|
echo "[health] wg stats:"
|
||||||
wg show "$INTERFACE" 2>/dev/null | grep -E 'latest handshake|transfer|endpoint' | sed 's/^/[health] /' || echo "[health] wg0 not found"
|
wg show "$INTERFACE" 2>/dev/null | grep -E 'latest handshake|transfer|endpoint' | sed 's/^/[health] /' || echo "[health] wg0 not found"
|
||||||
@@ -316,16 +311,16 @@ check_vpn_connectivity() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. Check whether traffic actually flows through the tunnel
|
# 2. Check whether traffic actually flows through the tunnel
|
||||||
echo "[check] Testing traffic through tunnel (http://${CHECK_HOST})..."
|
echo "[check] Testing traffic through tunnel (ping ${CHECK_HOST})..."
|
||||||
local rx_before
|
local rx_before
|
||||||
rx_before=$(wg show "$INTERFACE" transfer 2>/dev/null | awk '{print $2}' | head -1)
|
rx_before=$(wg show "$INTERFACE" transfer 2>/dev/null | awk '{print $2}' | head -1)
|
||||||
|
|
||||||
if curl -sf --max-time 8 "http://${CHECK_HOST}" > /dev/null 2>&1; then
|
if ping -c 1 -W 8 "${CHECK_HOST}" > /dev/null 2>&1; then
|
||||||
echo "[check] OK Traffic flows — tunnel is fully working"
|
echo "[check] OK Traffic flows — tunnel is fully working"
|
||||||
else
|
else
|
||||||
local rx_after
|
local rx_after
|
||||||
rx_after=$(wg show "$INTERFACE" transfer 2>/dev/null | awk '{print $2}' | head -1)
|
rx_after=$(wg show "$INTERFACE" transfer 2>/dev/null | awk '{print $2}' | head -1)
|
||||||
echo "[check] FAIL http://${CHECK_HOST} unreachable through tunnel"
|
echo "[check] FAIL ping ${CHECK_HOST} unreachable through tunnel"
|
||||||
|
|
||||||
if [ -n "$rx_before" ] && [ -n "$rx_after" ]; then
|
if [ -n "$rx_before" ] && [ -n "$rx_after" ]; then
|
||||||
if [ "$rx_after" -le "$rx_before" ]; then
|
if [ "$rx_after" -le "$rx_before" ]; then
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ bash "${SCRIPT_DIR}/push.sh" "${TARGET}"
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
cd "${SCRIPT_DIR}/.."
|
cd "${SCRIPT_DIR}/.."
|
||||||
git add Docker/VERSION package.json pyproject.toml
|
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}"
|
git tag -a "${NEW_TAG}" -m "Release ${NEW_TAG}"
|
||||||
echo "Local git commit + tag ${NEW_TAG} created."
|
echo "Local git commit + tag ${NEW_TAG} created."
|
||||||
|
|
||||||
|
|||||||
47
docs/key
47
docs/key
@@ -2,3 +2,50 @@ API key : 299ae8f630a31bda814263c551361448
|
|||||||
|
|
||||||
/mnt/server/serien/Serien/
|
/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",
|
"name": "aniworld-web",
|
||||||
"version": "1.1.4",
|
"version": "1.1.7",
|
||||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -445,6 +445,9 @@ class SeriesApp:
|
|||||||
try:
|
try:
|
||||||
def download_progress_handler(progress_info):
|
def download_progress_handler(progress_info):
|
||||||
"""Handle download progress events from loader."""
|
"""Handle download progress events from loader."""
|
||||||
|
# Throttle progress logging to avoid spam
|
||||||
|
status = progress_info.get("status", "")
|
||||||
|
if status in ("downloading", "finished"):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"download_progress_handler called with: %s", progress_info
|
"download_progress_handler called with: %s", progress_info
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -271,7 +271,11 @@ class Serie:
|
|||||||
'Dororo (2025)'
|
'Dororo (2025)'
|
||||||
"""
|
"""
|
||||||
if self._year:
|
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
|
return self._name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ class AniworldLoader(Loader):
|
|||||||
'no_warnings': True,
|
'no_warnings': True,
|
||||||
'progress_with_newline': False,
|
'progress_with_newline': False,
|
||||||
'nocheckcertificate': True,
|
'nocheckcertificate': True,
|
||||||
|
'logger': logger,
|
||||||
'progress_hooks': [events_progress_hook],
|
'progress_hooks': [events_progress_hook],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +340,7 @@ class AniworldLoader(Loader):
|
|||||||
logger.debug("Using custom headers for download")
|
logger.debug("Using custom headers for download")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.debug("Starting YoutubeDL download")
|
logger.info("Starting download: %s", output_file)
|
||||||
logger.debug("Download link: %s...", link[:100])
|
logger.debug("Download link: %s...", link[:100])
|
||||||
logger.debug("YDL options: %s", ydl_opts)
|
logger.debug("YDL options: %s", ydl_opts)
|
||||||
|
|
||||||
|
|||||||
@@ -566,6 +566,7 @@ class EnhancedAniWorldLoader(Loader):
|
|||||||
"nocheckcertificate": True,
|
"nocheckcertificate": True,
|
||||||
"socket_timeout": self.download_timeout,
|
"socket_timeout": self.download_timeout,
|
||||||
"http_chunk_size": 1024 * 1024, # 1MB chunks
|
"http_chunk_size": 1024 * 1024, # 1MB chunks
|
||||||
|
"logger": self.logger,
|
||||||
}
|
}
|
||||||
if headers:
|
if headers:
|
||||||
ydl_opts['http_headers'] = 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))
|
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:
|
class NfoRepairService:
|
||||||
"""Service that detects and repairs incomplete tvshow.nfo files.
|
"""Service that detects and repairs incomplete tvshow.nfo files.
|
||||||
|
|
||||||
|
|||||||
@@ -83,11 +83,12 @@ class NFOService:
|
|||||||
>>> _extract_year_from_name("Attack on Titan")
|
>>> _extract_year_from_name("Attack on Titan")
|
||||||
("Attack on Titan", None)
|
("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)
|
match = re.search(r'\((\d{4})\)\s*$', serie_name)
|
||||||
if match:
|
if match:
|
||||||
year = int(match.group(1))
|
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 clean_name, year
|
||||||
return serie_name, None
|
return serie_name, None
|
||||||
|
|
||||||
|
|||||||
@@ -730,7 +730,11 @@ async def add_series(
|
|||||||
|
|
||||||
# Create folder name with year if available
|
# Create folder name with year if available
|
||||||
if year:
|
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:
|
else:
|
||||||
folder_name_with_year = name
|
folder_name_with_year = name
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def get_settings() -> Union[DevelopmentSettings, ProductionSettings]:
|
|||||||
Example:
|
Example:
|
||||||
>>> settings = get_settings()
|
>>> settings = get_settings()
|
||||||
>>> print(settings.log_level)
|
>>> print(settings.log_level)
|
||||||
DEBUG
|
INFO
|
||||||
"""
|
"""
|
||||||
if ENVIRONMENT in {"development", "testing"}:
|
if ENVIRONMENT in {"development", "testing"}:
|
||||||
return get_development_settings()
|
return get_development_settings()
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ class DevelopmentSettings(BaseSettings):
|
|||||||
@property
|
@property
|
||||||
def debug_enabled(self) -> bool:
|
def debug_enabled(self) -> bool:
|
||||||
"""Check if debug mode is enabled."""
|
"""Check if debug mode is enabled."""
|
||||||
return True
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def reload_enabled(self) -> bool:
|
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:
|
def _compute_expected_folder_name(title: str, year: str) -> str:
|
||||||
"""Compute the expected folder name from title and year.
|
"""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:
|
Args:
|
||||||
title: Series title from NFO.
|
title: Series title from NFO.
|
||||||
year: Release year from NFO.
|
year: Release year from NFO.
|
||||||
@@ -73,7 +76,15 @@ def _compute_expected_folder_name(title: str, year: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
Sanitised folder name in the format ``"{title} ({year})"``.
|
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)
|
return sanitize_folder_name(raw_name)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -334,6 +334,25 @@ async def test_add_series_sanitizes_folder_name(authenticated_client):
|
|||||||
assert "?" not in folder
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_add_series_returns_missing_episodes(authenticated_client):
|
async def test_add_series_returns_missing_episodes(authenticated_client):
|
||||||
"""Test that add_series returns loading progress info."""
|
"""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 "(2013)" in sanitized
|
||||||
assert "Attack on Titan" 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:
|
class TestEnsureFolderWithYear:
|
||||||
"""Test Serie.ensure_folder_with_year method."""
|
"""Test Serie.ensure_folder_with_year method."""
|
||||||
|
|||||||
@@ -75,6 +75,84 @@ class TestComputeExpectedFolderName:
|
|||||||
result = _compute_expected_folder_name("A / B", "2021")
|
result = _compute_expected_folder_name("A / B", "2021")
|
||||||
assert result == "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:
|
class TestIsSeriesBeingDownloaded:
|
||||||
"""Tests for _is_series_being_downloaded."""
|
"""Tests for _is_series_being_downloaded."""
|
||||||
|
|||||||
Reference in New Issue
Block a user