Compare commits

..

35 Commits

Author SHA1 Message Date
11e231a4ab chore: bump version 2026-05-21 21:42:13 +02:00
a11f8c4fa0 fix(vpn): add explicit host route for health-check target
Without a /32 route in the main table, CHECK_HOST (1.1.1.1) fell through
to the VPN default route where source-address selection was defeated by
the priority-100 'from ETH0_IP' policy rule, causing pings to bypass
wg0 and be dropped by the kill switch.

Also add secondary google.com ping to distinguish IP vs DNS failures.
2026-05-21 21:41:51 +02:00
cf5a06af11 chore: bump version 2026-05-21 21:22:08 +02:00
e07f75432e backup 2026-05-21 21:21:13 +02:00
1696d5c65b chore: bump version 2026-05-21 21:04:51 +02:00
c8b386f47a chore: bump version 2026-05-20 20:00:45 +02:00
3888da352a feat(tmdb): improve rate limiting and retry resilience
- Increase max_retries from 3 to 5 with exponential backoff capped at 30s
- Add per-second rate limiter (~35 req/s) to stay under TMDB's ~40/s limit
- Replace small semaphore (4) with larger one (30) + token-bucket throttle
- Abort retries immediately on DNS/name-resolution failures
- Increase rate-limit fallback wait from default to max(delay*2, 10)s
2026-05-20 20:00:11 +02:00
06e104db42 chore: bump version 2026-05-20 19:41:58 +02:00
d4594bd1d9 chore: bump version 2026-05-20 19:40:17 +02:00
d866e836f6 backup 2026-05-20 19:39:08 +02:00
195dae13cb test: add integration tests for NFO content and repair
- test_add_anime_nfo_content.py: verify required NFO tags after anime add
- test_sacrificial_princess_nfo.py: test full NFO generation and repair path
2026-05-20 19:38:43 +02:00
51be777e7d fix: strip all trailing year suffixes to prevent duplication
- series.py: use regex to remove all trailing (YYYY) before appending year
- nfo_service.py: _extract_year_from_name strips all trailing year suffixes
- nfo_repair_service.py: add _read_tmdb_id() helper to extract TMDB ID from NFO
2026-05-20 19:38:37 +02:00
7930e49701 fix: prevent duplicate year suffixes in series name and folder creation
Apply the same duplicate-year prevention logic to additional code paths:

- Serie.name_with_year property: skip adding year suffix if name already ends with it
- add_series API endpoint: avoid duplicating year in folder_name_with_year
- Add integration test for Serie.name_with_year idempotency
- Add API test for add_series endpoint year deduplication

Complements the folder_rename_service fix for comprehensive coverage.
2026-05-19 21:25:21 +02:00
75c22fe296 fix(folder-rename): prevent duplicate year suffixes in series folder names
Use regex to strip all trailing year suffixes before adding the canonical
one, preventing duplication like 'Show (2021) (2021) (2021)'.

- Add regex pattern (\s*\(\d{4}\))+\s*$ to remove all existing year suffixes
- Ensure idempotent behavior across multiple folder rename runs
- Add 7 unit tests covering the bug cases and edge scenarios

Fixes: 86 Eighty Six (2021) (2021)..., Alma-chan (2025) (2025)...
2026-05-19 21:24:07 +02:00
7bcd0600d5 chore: bump version 2026-05-18 09:57:51 +02:00
a333329ae2 backup 2026-05-18 09:56:59 +02:00
363f7899f8 refactor(logging): reduce download log spam and set INFO level
- Pass app logger to yt-dlp so internal [download] progress lines
  are routed through the INFO-level logger instead of stdout.
- Throttle download_progress_handler debug logging to avoid
  flooding logs on every fragment tick.
- Switch key provider lifecycle messages to INFO (start/complete)
  while keeping verbose details at DEBUG.
- Set debug_enabled=False in development config so dev mode
  does not emit extra debug noise.
- Update config docstring example from DEBUG to INFO.
2026-05-18 09:56:19 +02:00
a08a8f7408 backup 2026-05-17 18:57:12 +02:00
54ac5e9ab7 chore: release v1.1.4 2026-05-17 18:51:09 +02:00
c93ac3e7b8 chore: release v1.1.3 2026-05-17 18:40:37 +02:00
68c4335348 feat(vpn): add startup connectivity checks and PersistentKeepalive
Add check_vpn_connectivity() that runs once after wg0 comes up:
- Waits for handshake (up to 15s) and prints public key if missing
- Measures RX bytes before/after curl to detect server-side routing issues
- Tests DNS resolution and dumps resolv.conf on failure
- On failure prints exact server-side commands to fix (sysctl, iptables, wg)

Add PersistentKeepalive=25 to wg0.conf to keep NAT mappings alive.
2026-05-17 18:40:24 +02:00
be87f2e230 chore: release v1.1.2 2026-05-17 18:31:59 +02:00
c56e0f507d fix(vpn): fix DNS iptables rules and add NET_RAW cap
DNS OUTPUT was restricted to -o wg0, but routing decision happens
after iptables OUTPUT — so DNS to VPN-internal addresses (198.18.0.x)
was blocked before the kernel selected the outgoing interface.
Allow DNS unconditionally; routing still sends it through wg0.

Add NET_RAW capability so ping works inside the container.
2026-05-17 18:31:38 +02:00
cb0a36ccc2 chore: release v1.1.1 2026-05-16 21:47:05 +02:00
3644b16447 feat(vpn): add version logging from VERSION file
- Read version from /etc/wireguard/VERSION instead of hardcoding
- Copy VERSION file into container image during build
- Update VERSION to v1.1.0
2026-05-16 21:46:19 +02:00
d5116e378e chore: release v0.1.0 2026-05-16 21:41:40 +02:00
50a7083ce5 fix(vpn): support AllowedIPs=0.0.0.0/0 and multi-DNS configs
- Parse AllowedIPs dynamically from WireGuard config instead of hardcoding routes
- Remove auto-created default route by wg setconf to prevent breaking endpoint connection
- Fix DNS parsing: write comma-separated DNS servers as separate nameserver lines
- Add test for AllowedIPs route verification and DNS configuration
- Update test to skip container runtime tests when not running as root
2026-05-16 21:41:27 +02:00
52c0ff2337 chore(docs): remove temporary planning file docs/bla 2026-05-16 21:22:44 +02:00
a5fd88e224 chore(vpn): update WireGuard endpoint and credentials
- Rotate to new VPN endpoint (91.148.236.64)
- Update private/public keys and client address
- Switch DNS to 198.18.0.1/0.2
- Add local network route preservation via PostUp/PostDown
- Align nl.conf and wg0.conf configurations
2026-05-16 21:22:04 +02:00
98d4edad14 feat(vpn): dynamic AllowedIPs routing and improved test coverage
- Parse AllowedIPs from WireGuard config in entrypoint.sh
- Add/remove routes dynamically instead of hardcoded 0.0.0.0/1 split
- Handle both 0.0.0.0/0 and custom AllowedIPs
- Add route cleanup on VPN stop (endpoint + AllowedIPs)
- Update test_vpn.py with AllowedIPs route verification
- Allow non-root build-only tests with automatic runtime skip
2026-05-16 21:21:56 +02:00
bc8059b453 feat(docker): add release script and enhance push script
- Add release.sh for automated versioning and image pushing
- Enhance push.sh with target selection (app/vpn/all)
- Add docker/podman engine auto-detection
- Improve usage docs and error handling
2026-05-16 21:21:45 +02:00
815a4f1520 chore: release v0.0.1 2026-05-16 21:20:20 +02:00
e3509f5c8f feat(scanner): add DB fallback for series key resolution
When SerieScanner encounters a folder without a local key or data file,
it now optionally falls back to a database lookup by folder name. This
prevents newly-added series from being silently skipped on rescan when
their metadata only lives in the DB.

Changes:
- SerieScanner accepts an optional db_lookup callable
- SeriesApp forwards db_lookup to SerieScanner
- AnimeSeriesService adds get_by_folder_sync() helper
- dependencies.py wires a sync DB lookup into get_series_app()
- Unit tests cover fallback hit, miss, and exception paths
2026-05-14 19:28:43 +02:00
69c2fd01f9 chore: bump version to 1.0.1 2026-05-14 17:30:13 +02:00
0f36afd88c refactor: move NFO repair from initialization_service to folder_scan_service
Moves perform_nfo_repair_scan and its helpers (_repair_one_series,
_NFO_REPAIR_SEMAPHORE) into folder_scan_service.py so NFO repair runs
during the scheduled folder scan instead of on startup.

- Removes NFO repair code from initialization_service.py
- Updates all test imports and patch targets
- Updates docs/NFO_GUIDE.md and docs/CHANGELOG.md references

All 174 related tests pass.
2026-05-14 17:01:01 +02:00
50 changed files with 1929 additions and 269 deletions

View File

@@ -13,7 +13,8 @@ RUN apk add --no-cache \
# Create wireguard config directory (config is mounted at runtime)
RUN mkdir -p /etc/wireguard
# Copy entrypoint
# Copy version file and entrypoint
COPY VERSION /etc/wireguard/VERSION
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

1
Docker/VERSION Normal file
View File

@@ -0,0 +1 @@
v1.1.12

View File

@@ -1,6 +1,14 @@
#!/bin/bash
set -e
VERSION_FILE="/etc/wireguard/VERSION"
if [ -f "$VERSION_FILE" ]; then
VERSION=$(cat "$VERSION_FILE")
else
VERSION="unknown"
fi
echo "[init] VPN Container Entrypoint ${VERSION}"
INTERFACE="wg0"
MOUNT_CONFIG="/etc/wireguard/${INTERFACE}.conf"
CONFIG_DIR="/run/wireguard"
@@ -64,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
@@ -120,27 +130,46 @@ start_vpn() {
ip link add "$INTERFACE" type wireguard
# Apply the WireGuard config (keys, peer, endpoint)
# 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"
# Find default gateway/interface for the endpoint route
# ── 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
# 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
# Route all traffic through the WireGuard tunnel
ip route add 0.0.0.0/1 dev "$INTERFACE"
ip route add 128.0.0.0/1 dev "$INTERFACE"
# ── 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)
@@ -155,14 +184,37 @@ start_vpn() {
fi
fi
# Set up DNS
# Set up DNS (handle comma-separated DNS servers)
VPN_DNS=$(grep -i '^DNS' "$CONFIG_FILE" | head -1 | sed 's/.*= *//;s/ //g')
if [ -n "$VPN_DNS" ]; then
echo "nameserver $VPN_DNS" > /etc/resolv.conf
echo "[vpn] DNS set to ${VPN_DNS}"
# Clear resolv.conf and add each DNS server on its own line
> /etc/resolv.conf
for dns in $(echo "$VPN_DNS" | tr ',' ' '); do
echo "nameserver $dns" >> /etc/resolv.conf
# Add explicit route to DNS server through wg0 so it's found in main table
# (suppress_prefixlength 0 ignores default routes but allows host routes)
ip -4 route add "$dns" dev "$INTERFACE" 2>/dev/null || true
done
echo "[vpn] DNS set to: ${VPN_DNS}"
fi
# Add explicit host route for the health-check target so it is picked up by
# the 'lookup main suppress_prefixlength 0' rule (same as DNS servers above).
# Without this, CHECK_HOST falls through to the VPN table default route whose
# source-address selection can be defeated by the priority-100 'from ETH0_IP'
# policy rule, causing pings to bypass wg0 and be dropped by the kill switch.
ip -4 route add "${CHECK_HOST}" dev "$INTERFACE" 2>/dev/null || true
echo "[vpn] Health-check route: ${CHECK_HOST}${INTERFACE}"
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] /'
}
# ──────────────────────────────────────────────
@@ -170,6 +222,21 @@ start_vpn() {
# ──────────────────────────────────────────────
stop_vpn() {
echo "[vpn] Stopping WireGuard interface ${INTERFACE}..."
local FW_MARK=51820
local FW_TABLE=51820
# 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
}
@@ -185,14 +252,31 @@ 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
# Secondary DNS check
if ping -c 1 -W 5 "google.com" > /dev/null 2>&1; then
: # DNS OK — silent
else
echo "[health] WARN google.com unreachable — possible DNS issue"
fi
else
failures=$((failures + 1))
echo "[health] Ping failed ($failures/$max_failures)"
echo "[health] Check failed ($failures/$max_failures) — ping ${CHECK_HOST} failed"
# Secondary check: distinguish IP failure from DNS failure
if ping -c 1 -W 5 "google.com" > /dev/null 2>&1; then
echo "[health] INFO google.com reachable — DNS works, ${CHECK_HOST} may be filtered"
else
echo "[health] INFO google.com also unreachable — DNS or general routing failure"
fi
# 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..."
@@ -221,8 +305,83 @@ 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"
echo "[check] ── End of checks ──"
exit 1
fi
echo "[check] ── End of checks ──"
}
# ── Main ──
enable_forwarding
setup_killswitch
start_vpn
check_vpn_connectivity
health_loop

View File

@@ -1,17 +1,16 @@
[Interface]
PrivateKey = iO5spIue/6ciwUoR95hYtuxdtQxV/Q9EOoQ/jHe18kM=
Address = 10.2.0.2/32
DNS = 10.2.0.1
PrivateKey = EPRa2f/v72LvIXAY4yqIRJifsSb+nCcYHqC2rwA94UI=
Address = 100.64.244.78/32
DNS = 198.18.0.1,198.18.0.2
# Route zum VPN-Server direkt über dein lokales Netz
PostUp = ip route add 185.183.34.149 via 192.168.178.1 dev wlp4s0f0
PostUp = ip route add 91.148.236.64 via 192.168.178.1 dev wlp4s0f0
PostUp = ip route add 192.168.178.0/24 via 192.168.178.1 dev wlp4s0f0
PostDown = ip route del 185.183.34.149 via 192.168.178.1 dev wlp4s0f0
PostDown = ip route del 91.148.236.64 via 192.168.178.1 dev wlp4s0f0
PostDown = ip route del 192.168.178.0/24 via 192.168.178.1 dev wlp4s0f0
[Peer]
PublicKey = J4XVdtoBVc/EoI2Yk673Oes97WMnQSH5KfamZNjtM2s=
AllowedIPs = 0.0.0.0/1, 128.0.0.0/1
Endpoint = 185.183.34.149:51820
PublicKey = KgTUh3KLijVluDvNpzDCJJfrJ7EyLzYLmdHCksG4sRg=
AllowedIPs = 0.0.0.0/0
Endpoint = 91.148.236.64:51820

View File

@@ -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

View File

@@ -1,15 +1,19 @@
#!/usr/bin/env bash
# filepath: /home/lukas/Volume/repo/Aniworld/Docker/push.sh
#
# Build and push Aniworld container images to the Gitea registry.
# Build and push AniWorld container images to the Gitea registry.
#
# Usage:
# ./push.sh # builds & pushes with tag "latest"
# ./push.sh v1.2.3 # builds & pushes with tag "v1.2.3"
# ./push.sh v1.2.3 --no-build # pushes existing images only
# ./push.sh # builds & pushes app with tag "latest"
# ./push.sh app # builds & pushes app image
# ./push.sh vpn # builds & pushes vpn image
# ./push.sh all # builds & pushes both images
# ./push.sh app v1.2.3 # builds & pushes app with tag "v1.2.3"
# ./push.sh vpn v1.2.3 # builds & pushes vpn with tag "v1.2.3"
# ./push.sh all v1.2.3 # builds & pushes both images
# ./push.sh app v1.2.3 --no-build # pushes existing image only
#
# Prerequisites:
# podman login git.lpl-mind.de
# podman login git.lpl-mind.de (or: docker login git.lpl-mind.de)
set -euo pipefail
@@ -23,12 +27,20 @@ PROJECT="aniworld"
APP_IMAGE="${REGISTRY}/${NAMESPACE}/${PROJECT}/app"
VPN_IMAGE="${REGISTRY}/${NAMESPACE}/${PROJECT}/vpn"
TAG="${1:-latest}"
# Parse arguments
TARGET="${1:-app}"
TAG="${2:-latest}"
SKIP_BUILD=false
if [[ "${2:-}" == "--no-build" ]]; then
if [[ "${3:-}" == "--no-build" ]]; then
SKIP_BUILD=true
fi
# Validate target
if [[ "${TARGET}" != "app" && "${TARGET}" != "vpn" && "${TARGET}" != "all" ]]; then
echo "ERROR: Invalid target '${TARGET}'. Must be one of: app, vpn, all" >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
@@ -36,62 +48,93 @@ PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# Helpers
# ---------------------------------------------------------------------------
log() { echo -e "\n>>> $*"; }
err() { echo -e "\nERROR: $*" >&2; exit 1; }
err() { echo -e "\nERROR: $*" >&2; exit 1; }
# Detect container engine (podman preferred, docker fallback)
if command -v podman &>/dev/null; then
ENGINE="podman"
elif command -v docker &>/dev/null; then
ENGINE="docker"
else
err "Neither podman nor docker is installed."
fi
# ---------------------------------------------------------------------------
# Pre-flight checks
# ---------------------------------------------------------------------------
echo "============================================"
echo " Aniworld — Build & Push"
echo " AniWorld — Build & Push"
echo " Engine : ${ENGINE}"
echo " Registry : ${REGISTRY}"
echo " Target : ${TARGET}"
echo " Tag : ${TAG}"
echo "============================================"
command -v podman &>/dev/null || err "podman is not installed."
if ! podman login --get-login "${REGISTRY}" &>/dev/null; then
err "Not logged in. Run:\n podman login ${REGISTRY}"
fi
log "Logging in to ${REGISTRY}"
"${ENGINE}" login "${REGISTRY}"
# ---------------------------------------------------------------------------
# Build
# ---------------------------------------------------------------------------
if [[ "${SKIP_BUILD}" == false ]]; then
build_app() {
log "Building app image → ${APP_IMAGE}:${TAG}"
podman build \
"${ENGINE}" build \
-t "${APP_IMAGE}:${TAG}" \
-f "${SCRIPT_DIR}/Dockerfile.app" \
"${PROJECT_ROOT}"
}
log "Building VPN image → ${VPN_IMAGE}:${TAG}"
podman build \
build_vpn() {
log "Building vpn image → ${VPN_IMAGE}:${TAG}"
"${ENGINE}" build \
-t "${VPN_IMAGE}:${TAG}" \
-f "${SCRIPT_DIR}/Containerfile" \
"${SCRIPT_DIR}"
}
if [[ "${SKIP_BUILD}" == false ]]; then
case "${TARGET}" in
app) build_app ;;
vpn) build_vpn ;;
all) build_app; build_vpn ;;
esac
fi
# ---------------------------------------------------------------------------
# Push
# ---------------------------------------------------------------------------
log "Pushing ${APP_IMAGE}:${TAG}"
podman push "${APP_IMAGE}:${TAG}"
push_app() {
log "Pushing ${APP_IMAGE}:${TAG}"
"${ENGINE}" push "${APP_IMAGE}:${TAG}"
}
log "Pushing ${VPN_IMAGE}:${TAG}"
podman push "${VPN_IMAGE}:${TAG}"
push_vpn() {
log "Pushing ${VPN_IMAGE}:${TAG}"
"${ENGINE}" push "${VPN_IMAGE}:${TAG}"
}
case "${TARGET}" in
app) push_app ;;
vpn) push_vpn ;;
all) push_app; push_vpn ;;
esac
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
echo ""
echo "============================================"
echo " Push complete!"
echo " Push complete!"
echo ""
echo " Images:"
echo " ${APP_IMAGE}:${TAG}"
echo " ${VPN_IMAGE}:${TAG}"
case "${TARGET}" in
app) echo " ${APP_IMAGE}:${TAG}" ;;
vpn) echo " ${VPN_IMAGE}:${TAG}" ;;
all) echo " ${APP_IMAGE}:${TAG}"; echo " ${VPN_IMAGE}:${TAG}" ;;
esac
echo ""
echo " Deploy on server:"
echo " podman login ${REGISTRY}"
echo " podman-compose -f podman-compose.prod.yml pull"
echo " podman-compose -f podman-compose.prod.yml up -d"
echo " ${ENGINE} login ${REGISTRY}"
echo " ${ENGINE} compose -f Docker/podman-compose.prod.yml pull"
echo " ${ENGINE} compose -f Docker/podman-compose.prod.yml up -d"
echo "============================================"

129
Docker/release.sh Normal file
View File

@@ -0,0 +1,129 @@
#!/usr/bin/env bash
#
# Bump the project version and push images to the registry.
#
# Usage:
# ./release.sh
#
# The current version is stored in VERSION (next to this script).
# You will be asked whether to bump major, minor, or patch.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VERSION_FILE="${SCRIPT_DIR}/VERSION"
# ---------------------------------------------------------------------------
# Read current version
# ---------------------------------------------------------------------------
if [[ ! -f "${VERSION_FILE}" ]]; then
echo "0.0.0" > "${VERSION_FILE}"
fi
CURRENT="$(cat "${VERSION_FILE}")"
# Strip leading 'v' for arithmetic
VERSION="${CURRENT#v}"
IFS='.' read -r MAJOR MINOR PATCH <<< "${VERSION}"
echo "============================================"
echo " AniWorld — Release"
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}"
echo "============================================"
echo ""
echo "Which image(s) would you like to release?"
echo " 1) app (Dockerfile.app)"
echo " 2) vpn (Containerfile)"
echo " 3) all (both images)"
echo ""
read -rp "Enter choice [1/2/3]: " TARGET_CHOICE
case "${TARGET_CHOICE}" in
1) TARGET="app" ;;
2) TARGET="vpn" ;;
3) TARGET="all" ;;
*)
echo "Invalid choice. Aborting." >&2
exit 1
;;
esac
echo ""
echo "How would you like to bump the version?"
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.$((PATCH + 1)))"
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.$((MINOR + 1)).0)"
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH} → v$((MAJOR + 1)).0.0)"
echo ""
read -rp "Enter choice [1/2/3]: " CHOICE
case "${CHOICE}" in
1) NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))" ;;
2) NEW_TAG="v${MAJOR}.$((MINOR + 1)).0" ;;
3) NEW_TAG="v$((MAJOR + 1)).0.0" ;;
*)
echo "Invalid choice. Aborting." >&2
exit 1
;;
esac
echo ""
echo "New version: ${NEW_TAG}"
echo "Target: ${TARGET}"
read -rp "Confirm? [y/N]: " CONFIRM
if [[ ! "${CONFIRM}" =~ ^[yY]$ ]]; then
echo "Aborted."
exit 0
fi
# ---------------------------------------------------------------------------
# Write new version
# ---------------------------------------------------------------------------
echo "${NEW_TAG}" > "${VERSION_FILE}"
echo "Version file updated → ${VERSION_FILE}"
# Keep root package.json in sync.
FRONT_VERSION="${NEW_TAG#v}"
FRONT_PKG="${SCRIPT_DIR}/../package.json"
if [[ -f "${FRONT_PKG}" ]]; then
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_PKG}"
echo "package.json version updated → ${FRONT_VERSION}"
else
echo "Warning: package.json not found, skipping package.json version sync" >&2
fi
# Keep root pyproject.toml in sync.
BACKEND_PYPROJECT="${SCRIPT_DIR}/../pyproject.toml"
if [[ -f "${BACKEND_PYPROJECT}" ]]; then
# Update version under [project] section if present
if grep -q '^\[project\]' "${BACKEND_PYPROJECT}"; then
sed -i "/^\[project\]/,/^\[/ s/^version = \".*\"/version = \"${FRONT_VERSION}\"/" "${BACKEND_PYPROJECT}"
else
sed -i "s/^version = \".*\"/version = \"${FRONT_VERSION}\"/" "${BACKEND_PYPROJECT}"
fi
echo "pyproject.toml version updated → ${FRONT_VERSION}"
else
echo "Warning: pyproject.toml not found, skipping pyproject.toml version sync" >&2
fi
# ---------------------------------------------------------------------------
# Push containers
# ---------------------------------------------------------------------------
bash "${SCRIPT_DIR}/push.sh" "${TARGET}" "${NEW_TAG}"
bash "${SCRIPT_DIR}/push.sh" "${TARGET}"
# ---------------------------------------------------------------------------
# Git tag (local only; push after container build)
# ---------------------------------------------------------------------------
cd "${SCRIPT_DIR}/.."
git add Docker/VERSION package.json pyproject.toml
git commit -m "chore: bump version"
git tag -a "${NEW_TAG}" -m "Release ${NEW_TAG}"
echo "Local git commit + tag ${NEW_TAG} created."
# ---------------------------------------------------------------------------
# Push git commits & tag
# ---------------------------------------------------------------------------
git push origin HEAD
git push origin "${NEW_TAG}"
echo "Git commit and tag ${NEW_TAG} pushed."

View File

@@ -6,23 +6,29 @@ Verifies:
2. The container starts and becomes healthy.
3. The public IP inside the VPN differs from the host IP.
4. Kill switch blocks traffic when WireGuard is down.
5. AllowedIPs routes are set dynamically from the config.
Requirements:
- podman installed
- Root/sudo (NET_ADMIN capability)
- Root/sudo (NET_ADMIN capability) for container runtime tests
- A valid WireGuard config at ./wg0.conf (or ./nl.conf)
Usage:
# Build-only test (no sudo needed):
python3 -m pytest test_vpn.py::TestVPNImage::test_00_build_image -v
# Full integration test (requires sudo):
sudo python3 -m pytest test_vpn.py -v
# or
sudo python3 test_vpn.py
"""
import logging
import os
import subprocess
import sys
import time
import unittest
import os
logger = logging.getLogger(__name__)
@@ -35,6 +41,11 @@ STARTUP_TIMEOUT = 30 # seconds to wait for VPN to come up
HEALTH_POLL_INTERVAL = 2 # seconds between health checks
def is_root() -> bool:
"""Check if running as root."""
return os.geteuid() == 0
def run(cmd: list[str], timeout: int = 30, check: bool = True) -> subprocess.CompletedProcess:
"""Run a command and return the result."""
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=check)
@@ -55,6 +66,7 @@ class TestVPNImage(unittest.TestCase):
"""Test suite for the WireGuard VPN container."""
host_ip: str = ""
container_id: str = ""
@classmethod
def setUpClass(cls):
@@ -84,6 +96,12 @@ class TestVPNImage(unittest.TestCase):
assert result.returncode == 0, f"Build failed:\n{result.stderr}"
logger.info("Image built successfully.")
# Skip container runtime tests if not root
if not is_root():
logger.warning("Not running as root — skipping container runtime tests.")
cls.container_id = ""
return
# ── 3. Start the container ──
logger.info("Starting container '%s'...", CONTAINER_NAME)
result = run(
@@ -120,6 +138,8 @@ class TestVPNImage(unittest.TestCase):
@classmethod
def tearDownClass(cls):
"""Stop and remove the container."""
if not is_root():
return
logger.info("Cleaning up test container...")
subprocess.run(["podman", "rm", "-f", CONTAINER_NAME], capture_output=True, check=False)
logger.info("Cleanup complete.")
@@ -144,10 +164,22 @@ class TestVPNImage(unittest.TestCase):
)
return result.stdout.strip()
def _skip_if_not_root(self):
"""Skip test if not running as root."""
if not is_root():
self.skipTest("This test requires root/sudo privileges")
# ── Tests ────────────────────────────────────────────────
def test_00_build_image(self):
"""The image builds successfully."""
# This is already verified in setUpClass, just confirm here
result = run(["podman", "images", "--format", "{{.Repository}}:{{.Tag}}"])
self.assertIn(IMAGE_NAME, result.stdout, "Image was not built")
def test_01_ip_differs_from_host(self):
"""Public IP inside VPN is different from host IP."""
self._skip_if_not_root()
vpn_ip = self._get_vpn_ip()
logger.info("VPN public IP: %s", vpn_ip)
logger.info("Host public IP: %s", self.host_ip)
@@ -161,12 +193,42 @@ class TestVPNImage(unittest.TestCase):
def test_02_wireguard_interface_exists(self):
"""The wg0 interface is present in the container."""
self._skip_if_not_root()
result = podman_exec(CONTAINER_NAME, ["wg", "show", "wg0"])
self.assertEqual(result.returncode, 0, f"wg show failed:\n{result.stderr}")
self.assertIn("peer", result.stdout.lower(), "No peer information in wg show output")
# AllowedIPs should be present in wg show output
self.assertIn("allowed ips", result.stdout.lower(), "AllowedIPs not found in wg show output")
def test_03_kill_switch_blocks_traffic(self):
def test_03_allowedips_routes_set(self):
"""Routes are set dynamically based on AllowedIPs from config."""
self._skip_if_not_root()
# Check that routes exist for the AllowedIPs
result = podman_exec(CONTAINER_NAME, ["ip", "route", "show", "dev", "wg0"])
self.assertEqual(result.returncode, 0, f"ip route show failed:\n{result.stderr}")
# The config has AllowedIPs = 0.0.0.0/0, which should result in:
# 0.0.0.0/1 dev wg0 and 128.0.0.0/1 dev wg0
self.assertIn("0.0.0.0/1", result.stdout, "Route 0.0.0.0/1 not found")
self.assertIn("128.0.0.0/1", result.stdout, "Route 128.0.0.0/1 not found")
# Make sure there is NO default route through wg0 (Table = off should prevent this)
self.assertNotIn("default dev wg0", result.stdout, "Default route through wg0 found — Table = off not working!")
logger.info("AllowedIPs routes verified: %s", result.stdout.strip())
def test_03b_dns_configured(self):
"""DNS is configured correctly with multiple nameserver lines."""
self._skip_if_not_root()
result = podman_exec(CONTAINER_NAME, ["cat", "/etc/resolv.conf"])
self.assertEqual(result.returncode, 0, f"cat /etc/resolv.conf failed:\n{result.stderr}")
# Should have two separate nameserver lines, not one with commas
self.assertIn("nameserver 198.18.0.1", result.stdout, "DNS 198.18.0.1 not found")
self.assertIn("nameserver 198.18.0.2", result.stdout, "DNS 198.18.0.2 not found")
# Make sure there are no commas in nameserver lines
self.assertNotIn("nameserver 198.18.0.1,198.18.0.2", result.stdout, "DNS servers written on one line with comma!")
logger.info("DNS config verified: %s", result.stdout.strip())
def test_04_kill_switch_blocks_traffic(self):
"""When WireGuard is down, traffic is blocked (kill switch)."""
self._skip_if_not_root()
# Bring down the WireGuard interface by deleting it
down_result = podman_exec(CONTAINER_NAME, ["ip", "link", "del", "wg0"], timeout=10)
self.assertEqual(down_result.returncode, 0, f"ip link del wg0 failed:\n{down_result.stderr}")

View File

@@ -1,10 +1,18 @@
[Interface]
PrivateKey = iO5spIue/6ciwUoR95hYtuxdtQxV/Q9EOoQ/jHe18kM=
Address = 10.2.0.2/32
DNS = 10.2.0.1
PrivateKey = EPRa2f/v72LvIXAY4yqIRJifsSb+nCcYHqC2rwA94UI=
Address = 100.64.244.78/32
#DNS = 198.18.0.1,198.18.0.2
DNS = 8.8.8.8
# Route zum VPN-Server direkt über dein lokales Netz
PostUp = ip route add 91.148.236.64 via 192.168.178.1 dev wlp4s0f0
PostUp = ip route add 192.168.178.0/24 via 192.168.178.1 dev wlp4s0f0
PostDown = ip route del 91.148.236.64 via 192.168.178.1 dev wlp4s0f0
PostDown = ip route del 192.168.178.0/24 via 192.168.178.1 dev wlp4s0f0
[Peer]
PublicKey = J4XVdtoBVc/EoI2Yk673Oes97WMnQSH5KfamZNjtM2s=
PublicKey = KgTUh3KLijVluDvNpzDCJJfrJ7EyLzYLmdHCksG4sRg=
AllowedIPs = 0.0.0.0/0
Endpoint = 185.183.34.149:51820
Endpoint = 91.148.236.64:51820
PersistentKeepalive = 25

View File

@@ -1285,7 +1285,7 @@ Basic health check endpoint.
{
"status": "healthy",
"timestamp": "2025-12-13T10:30:00.000Z",
"version": "1.0.0"
"version": "1.0.1"
}
```
@@ -1303,7 +1303,7 @@ Comprehensive health check with database, filesystem, and system metrics.
{
"status": "healthy",
"timestamp": "2025-12-13T10:30:00.000Z",
"version": "1.0.0",
"version": "1.0.1",
"dependencies": {
"database": {
"status": "healthy",

View File

@@ -74,7 +74,7 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
Provides `parse_nfo_tags()`, `find_missing_tags()`, `nfo_needs_repair()`, and
`NfoRepairService.repair_series()`. 13 required tags are checked.
- **`perform_nfo_repair_scan()`
(`src/server/services/initialization_service.py`)**: New async function
(`src/server/services/folder_scan_service.py`)**: New async function
that iterates every series directory, checks whether `tvshow.nfo` is missing
required tags using `nfo_needs_repair()`, and queues the series for background
reload via `asyncio.create_task`. Skips gracefully when `tmdb_api_key` or

View File

@@ -144,7 +144,7 @@ Location: `data/config.json`
"master_password_hash": "$pbkdf2-sha256$...",
"anime_directory": "/path/to/anime"
},
"version": "1.0.0"
"version": "1.0.1"
}
```

View File

@@ -815,8 +815,7 @@ This calls `NFOService.update_tvshow_nfo()` directly and overwrites the existing
| File | Purpose |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `src/core/services/nfo_repair_service.py` | `REQUIRED_TAGS`, `parse_nfo_tags`, `find_missing_tags`, `nfo_needs_repair`, `NfoRepairService` |
| `src/server/services/initialization_service.py` | `perform_nfo_repair_scan` — invoked from `FolderScanService` |
| `src/server/services/folder_scan_service.py` | Calls `perform_nfo_repair_scan` during the scheduled daily folder scan |
| `src/server/services/folder_scan_service.py` | `perform_nfo_repair_scan` — invoked during the scheduled daily folder scan |
---

View File

@@ -1,10 +0,0 @@
review frontend code and check for architektre issues
write the tasks in Task.md
for each task add the following informations
where is that found
goal. how it should be
possibale traps and issues
docs changes needed
why this is needed

View File

@@ -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"
}

View File

@@ -1,6 +1,6 @@
{
"name": "aniworld-web",
"version": "1.0.0",
"version": "1.1.12",
"description": "Aniworld Anime Download Manager - Web Frontend",
"type": "module",
"scripts": {

View File

@@ -15,7 +15,7 @@ import os
import re
import traceback
import uuid
from typing import Iterable, Iterator, Optional
from typing import Callable, Iterable, Iterator, Optional
from events import Events
@@ -43,12 +43,17 @@ class SerieScanner:
scanner = SerieScanner("/path/to/anime", loader)
scanner.scan()
# Results are in scanner.keyDict
# With DB lookup fallback:
scanner = SerieScanner("/path/to/anime", loader,
db_lookup=lambda folder: my_db.get_by_folder(folder))
"""
def __init__(
self,
basePath: str,
loader: Loader,
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
) -> None:
"""
Initialize the SerieScanner.
@@ -56,8 +61,12 @@ class SerieScanner:
Args:
basePath: Base directory containing anime series
loader: Loader instance for fetching series information
callback_manager: Optional callback manager for progress updates
db_lookup: Optional callable ``(folder_name) -> Serie | None``.
When provided, it is called as a fallback when neither a
``key`` file nor a ``data`` file is found in the folder.
This allows the database to supply the series key for
folders that have never had a local key file.
Raises:
ValueError: If basePath is invalid or doesn't exist
"""
@@ -75,6 +84,7 @@ class SerieScanner:
self.directory: str = abs_path
self.keyDict: dict[str, Serie] = {}
self.loader: Loader = loader
self._db_lookup: Optional[Callable[[str], Optional[Serie]]] = db_lookup
self._current_operation_id: Optional[str] = None
self.events = Events()
@@ -268,6 +278,30 @@ class SerieScanner:
)
serie = self.__read_data_from_file(folder)
if serie is None or not serie.key or not serie.key.strip():
# Fallback: ask the database for a matching series
if self._db_lookup is not None:
try:
serie = self._db_lookup(folder)
if serie:
logger.info(
"DB lookup resolved folder '%s' -> key='%s'",
folder,
serie.key,
)
except Exception as exc:
logger.warning(
"DB lookup failed for folder '%s': %s",
folder,
exc,
)
serie = None
if serie is None or not serie.key or not serie.key.strip():
logger.warning(
"No key or data file found for folder '%s', skipping",
folder,
)
if (
serie is not None
and serie.key

View File

@@ -14,7 +14,7 @@ import asyncio
import logging
import os
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional
from events import Events
@@ -143,12 +143,16 @@ class SeriesApp:
def __init__(
self,
directory_to_search: str,
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
):
"""
Initialize SeriesApp.
Args:
directory_to_search: Base directory for anime series
db_lookup: Optional callable ``(folder_name) -> Serie | None``
passed through to ``SerieScanner`` as a fallback key source
when no local ``key`` or ``data`` file exists.
"""
self.directory_to_search = directory_to_search
@@ -162,7 +166,7 @@ class SeriesApp:
self.loaders = Loaders()
self.loader = self.loaders.GetLoader(key="aniworld.to")
self.serie_scanner = SerieScanner(
directory_to_search, self.loader
directory_to_search, self.loader, db_lookup=db_lookup
)
# Skip automatic loading from data files - series will be loaded
# from database by the service layer during application setup
@@ -441,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 = (

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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}")

View File

@@ -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

View File

@@ -22,7 +22,7 @@ class HealthStatus(BaseModel):
status: str
timestamp: str
version: str = "1.0.0"
version: str = "1.0.1"
service: str = "aniworld-api"
series_app_initialized: bool = False
anime_directory_configured: bool = False
@@ -60,7 +60,7 @@ class DetailedHealthStatus(BaseModel):
status: str
timestamp: str
version: str = "1.0.0"
version: str = "1.0.1"
dependencies: DependencyHealth
startup_time: datetime

View File

@@ -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()

View File

@@ -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:

View File

@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
# Schema Version Constants
# =============================================================================
CURRENT_SCHEMA_VERSION = "1.0.0"
CURRENT_SCHEMA_VERSION = "1.0.1"
SCHEMA_VERSION_TABLE = "schema_version"
# Expected tables in the current schema
@@ -319,7 +319,7 @@ async def get_schema_version(engine: Optional[AsyncEngine] = None) -> str:
engine: Optional database engine (uses default if not provided)
Returns:
Schema version string (e.g., "1.0.0", "empty", "unknown")
Schema version string (e.g., "1.0.1", "empty", "unknown")
"""
if engine is None:
engine = get_engine()

View File

@@ -148,7 +148,27 @@ class AnimeSeriesService:
select(AnimeSeries).where(AnimeSeries.key == key)
)
return result.scalar_one_or_none()
@staticmethod
def get_by_folder_sync(db: Session, folder: str) -> Optional[AnimeSeries]:
"""Look up an anime series by its filesystem folder name (sync).
Intended as a fallback for ``SerieScanner`` when neither a ``key``
file nor a ``data`` file exists on disk for a given folder.
Args:
db: Synchronous database session (from ``get_sync_session``).
folder: Filesystem folder name to match (e.g.
``"Rooster Fighter (2026)"``).
Returns:
``AnimeSeries`` instance or ``None`` if not found.
"""
result = db.execute(
select(AnimeSeries).where(AnimeSeries.folder == folder)
)
return result.scalar_one_or_none()
@staticmethod
async def get_all(
db: AsyncSession,

View File

@@ -480,7 +480,7 @@ async def lifespan(_application: FastAPI):
app = FastAPI(
title="Aniworld Download Manager",
description="Modern web interface for Aniworld anime download management",
version="1.0.0",
version="1.0.1",
docs_url="/api/docs",
redoc_url="/api/redoc",
lifespan=lifespan

View File

@@ -44,7 +44,7 @@ class ConfigService:
"""
# Current configuration schema version
CONFIG_VERSION = "1.0.0"
CONFIG_VERSION = "1.0.1"
def __init__(
self,

View File

@@ -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)

View File

@@ -13,8 +13,8 @@ from typing import Optional
import structlog
from lxml import etree
from src.config.settings import settings as _settings
from src.core.utils.image_downloader import ImageDownloader
from src.server.services.initialization_service import perform_nfo_repair_scan
logger = structlog.get_logger(__name__)
@@ -24,6 +24,101 @@ _TMDB_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
# Semaphore to limit concurrent poster image downloads to 3.
_POSTER_DOWNLOAD_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
# Semaphore to limit concurrent NFO repair TMDB operations to 3.
_NFO_REPAIR_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
async def _repair_one_series(series_dir: Path, series_name: str) -> None:
"""Repair a single series NFO in isolation.
Creates a fresh :class:`NFOService` and :class:`NfoRepairService` per
invocation so that each repair owns its own ``aiohttp`` session/connector
and concurrent tasks cannot interfere with each other.
A module-level semaphore (``_NFO_REPAIR_SEMAPHORE``) limits the number of
simultaneous TMDB requests to avoid rate-limiting.
Any exception is caught and logged so the asyncio task never silently
drops an unhandled error.
Args:
series_dir: Absolute path to the series folder.
series_name: Human-readable series name for log messages.
"""
from src.core.services.nfo_factory import NFOServiceFactory
from src.core.services.nfo_repair_service import NfoRepairService
async with _NFO_REPAIR_SEMAPHORE:
try:
factory = NFOServiceFactory()
nfo_service = factory.create()
repair_service = NfoRepairService(nfo_service)
await repair_service.repair_series(series_dir, series_name)
except Exception as exc: # pylint: disable=broad-except
logger.error(
"NFO repair failed for %s: %s",
series_name,
exc,
)
async def perform_nfo_repair_scan(background_loader=None) -> None:
"""Scan all series folders and repair incomplete tvshow.nfo files.
Called from ``FolderScanService.run_folder_scan()`` during the scheduled
daily folder scan (not on every startup). Checks each subfolder of
``settings.anime_directory`` for a ``tvshow.nfo`` and calls
``_repair_one_series`` for every file with absent or empty required tags.
Each repair task creates its own isolated :class:`NFOService` /
:class:`TMDBClient` so concurrent tasks never share an ``aiohttp``
session — this prevents "Connector is closed" errors when many repairs
run in parallel. A semaphore caps TMDB concurrency at 3 to stay within
rate limits.
The ``background_loader`` parameter is accepted for backwards-compatibility
but is no longer used.
Args:
background_loader: Unused. Kept to avoid breaking call-sites.
"""
from src.core.services.nfo_repair_service import nfo_needs_repair
if not _settings.tmdb_api_key:
logger.warning("NFO repair scan skipped — TMDB API key not configured")
return
if not _settings.anime_directory:
logger.warning("NFO repair scan skipped — anime directory not configured")
return
anime_dir = Path(_settings.anime_directory)
if not anime_dir.is_dir():
logger.warning("NFO repair scan skipped — anime directory not found: %s", anime_dir)
return
queued = 0
total = 0
for series_dir in sorted(anime_dir.iterdir()):
if not series_dir.is_dir():
continue
nfo_path = series_dir / "tvshow.nfo"
if not nfo_path.exists():
continue
total += 1
series_name = series_dir.name
if nfo_needs_repair(nfo_path):
queued += 1
# Each task creates its own NFOService so connectors are isolated.
asyncio.create_task(
_repair_one_series(series_dir, series_name),
name=f"nfo_repair:{series_name}",
)
logger.info(
"NFO repair scan complete: %d of %d series queued for repair",
queued,
total,
)
class FolderScanServiceError(Exception):
"""Service-level exception for folder-scan operations."""

View File

@@ -377,101 +377,6 @@ async def perform_nfo_scan_if_needed(progress_service=None):
)
_NFO_REPAIR_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
async def _repair_one_series(series_dir: Path, series_name: str) -> None:
"""Repair a single series NFO in isolation.
Creates a fresh :class:`NFOService` and :class:`NfoRepairService` per
invocation so that each repair owns its own ``aiohttp`` session/connector
and concurrent tasks cannot interfere with each other.
A module-level semaphore (``_NFO_REPAIR_SEMAPHORE``) limits the number of
simultaneous TMDB requests to avoid rate-limiting.
Any exception is caught and logged so the asyncio task never silently
drops an unhandled error.
Args:
series_dir: Absolute path to the series folder.
series_name: Human-readable series name for log messages.
"""
from src.core.services.nfo_factory import NFOServiceFactory
from src.core.services.nfo_repair_service import NfoRepairService
async with _NFO_REPAIR_SEMAPHORE:
try:
factory = NFOServiceFactory()
nfo_service = factory.create()
repair_service = NfoRepairService(nfo_service)
await repair_service.repair_series(series_dir, series_name)
except Exception as exc: # pylint: disable=broad-except
logger.error(
"NFO repair failed for %s: %s",
series_name,
exc,
)
async def perform_nfo_repair_scan(background_loader=None) -> None:
"""Scan all series folders and repair incomplete tvshow.nfo files.
Called from ``FolderScanService.run_folder_scan()`` during the scheduled
daily folder scan (not on every startup). Checks each subfolder of
``settings.anime_directory`` for a ``tvshow.nfo`` and calls
``_repair_one_series`` for every file with absent or empty required tags.
Each repair task creates its own isolated :class:`NFOService` /
:class:`TMDBClient` so concurrent tasks never share an ``aiohttp``
session — this prevents "Connector is closed" errors when many repairs
run in parallel. A semaphore caps TMDB concurrency at 3 to stay within
rate limits.
The ``background_loader`` parameter is accepted for backwards-compatibility
but is no longer used.
Args:
background_loader: Unused. Kept to avoid breaking call-sites.
"""
from src.core.services.nfo_repair_service import nfo_needs_repair
if not settings.tmdb_api_key:
logger.warning("NFO repair scan skipped — TMDB API key not configured")
return
if not settings.anime_directory:
logger.warning("NFO repair scan skipped — anime directory not configured")
return
anime_dir = Path(settings.anime_directory)
if not anime_dir.is_dir():
logger.warning("NFO repair scan skipped — anime directory not found: %s", anime_dir)
return
queued = 0
total = 0
for series_dir in sorted(anime_dir.iterdir()):
if not series_dir.is_dir():
continue
nfo_path = series_dir / "tvshow.nfo"
if not nfo_path.exists():
continue
total += 1
series_name = series_dir.name
if nfo_needs_repair(nfo_path):
queued += 1
# Each task creates its own NFOService so connectors are isolated.
asyncio.create_task(
_repair_one_series(series_dir, series_name),
name=f"nfo_repair:{series_name}",
)
logger.info(
"NFO repair scan complete: %d of %d series queued for repair",
queued,
total,
)
async def _check_media_scan_status() -> bool:
"""Check if initial media scan has been completed.

View File

@@ -57,6 +57,44 @@ _rate_limit_lock = Lock()
_RATE_LIMIT_WINDOW_SECONDS = 60.0
def _make_db_lookup():
"""Build a synchronous ``(folder) -> Serie | None`` callable for SerieScanner.
The returned function opens a short-lived sync DB session, queries for a
series whose ``folder`` column matches the given name, and converts the
ORM row to a ``Serie`` domain object. Returns ``None`` when the DB is not
yet initialised or no matching row is found.
"""
from src.core.entities.series import Serie
def _lookup(folder: str) -> Optional["Serie"]:
try:
from src.server.database.connection import get_sync_session
from src.server.database.service import AnimeSeriesService
db = get_sync_session()
try:
row = AnimeSeriesService.get_by_folder_sync(db, folder)
finally:
db.close()
if row is None:
return None
return Serie(
key=row.key,
name=row.name or "",
site=row.site,
folder=row.folder,
episodeDict={},
year=row.year,
)
except RuntimeError:
# DB not initialised yet (e.g. first boot before init_db())
return None
return _lookup
def get_series_app() -> SeriesApp:
"""
Dependency to get SeriesApp instance.
@@ -134,7 +172,7 @@ def get_series_app() -> SeriesApp:
),
)
_series_app = SeriesApp(anime_dir)
_series_app = SeriesApp(anime_dir, db_lookup=_make_db_lookup())
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,

View File

@@ -48,7 +48,7 @@ def get_base_context(
"request": request,
"title": title,
"app_name": "Aniworld Download Manager",
"version": "1.0.0",
"version": "1.0.1",
"static_v": STATIC_VERSION,
}

View File

@@ -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."""

View 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}"
)

View File

@@ -67,7 +67,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
@pytest.mark.asyncio
async def test_incomplete_nfo_series_scheduled_for_repair(self, tmp_path):
"""Series whose tvshow.nfo is missing required tags are scheduled via asyncio.create_task."""
from src.server.services.initialization_service import perform_nfo_repair_scan
from src.server.services.folder_scan_service import perform_nfo_repair_scan
series_dir = tmp_path / "IncompleteAnime"
series_dir.mkdir()
@@ -83,7 +83,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
mock_repair_service.repair_series = AsyncMock(return_value=True)
with patch(
"src.server.services.initialization_service.settings", mock_settings
"src.server.services.folder_scan_service._settings", mock_settings
), patch(
"src.core.services.nfo_repair_service.nfo_needs_repair",
return_value=True,
@@ -103,7 +103,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
@pytest.mark.asyncio
async def test_complete_nfo_series_not_scheduled(self, tmp_path):
"""Series whose tvshow.nfo has all required tags are not scheduled for repair."""
from src.server.services.initialization_service import perform_nfo_repair_scan
from src.server.services.folder_scan_service import perform_nfo_repair_scan
series_dir = tmp_path / "CompleteAnime"
series_dir.mkdir()
@@ -116,7 +116,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
mock_settings.anime_directory = str(tmp_path)
with patch(
"src.server.services.initialization_service.settings", mock_settings
"src.server.services.folder_scan_service._settings", mock_settings
), patch(
"src.core.services.nfo_repair_service.nfo_needs_repair",
return_value=False,

View 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

View File

@@ -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."""

View File

@@ -472,7 +472,7 @@ async def test_validate_schema_with_inspection_error():
def test_schema_constants():
"""Test that schema constants are properly defined."""
assert CURRENT_SCHEMA_VERSION == "1.0.0"
assert CURRENT_SCHEMA_VERSION == "1.0.1"
assert len(EXPECTED_TABLES) == 5
assert "anime_series" in EXPECTED_TABLES
assert "episodes" in EXPECTED_TABLES

View File

@@ -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."""

View File

@@ -20,6 +20,7 @@ from src.server.services.folder_scan_service import (
_TMDB_SEMAPHORE,
FolderScanService,
FolderScanServiceError,
perform_nfo_repair_scan,
)
# ---------------------------------------------------------------------------

View File

@@ -25,7 +25,7 @@ async def test_basic_health_check():
assert isinstance(result, HealthStatus)
assert result.status == "healthy"
assert result.version == "1.0.0"
assert result.version == "1.0.1"
assert result.service == "aniworld-api"
assert result.timestamp is not None
assert result.series_app_initialized is False

View File

@@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
from src.server.services.folder_scan_service import perform_nfo_repair_scan
from src.server.services.initialization_service import (
_check_initial_scan_status,
_check_media_scan_status,
@@ -27,7 +28,6 @@ from src.server.services.initialization_service import (
_validate_anime_directory,
perform_initial_setup,
perform_media_scan_if_needed,
perform_nfo_repair_scan,
perform_nfo_scan_if_needed,
)
@@ -771,7 +771,7 @@ class TestPerformNfoRepairScan:
mock_settings.anime_directory = str(tmp_path)
with patch(
"src.server.services.initialization_service.settings", mock_settings
"src.server.services.folder_scan_service._settings", mock_settings
):
await perform_nfo_repair_scan()
@@ -785,7 +785,7 @@ class TestPerformNfoRepairScan:
mock_settings.anime_directory = ""
with patch(
"src.server.services.initialization_service.settings", mock_settings
"src.server.services.folder_scan_service._settings", mock_settings
):
await perform_nfo_repair_scan()
@@ -805,7 +805,7 @@ class TestPerformNfoRepairScan:
mock_repair_service.repair_series = AsyncMock(return_value=True)
with patch(
"src.server.services.initialization_service.settings", mock_settings
"src.server.services.folder_scan_service._settings", mock_settings
), patch(
"src.core.services.nfo_repair_service.nfo_needs_repair",
return_value=True,
@@ -835,7 +835,7 @@ class TestPerformNfoRepairScan:
mock_settings.anime_directory = str(tmp_path)
with patch(
"src.server.services.initialization_service.settings", mock_settings
"src.server.services.folder_scan_service._settings", mock_settings
), patch(
"src.core.services.nfo_repair_service.nfo_needs_repair",
return_value=False,
@@ -865,7 +865,7 @@ class TestPerformNfoRepairScan:
mock_repair_service.repair_series = AsyncMock(return_value=True)
with patch(
"src.server.services.initialization_service.settings", mock_settings
"src.server.services.folder_scan_service._settings", mock_settings
), patch(
"src.core.services.nfo_repair_service.nfo_needs_repair",
return_value=True,

View File

@@ -187,7 +187,7 @@ class TestTemplateHelpers:
assert context["request"] == mock_request
assert context["title"] == "Test Title"
assert context["app_name"] == "Aniworld Download Manager"
assert context["version"] == "1.0.0"
assert context["version"] == "1.0.1"
def test_get_base_context_default_title(self):
"""Test getting base context with default title."""

View File

@@ -1,5 +1,6 @@
"""Tests for SerieScanner class - file-based operations."""
import logging
import os
import tempfile
from unittest.mock import MagicMock, patch
@@ -651,4 +652,187 @@ class TestScanProgressEvents:
error_handler.assert_called_once()
call_data = error_handler.call_args[0][0]
assert call_data["recoverable"] is True
assert call_data["recoverable"] is True
class TestDbLookupFallback:
"""Tests for the db_lookup callback in SerieScanner."""
def _make_scanner(self, tmp_dir, mock_loader, db_lookup=None):
"""Create a scanner with an optional db_lookup."""
# Create a folder with an mp4 but NO key/data file
folder = os.path.join(tmp_dir, "Rooster Fighter (2026)")
os.makedirs(folder, exist_ok=True)
mp4 = os.path.join(folder, "Rooster Fighter - S01E001 - (German Dub).mp4")
with open(mp4, "w") as f:
f.write("dummy")
return SerieScanner(tmp_dir, mock_loader, db_lookup=db_lookup)
def test_db_lookup_stored_on_init(self, temp_directory, mock_loader):
"""db_lookup callable should be stored as _db_lookup."""
lookup = MagicMock(return_value=None)
scanner = SerieScanner(temp_directory, mock_loader, db_lookup=lookup)
assert scanner._db_lookup is lookup
def test_no_db_lookup_defaults_to_none(self, temp_directory, mock_loader):
"""Without db_lookup, _db_lookup should be None."""
scanner = SerieScanner(temp_directory, mock_loader)
assert scanner._db_lookup is None
def test_db_lookup_called_when_no_files(self, mock_loader):
"""db_lookup is called when neither key nor data file exists."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
lookup = MagicMock(return_value=None)
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
with patch.object(scanner, 'get_total_to_scan', return_value=1), \
patch.object(
scanner,
'_SerieScanner__get_missing_episodes_and_season',
return_value=({}, "aniworld.to"),
):
scanner.scan()
lookup.assert_called_once_with("Rooster Fighter (2026)")
def test_db_lookup_not_called_when_key_file_exists(self, mock_loader):
"""db_lookup is NOT called when a key file is present."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
folder = os.path.join(tmp_dir, "Rooster Fighter (2026)")
os.makedirs(folder, exist_ok=True)
mp4 = os.path.join(folder, "S01E001.mp4")
with open(mp4, "w") as f:
f.write("dummy")
with open(os.path.join(folder, "key"), "w") as f:
f.write("rooster-fighter")
lookup = MagicMock(return_value=None)
scanner = SerieScanner(tmp_dir, mock_loader, db_lookup=lookup)
with patch.object(scanner, 'get_total_to_scan', return_value=1), \
patch.object(
scanner,
'_SerieScanner__get_missing_episodes_and_season',
return_value=({1: []}, "aniworld.to"),
), \
patch.object(
SerieScanner,
'_SerieScanner__read_data_from_file',
return_value=Serie(
key="rooster-fighter", name="", site="aniworld.to",
folder="Rooster Fighter (2026)", episodeDict={},
),
):
scanner.scan()
lookup.assert_not_called()
def test_db_lookup_resolves_serie_and_scans(self, mock_loader):
"""When db_lookup returns a Serie, scanning continues normally."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
resolved = Serie(
key="rooster-fighter",
name="Rooster Fighter",
site="aniworld.to",
folder="Rooster Fighter (2026)",
episodeDict={},
year=2026,
)
lookup = MagicMock(return_value=resolved)
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
with patch.object(scanner, 'get_total_to_scan', return_value=1), \
patch.object(
scanner,
'_SerieScanner__get_missing_episodes_and_season',
return_value=({1: [1, 2, 3]}, "aniworld.to"),
), \
patch.object(resolved, 'save_to_file'):
scanner.scan()
assert "rooster-fighter" in scanner.keyDict
assert scanner.keyDict["rooster-fighter"].episodeDict == {1: [1, 2, 3]}
def test_db_lookup_returns_none_folder_skipped(self, mock_loader):
"""When db_lookup returns None, the folder is skipped with a warning."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
lookup = MagicMock(return_value=None)
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
with patch.object(scanner, 'get_total_to_scan', return_value=1):
scanner.scan()
assert len(scanner.keyDict) == 0
def test_db_lookup_exception_skips_folder(self, mock_loader):
"""When db_lookup raises, the folder is skipped gracefully."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
lookup = MagicMock(side_effect=RuntimeError("DB offline"))
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
with patch.object(scanner, 'get_total_to_scan', return_value=1):
scanner.scan() # should not raise
assert len(scanner.keyDict) == 0
def test_db_lookup_warning_logged_when_no_files(
self, mock_loader, caplog
):
"""A warning is logged for folders without key/data file."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=None)
with caplog.at_level(logging.WARNING, logger="src.core.SerieScanner"):
with patch.object(scanner, 'get_total_to_scan', return_value=1):
scanner.scan()
assert any(
"Rooster Fighter (2026)" in record.message
for record in caplog.records
if record.levelname == "WARNING"
)
def test_db_lookup_info_logged_on_resolution(
self, mock_loader, caplog
):
"""An INFO log is emitted when db_lookup resolves a folder."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
resolved = Serie(
key="rooster-fighter",
name="",
site="aniworld.to",
folder="Rooster Fighter (2026)",
episodeDict={},
)
lookup = MagicMock(return_value=resolved)
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
with caplog.at_level(logging.INFO, logger="src.core.SerieScanner"), \
patch.object(scanner, 'get_total_to_scan', return_value=1), \
patch.object(
scanner,
'_SerieScanner__get_missing_episodes_and_season',
return_value=({}, "aniworld.to"),
), \
patch.object(resolved, 'save_to_file'):
scanner.scan()
assert any(
"rooster-fighter" in record.message
for record in caplog.records
if record.levelname == "INFO"
)

View File

@@ -30,7 +30,7 @@ class TestTemplateHelpers:
assert context["request"] == request
assert context["title"] == "Test Title"
assert context["app_name"] == "Aniworld Download Manager"
assert context["version"] == "1.0.0"
assert context["version"] == "1.0.1"
def test_get_base_context_default_title(self):
"""Test that default title is used."""