Compare commits

..

45 Commits

Author SHA1 Message Date
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
ceac22fc34 test: fix NFO workflow and background loader tests
- Add missing TMDB async mock methods (_ensure_session, close)
  to all TMDB mocks in test_nfo_workflow.py
- Refactor test_anime_add_nfo_isolation.py to mock get_nfo_factory()
  instead of asserting on series_app.nfo_service directly
- Patch get_nfo_factory in test_background_loader_service.py
  to align with factory-based NFOService creation

Fixes test failures caused by NFOService refactoring that introduced
explicit TMDB session lifecycle and NFO factory pattern.
2026-05-13 12:41:22 +02:00
9c0f7ce08d test: add tests for scheduled folder scan and startup NFO repair removal
Add comprehensive test coverage for Tasks 1.1–1.5 and 2.1:

- test_scheduler_config_model.py: folder_scan_enabled defaults, explicit
  values, backward compatibility with old configs, serialization roundtrip
- test_folder_scan_service.py (new): prerequisites, NFO repair integration,
  folder rename integration, poster check/download, semaphore values,
  NFO thumb URL extraction, full end-to-end scan flow
- test_scheduler_service.py: scheduler _perform_rescan integration with
  folder_scan_enabled (called when enabled, skipped when disabled, error
  handling and broadcasting), folder_scan_enabled in get_status output
- test_nfo_repair_startup.py: verify perform_nfo_repair_scan is NOT called
  during FastAPI lifespan startup and IS called from FolderScanService

All 90 tests pass.
2026-05-13 09:43:34 +02:00
756731cd5d feat: remove startup NFO repair, update docs and tests
- Remove NFO repair scan step from ARCHITECTURE.md startup sequence
- Update CHANGELOG.md: rephrase perform_nfo_repair_scan as scheduled scan
- Add test verifying perform_nfo_repair_scan is NOT called in lifespan
- Keep existing folder scan wiring tests and unit tests intact
- NFO_GUIDE.md already correctly describes scheduled scan behavior
2026-05-13 09:23:21 +02:00
eb0e6e8ccb fix: task 1.5 poster check + fix stuck tests
- Fix structlog format string in folder_scan_service (%(key)d -> kwargs)
- Add nfo_download_poster setting check before poster download
- Create missing NFO fixture files (tvshow.nfo.bad/good) for repair tests
- Fix test_context_used_in_logging to check all call args not format string
- Fix test_system_settings_integration isolation via reset_all_scans
2026-05-13 08:07:16 +02:00
eb2fc3c5ab feat: integrate NFO repair into scheduled folder scan
- Add FolderScanService.run_folder_scan() calling perform_nfo_repair_scan()
- Remove startup-time NFO repair from fastapi_app lifespan
- Update docs/NFO_GUIDE.md: repair now runs as part of daily scan
- Update tests to verify integration wiring
- Update ARCHITECTURE.md and scheduler_service for scan scheduling
2026-05-12 20:15:32 +02:00
c39ae9d0fc feat(scheduler): add folder_scan_enabled toggle to SchedulerConfig
- Add folder_scan_enabled boolean field (default false) to SchedulerConfig
- Update data/config.json example with new field
- Add checkbox to setup.html and include in JS payload
- Handle field in auth.py setup endpoint
- Expose field in scheduler API response
- Log and return field in scheduler_service.py
- Update docs/CONFIGURATION.md and docs/ARCHITECTURE.md
- Update index.html UI, app.js and scheduler-config.js handlers
- Verified backward compatibility: old configs load with default False
2026-05-11 21:02:05 +02:00
079f1f99e3 backup 2026-04-19 19:00:05 +02:00
9373f500d3 Commit remaining tracked changes 2026-04-19 18:57:26 +02:00
2274403899 Fix NFO plot fallback by using en-US search overview when German result is empty 2026-04-19 18:53:11 +02:00
6ad14c03b5 Task 3: remove non-reentrant TMDB context in NFOService and mark task done 2026-04-19 18:49:21 +02:00
b10cce0489 Task 2: guard SeriesApp NFOService init on NFOServiceFactory fallback and document config-only TMDB API key support 2026-04-19 18:46:30 +02:00
2aa184c870 Mark Task 1 done for NFOService per-task isolation 2026-04-19 18:41:04 +02:00
92bd55ada1 chore: apply pending code updates 2026-03-17 11:39:27 +01:00
e5fae0a0a2 docs: add logging instruction reference to tasks 2026-03-17 11:38:57 +01:00
151a08e033 fix: support missing/no-episodes library filters (API, UI, docs, tests) 2026-03-16 21:01:59 +01:00
109 changed files with 7324 additions and 2648 deletions

View File

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

1
Docker/VERSION Normal file
View File

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

View File

@@ -1,6 +1,14 @@
#!/bin/bash #!/bin/bash
set -e 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" INTERFACE="wg0"
MOUNT_CONFIG="/etc/wireguard/${INTERFACE}.conf" MOUNT_CONFIG="/etc/wireguard/${INTERFACE}.conf"
CONFIG_DIR="/run/wireguard" CONFIG_DIR="/run/wireguard"
@@ -64,9 +72,11 @@ setup_killswitch() {
iptables -A INPUT -i "$INTERFACE" -j ACCEPT iptables -A INPUT -i "$INTERFACE" -j ACCEPT
iptables -A OUTPUT -o "$INTERFACE" -j ACCEPT iptables -A OUTPUT -o "$INTERFACE" -j ACCEPT
# Allow DNS to the VPN DNS server (through wg0) # Allow DNS (VPN DNS servers are routed through wg0; allow before routing decision)
iptables -A OUTPUT -o "$INTERFACE" -p udp --dport 53 -j ACCEPT iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -o "$INTERFACE" -p tcp --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) # Allow DHCP (for container networking)
iptables -A OUTPUT -p udp --dport 67:68 -j ACCEPT iptables -A OUTPUT -p udp --dport 67:68 -j ACCEPT
@@ -120,27 +130,46 @@ start_vpn() {
ip link add "$INTERFACE" type wireguard ip link add "$INTERFACE" type wireguard
# Apply the WireGuard config (keys, peer, endpoint) # Apply the WireGuard config (keys, peer, endpoint)
# 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") 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 # Assign the address
ip -4 address add "$VPN_ADDRESS" dev "$INTERFACE" ip -4 address add "$VPN_ADDRESS" dev "$INTERFACE"
# Set MTU # Set MTU and bring up
ip link set mtu 1420 up dev "$INTERFACE" ip link set mtu 1420 up dev "$INTERFACE"
# 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_GW=$(ip route | grep '^default' | head -1 | awk '{print $3}')
DEFAULT_IF=$(ip route | grep '^default' | head -1 | awk '{print $5}') DEFAULT_IF=$(ip route | grep '^default' | head -1 | awk '{print $5}')
# Route VPN endpoint through the container's default gateway
if [ -n "$DEFAULT_GW" ] && [ -n "$DEFAULT_IF" ]; then
ip route add "$VPN_ENDPOINT/32" via "$DEFAULT_GW" dev "$DEFAULT_IF" 2>/dev/null || true
fi
# 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 ── # ── Policy routing: ensure responses to incoming LAN traffic go back via eth0 ──
if [ -n "$DEFAULT_GW" ] && [ -n "$DEFAULT_IF" ]; then if [ -n "$DEFAULT_GW" ] && [ -n "$DEFAULT_IF" ]; then
# Get the container's eth0 IP address (BusyBox-compatible, no grep -P) # Get the container's eth0 IP address (BusyBox-compatible, no grep -P)
@@ -155,14 +184,26 @@ start_vpn() {
fi fi
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') VPN_DNS=$(grep -i '^DNS' "$CONFIG_FILE" | head -1 | sed 's/.*= *//;s/ //g')
if [ -n "$VPN_DNS" ]; then if [ -n "$VPN_DNS" ]; then
echo "nameserver $VPN_DNS" > /etc/resolv.conf # Clear resolv.conf and add each DNS server on its own line
echo "[vpn] DNS set to ${VPN_DNS}" > /etc/resolv.conf
for dns in $(echo "$VPN_DNS" | tr ',' ' '); do
echo "nameserver $dns" >> /etc/resolv.conf
done
echo "[vpn] DNS set to: ${VPN_DNS}"
fi fi
echo "[vpn] WireGuard interface ${INTERFACE} is up." 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 +211,21 @@ start_vpn() {
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
stop_vpn() { stop_vpn() {
echo "[vpn] Stopping WireGuard interface ${INTERFACE}..." 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 ip link del "$INTERFACE" 2>/dev/null || true
} }
@@ -185,14 +241,19 @@ health_loop() {
while true; do while true; do
sleep "$CHECK_INTERVAL" sleep "$CHECK_INTERVAL"
if curl -sf --max-time 5 "http://$CHECK_HOST" > /dev/null 2>&1; then if ping -c 1 -W 5 "$CHECK_HOST" > /dev/null 2>&1; then
if [ "$failures" -gt 0 ]; then if [ "$failures" -gt 0 ]; then
echo "[health] VPN recovered." echo "[health] VPN recovered."
failures=0 failures=0
fi fi
else else
failures=$((failures + 1)) failures=$((failures + 1))
echo "[health] Ping failed ($failures/$max_failures)" echo "[health] Check failed ($failures/$max_failures) — ping ${CHECK_HOST} failed"
# Dump WireGuard stats to show if handshake is stale and how much data flows
echo "[health] wg stats:"
wg show "$INTERFACE" 2>/dev/null | grep -E 'latest handshake|transfer|endpoint' | sed 's/^/[health] /' || echo "[health] wg0 not found"
echo "[health] routes:"
ip route show | grep -E 'wg0|default' | sed 's/^/[health] /'
if [ "$failures" -ge "$max_failures" ]; then if [ "$failures" -ge "$max_failures" ]; then
echo "[health] VPN appears down. Restarting WireGuard..." echo "[health] VPN appears down. Restarting WireGuard..."
@@ -221,8 +282,81 @@ cleanup() {
trap cleanup SIGTERM SIGINT trap cleanup SIGTERM SIGINT
# ──────────────────────────────────────────────
# Startup connectivity checks — diagnose issues early
# ──────────────────────────────────────────────
check_vpn_connectivity() {
echo "[check] ── Startup connectivity checks ──"
# 1. Wait for WireGuard handshake (up to 15s)
local elapsed=0
local handshake_ts=0
echo "[check] Waiting for WireGuard handshake (up to 15s)..."
while [ "$elapsed" -lt 15 ]; do
handshake_ts=$(wg show "$INTERFACE" latest-handshakes 2>/dev/null | awk '{print $2}' | head -1)
if [ -n "$handshake_ts" ] && [ "$handshake_ts" != "0" ]; then
local age=$(( $(date +%s) - handshake_ts ))
echo "[check] OK Handshake established ${age}s ago"
break
fi
sleep 1
elapsed=$((elapsed + 1))
done
if [ "$elapsed" -ge 15 ]; then
echo "[check] FAIL No WireGuard handshake after 15s — tunnel is not up"
echo "[check] This container's public key (must be on the server):"
echo "[check] PublicKey = $(wg show "$INTERFACE" public-key 2>/dev/null || echo 'unknown')"
echo "[check] AllowedIPs = ${VPN_ADDRESS}"
echo "[check] Verify on server: wg show"
fi
# 2. Check whether traffic actually flows through the tunnel
echo "[check] Testing traffic through tunnel (ping ${CHECK_HOST})..."
local rx_before
rx_before=$(wg show "$INTERFACE" transfer 2>/dev/null | awk '{print $2}' | head -1)
if ping -c 1 -W 8 "${CHECK_HOST}" > /dev/null 2>&1; then
echo "[check] OK Traffic flows — tunnel is fully working"
else
local rx_after
rx_after=$(wg show "$INTERFACE" transfer 2>/dev/null | awk '{print $2}' | head -1)
echo "[check] FAIL ping ${CHECK_HOST} unreachable through tunnel"
if [ -n "$rx_before" ] && [ -n "$rx_after" ]; then
if [ "$rx_after" -le "$rx_before" ]; then
echo "[check] RX bytes unchanged (${rx_before}${rx_after})"
echo "[check] Server receives packets but does NOT route them back"
echo "[check] Fix on VPN server (${VPN_ENDPOINT}):"
echo "[check] sysctl net.ipv4.ip_forward # must output 1"
echo "[check] iptables -t nat -L POSTROUTING -v -n # must have MASQUERADE"
echo "[check] wg show # check peer + AllowedIPs"
else
echo "[check] RX increased (${rx_before}${rx_after}) — tunnel passes data"
echo "[check] Issue may be specific to ${CHECK_HOST} or DNS"
fi
fi
local transfer
transfer=$(wg show "$INTERFACE" transfer 2>/dev/null | awk '{printf "rx=%s tx=%s", $2, $3}')
echo "[check] wg transfer: ${transfer}"
fi
# 3. DNS check
echo "[check] Testing DNS resolution..."
if nslookup 1.1.1.1 > /dev/null 2>&1 || nslookup google.com > /dev/null 2>&1; then
echo "[check] OK DNS resolves"
else
echo "[check] FAIL DNS resolution failed"
echo "[check] resolv.conf: $(tr '\n' ' ' < /etc/resolv.conf)"
echo "[check] Check that DNS servers are reachable through wg0"
fi
echo "[check] ── End of checks ──"
}
# ── Main ── # ── Main ──
enable_forwarding enable_forwarding
setup_killswitch setup_killswitch
start_vpn start_vpn
check_vpn_connectivity
health_loop health_loop

View File

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

View File

@@ -7,6 +7,7 @@ services:
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
- SYS_MODULE - SYS_MODULE
- NET_RAW
sysctls: sysctls:
- net.ipv4.ip_forward=1 - net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1 - net.ipv4.conf.all.src_valid_mark=1

View File

@@ -1,15 +1,19 @@
#!/usr/bin/env bash #!/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: # Usage:
# ./push.sh # builds & pushes with tag "latest" # ./push.sh # builds & pushes app with tag "latest"
# ./push.sh v1.2.3 # builds & pushes with tag "v1.2.3" # ./push.sh app # builds & pushes app image
# ./push.sh v1.2.3 --no-build # pushes existing images only # ./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: # Prerequisites:
# podman login git.lpl-mind.de # podman login git.lpl-mind.de (or: docker login git.lpl-mind.de)
set -euo pipefail set -euo pipefail
@@ -23,12 +27,20 @@ PROJECT="aniworld"
APP_IMAGE="${REGISTRY}/${NAMESPACE}/${PROJECT}/app" APP_IMAGE="${REGISTRY}/${NAMESPACE}/${PROJECT}/app"
VPN_IMAGE="${REGISTRY}/${NAMESPACE}/${PROJECT}/vpn" VPN_IMAGE="${REGISTRY}/${NAMESPACE}/${PROJECT}/vpn"
TAG="${1:-latest}" # Parse arguments
TARGET="${1:-app}"
TAG="${2:-latest}"
SKIP_BUILD=false SKIP_BUILD=false
if [[ "${2:-}" == "--no-build" ]]; then if [[ "${3:-}" == "--no-build" ]]; then
SKIP_BUILD=true SKIP_BUILD=true
fi 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)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
@@ -36,62 +48,93 @@ PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log() { echo -e "\n>>> $*"; } 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 # Pre-flight checks
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
echo "============================================" echo "============================================"
echo " Aniworld — Build & Push" echo " AniWorld — Build & Push"
echo " Engine : ${ENGINE}"
echo " Registry : ${REGISTRY}" echo " Registry : ${REGISTRY}"
echo " Target : ${TARGET}"
echo " Tag : ${TAG}" echo " Tag : ${TAG}"
echo "============================================" echo "============================================"
command -v podman &>/dev/null || err "podman is not installed." log "Logging in to ${REGISTRY}"
"${ENGINE}" login "${REGISTRY}"
if ! podman login --get-login "${REGISTRY}" &>/dev/null; then
err "Not logged in. Run:\n podman login ${REGISTRY}"
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Build # Build
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if [[ "${SKIP_BUILD}" == false ]]; then build_app() {
log "Building app image → ${APP_IMAGE}:${TAG}" log "Building app image → ${APP_IMAGE}:${TAG}"
podman build \ "${ENGINE}" build \
-t "${APP_IMAGE}:${TAG}" \ -t "${APP_IMAGE}:${TAG}" \
-f "${SCRIPT_DIR}/Dockerfile.app" \ -f "${SCRIPT_DIR}/Dockerfile.app" \
"${PROJECT_ROOT}" "${PROJECT_ROOT}"
}
log "Building VPN image → ${VPN_IMAGE}:${TAG}" build_vpn() {
podman build \ log "Building vpn image → ${VPN_IMAGE}:${TAG}"
"${ENGINE}" build \
-t "${VPN_IMAGE}:${TAG}" \ -t "${VPN_IMAGE}:${TAG}" \
-f "${SCRIPT_DIR}/Containerfile" \ -f "${SCRIPT_DIR}/Containerfile" \
"${SCRIPT_DIR}" "${SCRIPT_DIR}"
}
if [[ "${SKIP_BUILD}" == false ]]; then
case "${TARGET}" in
app) build_app ;;
vpn) build_vpn ;;
all) build_app; build_vpn ;;
esac
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Push # Push
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log "Pushing ${APP_IMAGE}:${TAG}" push_app() {
podman push "${APP_IMAGE}:${TAG}" log "Pushing ${APP_IMAGE}:${TAG}"
"${ENGINE}" push "${APP_IMAGE}:${TAG}"
}
log "Pushing ${VPN_IMAGE}:${TAG}" push_vpn() {
podman push "${VPN_IMAGE}:${TAG}" 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 # Summary
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
echo "" echo ""
echo "============================================" echo "============================================"
echo " Push complete!" echo " Push complete!"
echo "" echo ""
echo " Images:" echo " Images:"
echo " ${APP_IMAGE}:${TAG}" case "${TARGET}" in
echo " ${VPN_IMAGE}:${TAG}" app) echo " ${APP_IMAGE}:${TAG}" ;;
vpn) echo " ${VPN_IMAGE}:${TAG}" ;;
all) echo " ${APP_IMAGE}:${TAG}"; echo " ${VPN_IMAGE}:${TAG}" ;;
esac
echo "" echo ""
echo " Deploy on server:" echo " Deploy on server:"
echo " podman login ${REGISTRY}" echo " ${ENGINE} login ${REGISTRY}"
echo " podman-compose -f podman-compose.prod.yml pull" echo " ${ENGINE} compose -f Docker/podman-compose.prod.yml pull"
echo " podman-compose -f podman-compose.prod.yml up -d" echo " ${ENGINE} compose -f Docker/podman-compose.prod.yml up -d"
echo "============================================" 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,22 +6,31 @@ Verifies:
2. The container starts and becomes healthy. 2. The container starts and becomes healthy.
3. The public IP inside the VPN differs from the host IP. 3. The public IP inside the VPN differs from the host IP.
4. Kill switch blocks traffic when WireGuard is down. 4. Kill switch blocks traffic when WireGuard is down.
5. AllowedIPs routes are set dynamically from the config.
Requirements: Requirements:
- podman installed - 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) - A valid WireGuard config at ./wg0.conf (or ./nl.conf)
Usage: 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 sudo python3 -m pytest test_vpn.py -v
# or # or
sudo python3 test_vpn.py sudo python3 test_vpn.py
""" """
import logging
import os
import subprocess import subprocess
import sys
import time import time
import unittest import unittest
import os
logger = logging.getLogger(__name__)
IMAGE_NAME = "vpn-wireguard-test" IMAGE_NAME = "vpn-wireguard-test"
CONTAINER_NAME = "vpn-test-container" CONTAINER_NAME = "vpn-test-container"
@@ -32,6 +41,11 @@ STARTUP_TIMEOUT = 30 # seconds to wait for VPN to come up
HEALTH_POLL_INTERVAL = 2 # seconds between health checks 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: def run(cmd: list[str], timeout: int = 30, check: bool = True) -> subprocess.CompletedProcess:
"""Run a command and return the result.""" """Run a command and return the result."""
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=check) return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=check)
@@ -52,6 +66,7 @@ class TestVPNImage(unittest.TestCase):
"""Test suite for the WireGuard VPN container.""" """Test suite for the WireGuard VPN container."""
host_ip: str = "" host_ip: str = ""
container_id: str = ""
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@@ -63,23 +78,32 @@ class TestVPNImage(unittest.TestCase):
) )
# ── 1. Get host public IP before VPN ── # ── 1. Get host public IP before VPN ──
print("\n[setup] Fetching host public IP...") logger.info("Fetching host public IP...")
cls.host_ip = get_host_ip() cls.host_ip = get_host_ip()
print(f"[setup] Host public IP: {cls.host_ip}") logger.info("Host public IP: %s", cls.host_ip)
assert cls.host_ip, "Could not determine host public IP" assert cls.host_ip, "Could not determine host public IP"
# ── 2. Build the image ── # ── 2. Build the image ──
print(f"[setup] Building image '{IMAGE_NAME}'...") logger.info("Building image '%s'...", IMAGE_NAME)
result = run( result = run(
["podman", "build", "-t", IMAGE_NAME, BUILD_DIR], ["podman", "build", "-t", IMAGE_NAME, BUILD_DIR],
timeout=180, timeout=180,
) )
print(result.stdout[-500:] if len(result.stdout) > 500 else result.stdout) logger.debug(
"Build output: %s",
result.stdout[-500:] if len(result.stdout) > 500 else result.stdout,
)
assert result.returncode == 0, f"Build failed:\n{result.stderr}" assert result.returncode == 0, f"Build failed:\n{result.stderr}"
print("[setup] Image built successfully.") 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 ── # ── 3. Start the container ──
print(f"[setup] Starting container '{CONTAINER_NAME}'...") logger.info("Starting container '%s'...", CONTAINER_NAME)
result = run( result = run(
[ [
"podman", "run", "-d", "podman", "run", "-d",
@@ -96,7 +120,7 @@ class TestVPNImage(unittest.TestCase):
) )
assert result.returncode == 0, f"Container failed to start:\n{result.stderr}" assert result.returncode == 0, f"Container failed to start:\n{result.stderr}"
cls.container_id = result.stdout.strip() cls.container_id = result.stdout.strip()
print(f"[setup] Container started: {cls.container_id[:12]}") logger.info("Container started: %s", cls.container_id[:12])
# Verify it's running # Verify it's running
inspect = run( inspect = run(
@@ -106,17 +130,19 @@ class TestVPNImage(unittest.TestCase):
assert inspect.stdout.strip() == "true", "Container is not running" assert inspect.stdout.strip() == "true", "Container is not running"
# ── 4. Wait for VPN to come up ── # ── 4. Wait for VPN to come up ──
print(f"[setup] Waiting up to {STARTUP_TIMEOUT}s for VPN tunnel...") logger.info("Waiting up to %d seconds for VPN tunnel...", STARTUP_TIMEOUT)
vpn_up = cls._wait_for_vpn_cls(STARTUP_TIMEOUT) vpn_up = cls._wait_for_vpn_cls(STARTUP_TIMEOUT)
assert vpn_up, f"VPN did not come up within {STARTUP_TIMEOUT}s" assert vpn_up, f"VPN did not come up within {STARTUP_TIMEOUT}s"
print("[setup] VPN tunnel is up. Running tests.\n") logger.info("VPN tunnel is up. Running tests.")
@classmethod @classmethod
def tearDownClass(cls): def tearDownClass(cls):
"""Stop and remove the container.""" """Stop and remove the container."""
print("\n[teardown] Cleaning up...") if not is_root():
return
logger.info("Cleaning up test container...")
subprocess.run(["podman", "rm", "-f", CONTAINER_NAME], capture_output=True, check=False) subprocess.run(["podman", "rm", "-f", CONTAINER_NAME], capture_output=True, check=False)
print("[teardown] Done.") logger.info("Cleanup complete.")
@classmethod @classmethod
def _wait_for_vpn_cls(cls, timeout: int = STARTUP_TIMEOUT) -> bool: def _wait_for_vpn_cls(cls, timeout: int = STARTUP_TIMEOUT) -> bool:
@@ -138,13 +164,25 @@ class TestVPNImage(unittest.TestCase):
) )
return result.stdout.strip() 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 ──────────────────────────────────────────────── # ── 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): def test_01_ip_differs_from_host(self):
"""Public IP inside VPN is different from host IP.""" """Public IP inside VPN is different from host IP."""
self._skip_if_not_root()
vpn_ip = self._get_vpn_ip() vpn_ip = self._get_vpn_ip()
print(f"\n[test] VPN public IP: {vpn_ip}") logger.info("VPN public IP: %s", vpn_ip)
print(f"[test] Host public IP: {self.host_ip}") logger.info("Host public IP: %s", self.host_ip)
self.assertTrue(vpn_ip, "Could not fetch IP from inside the container") self.assertTrue(vpn_ip, "Could not fetch IP from inside the container")
self.assertNotEqual( self.assertNotEqual(
@@ -155,12 +193,42 @@ class TestVPNImage(unittest.TestCase):
def test_02_wireguard_interface_exists(self): def test_02_wireguard_interface_exists(self):
"""The wg0 interface is present in the container.""" """The wg0 interface is present in the container."""
self._skip_if_not_root()
result = podman_exec(CONTAINER_NAME, ["wg", "show", "wg0"]) result = podman_exec(CONTAINER_NAME, ["wg", "show", "wg0"])
self.assertEqual(result.returncode, 0, f"wg show failed:\n{result.stderr}") 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") 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).""" """When WireGuard is down, traffic is blocked (kill switch)."""
self._skip_if_not_root()
# Bring down the WireGuard interface by deleting it # Bring down the WireGuard interface by deleting it
down_result = podman_exec(CONTAINER_NAME, ["ip", "link", "del", "wg0"], timeout=10) 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}") self.assertEqual(down_result.returncode, 0, f"ip link del wg0 failed:\n{down_result.stderr}")
@@ -178,7 +246,7 @@ class TestVPNImage(unittest.TestCase):
result.returncode, 0, result.returncode, 0,
"Traffic went through even with WireGuard down — kill switch is NOT working!", "Traffic went through even with WireGuard down — kill switch is NOT working!",
) )
print("\n[test] Kill switch confirmed: traffic blocked with VPN down") logger.info("Kill switch confirmed: traffic blocked with VPN down")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -1,10 +1,17 @@
[Interface] [Interface]
PrivateKey = iO5spIue/6ciwUoR95hYtuxdtQxV/Q9EOoQ/jHe18kM= PrivateKey = EPRa2f/v72LvIXAY4yqIRJifsSb+nCcYHqC2rwA94UI=
Address = 10.2.0.2/32 Address = 100.64.244.78/32
DNS = 10.2.0.1 DNS = 198.18.0.1,198.18.0.2
# 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] [Peer]
PublicKey = J4XVdtoBVc/EoI2Yk673Oes97WMnQSH5KfamZNjtM2s= PublicKey = KgTUh3KLijVluDvNpzDCJJfrJ7EyLzYLmdHCksG4sRg=
AllowedIPs = 0.0.0.0/0 AllowedIPs = 0.0.0.0/0
Endpoint = 185.183.34.149:51820 Endpoint = 91.148.236.64:51820
PersistentKeepalive = 25 PersistentKeepalive = 25

View File

@@ -203,14 +203,14 @@ List library series that have missing episodes.
| `page` | int | 1 | Page number (must be positive) | | `page` | int | 1 | Page number (must be positive) |
| `per_page` | int | 20 | Items per page (max 1000) | | `per_page` | int | 20 | Items per page (max 1000) |
| `sort_by` | string | null | Sort field: `title`, `id`, `name`, `missing_episodes` | | `sort_by` | string | null | Sort field: `title`, `id`, `name`, `missing_episodes` |
| `filter` | string | null | Filter: `no_episodes` (shows only series with missing episodes - episodes in DB that haven't been downloaded yet) | | `filter` | string | null | Filter: `missing_episodes` (shows series with any missing episodes), `no_episodes` (shows series with zero downloaded episodes) |
**Filter Details:** **Filter Details:**
- `no_episodes`: Returns series that have at least one episode in the database with `is_downloaded=False` - `missing_episodes`: Returns series that have at least one missing episode recorded in the database (`is_downloaded=False`)
- `no_episodes`: Returns series that have missing episodes and no downloaded episodes (i.e., only missing episodes exist in the database)
- Episodes in the database represent MISSING episodes (from episodeDict during scanning) - Episodes in the database represent MISSING episodes (from episodeDict during scanning)
- `is_downloaded=False` means the episode file was not found in the folder - `is_downloaded=False` means the episode file was not found in the folder
- This effectively shows series where no video files were found for missing episodes
**Response (200 OK):** **Response (200 OK):**
@@ -1285,7 +1285,7 @@ Basic health check endpoint.
{ {
"status": "healthy", "status": "healthy",
"timestamp": "2025-12-13T10:30:00.000Z", "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", "status": "healthy",
"timestamp": "2025-12-13T10:30:00.000Z", "timestamp": "2025-12-13T10:30:00.000Z",
"version": "1.0.0", "version": "1.0.1",
"dependencies": { "dependencies": {
"database": { "database": {
"status": "healthy", "status": "healthy",

View File

@@ -81,6 +81,7 @@ src/server/
| +-- websocket_service.py# WebSocket broadcasting | +-- websocket_service.py# WebSocket broadcasting
| +-- queue_repository.py # Database persistence | +-- queue_repository.py # Database persistence
| +-- nfo_service.py # NFO metadata management | +-- nfo_service.py # NFO metadata management
| +-- folder_scan_service.py # Daily folder maintenance scan
+-- models/ # Pydantic models +-- models/ # Pydantic models
| +-- auth.py # Auth request/response models | +-- auth.py # Auth request/response models
| +-- config.py # Configuration models | +-- config.py # Configuration models
@@ -290,8 +291,9 @@ The FastAPI lifespan function (`src/server/fastapi_app.py`) runs the following s
8. Background loader service started 8. Background loader service started
9. Scheduler service started 9. Scheduler service started
+-- Cron-based library rescans configured
10. NFO repair scan (queue incomplete tvshow.nfo files for background reload) +-- Optional: auto-download missing episodes after rescan
+-- Optional: folder maintenance (NFO repair, renaming, poster checks) during scheduled runs
``` ```
### 12.2 Temp Folder Guarantee ### 12.2 Temp Folder Guarantee

View File

@@ -73,17 +73,16 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
that detects incomplete `tvshow.nfo` files and triggers TMDB re-fetch. that detects incomplete `tvshow.nfo` files and triggers TMDB re-fetch.
Provides `parse_nfo_tags()`, `find_missing_tags()`, `nfo_needs_repair()`, and Provides `parse_nfo_tags()`, `find_missing_tags()`, `nfo_needs_repair()`, and
`NfoRepairService.repair_series()`. 13 required tags are checked. `NfoRepairService.repair_series()`. 13 required tags are checked.
- **`perform_nfo_repair_scan()` startup hook - **`perform_nfo_repair_scan()`
(`src/server/services/initialization_service.py`)**: New async function (`src/server/services/folder_scan_service.py`)**: New async function
called during application startup. Iterates every series directory, checks that iterates every series directory, checks whether `tvshow.nfo` is missing
whether `tvshow.nfo` is missing required tags using `nfo_needs_repair()`, and required tags using `nfo_needs_repair()`, and queues the series for background
either queues the series for background reload (when a `background_loader` is reload via `asyncio.create_task`. Skips gracefully when `tmdb_api_key` or
provided) or calls `NfoRepairService.repair_series()` directly. Skips `anime_directory` is not configured.
gracefully when `tmdb_api_key` or `anime_directory` is not configured. - **NFO repair wired into scheduled folder scan (`src/server/services/folder_scan_service.py`)**:
- **NFO repair wired into startup lifespan (`src/server/fastapi_app.py`)**: `perform_nfo_repair_scan(background_loader=None)` is called during the
`perform_nfo_repair_scan(background_loader)` is called at the end of the scheduled daily folder scan, keeping startup fast while ensuring regular
FastAPI lifespan startup, after `perform_media_scan_if_needed`, ensuring maintenance.
every existing series NFO is checked and repaired on each server start.
### Changed ### Changed

View File

@@ -117,7 +117,8 @@ Location: `data/config.json`
"interval_minutes": 60, "interval_minutes": 60,
"schedule_time": "03:00", "schedule_time": "03:00",
"schedule_days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "schedule_days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"],
"auto_download_after_rescan": false "auto_download_after_rescan": false,
"folder_scan_enabled": false
}, },
"logging": { "logging": {
"level": "INFO", "level": "INFO",
@@ -143,7 +144,7 @@ Location: `data/config.json`
"master_password_hash": "$pbkdf2-sha256$...", "master_password_hash": "$pbkdf2-sha256$...",
"anime_directory": "/path/to/anime" "anime_directory": "/path/to/anime"
}, },
"version": "1.0.0" "version": "1.0.1"
} }
``` ```
@@ -173,6 +174,7 @@ Controls automatic cron-based library rescanning (powered by APScheduler).
| `scheduler.schedule_time` | string | `"03:00"` | Daily run time in 24-h `HH:MM` format. | | `scheduler.schedule_time` | string | `"03:00"` | Daily run time in 24-h `HH:MM` format. |
| `scheduler.schedule_days` | list[string] | `["mon","tue","wed","thu","fri","sat","sun"]` | Days of the week to run the scan. Empty list disables the cron job. | | `scheduler.schedule_days` | list[string] | `["mon","tue","wed","thu","fri","sat","sun"]` | Days of the week to run the scan. Empty list disables the cron job. |
| `scheduler.auto_download_after_rescan` | bool | `false` | Automatically queue missing episodes for download after each rescan. | | `scheduler.auto_download_after_rescan` | bool | `false` | Automatically queue missing episodes for download after each rescan. |
| `scheduler.folder_scan_enabled` | bool | `false` | Run folder maintenance (NFO repair, folder renaming, poster checks) during scheduled runs. **When enabled, series folders are automatically renamed to match the `<title> (<year>)` convention derived from their `tvshow.nfo` files.** |
Valid day abbreviations: `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, `sun`. Valid day abbreviations: `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, `sun`.
@@ -216,7 +218,9 @@ Source: [src/server/models/config.py](../src/server/models/config.py#L15-L24)
- Obtain a TMDB API key from https://www.themoviedb.org/settings/api - Obtain a TMDB API key from https://www.themoviedb.org/settings/api
- `auto_create` creates NFO files during the download process - `auto_create` creates NFO files during the download process
- `update_on_scan` refreshes metadata when scanning existing anime - `update_on_scan` refreshes metadata when scanning existing anime
- `download_poster` also controls whether the scheduled folder scan checks for and re-downloads missing or corrupted `poster.jpg` files (see [NFO_GUIDE.md](NFO_GUIDE.md#6-poster-check))
- Image downloads require valid `tmdb_api_key` - Image downloads require valid `tmdb_api_key`
- `TMDB_API_KEY` environment variable is optional when `nfo.tmdb_api_key` is configured in `data/config.json`
- Larger image sizes (`w780`, `original`) consume more storage space - Larger image sizes (`w780`, `original`) consume more storage space
Source: [src/server/models/config.py](../src/server/models/config.py#L109-L132) Source: [src/server/models/config.py](../src/server/models/config.py#L109-L132)

View File

@@ -0,0 +1,94 @@
# Logging Instructions
This document describes how to write and refactor logging across the AniWorld codebase to make logs **human-readable**, **debug-friendly**, and **noise-free**.
> ✅ Goal: Logs should help a developer understand what happened, why it happened, and what to inspect next — without overwhelming them with duplicates or irrelevant details.
---
## 1. Principles for Great Logs
### 1.1 Use the Right Log Level
- `DEBUG`: Detailed internal state useful when debugging a specific issue (e.g., decision points, returned values, request/response payloads). Not for normal operation.
- `INFO`: High-level events that represent what the system is doing (e.g., "Import started", "New series added", "Config reloaded"). Use sparingly.
- `WARNING`: Something unexpected happened, but the system can continue (e.g., missing optional file, fallback behavior).
- `ERROR`: An operation failed and needs attention (e.g., exception caught, failed database write).
- `CRITICAL`: The system is in an unusable state (e.g., config corruption, failed startup).
### 1.2 Keep Logs Human-Readable
- Write messages in a clear, descriptive sentence-style format.
- Avoid cryptic codes or single-word log messages.
- Prefer `logger.debug("... %s", value)`-style formatting over f-strings to avoid unnecessary work when the log level is disabled.
### 1.3 Avoid Log Spam
- Dont log inside hot loops unless you explicitly aggregate and log a summary (e.g., "Processed 124 files, 3 failures").
- Avoid repeated/logging the same event at the same level (e.g., do not log "Retrying" 10 times at INFO; log once at INFO and then use DEBUG for each retry).
- Use rate limiting or debounce patterns for logs that can fire rapidly (e.g., external service health checks).
- Prefer a single higher-level log with context rather than many low-level logs that clutter output.
### 1.4 Log Objects Usefully
- When logging objects, log the minimal useful representation (e.g., ID, name, status) rather than the full object or its memory address.
- If an object has a `.dict()`, `.to_dict()`, or `.as_dict()` helper (common in Pydantic models), log that rather than relying on `repr()`.
- Add a `__repr__` or `__str__` implementation to domain models that returns a helpful, concise string with key identifiers.
- Use structured logging (e.g., `logger.info("Series added", extra={"series_id": series.id, "title": series.title})`) where supported.
- For exceptions, prefer `logger.exception("Failed to ...")` to capture stack traces.
---
## 2. Refactoring Existing Logs
When improving or refactoring existing log statements, aim to make them:
- **Actionable**: A developer reading the log should know what happened and what to check next.
- **Non-redundant**: Remove duplicates and ensure only one log records the same high-level event at a given level.
- **Context-rich**: Include identifiers (e.g., `series_id`, `file_path`, `user_id`) and key state that explains why a decision was made.
- **Level-appropriate**: Downgrade noisy INFO logs to DEBUG, and elevate critical failures to ERROR/CRITICAL.
### 2.1 Refactor Checklist
1. **Locate noisy logs**: Search for repeated messages (e.g., "Start", "Done") and determine whether they should be DEBUG or removed.
2. **Replace ad-hoc prints**: Remove `print()` statements or `print(obj)` and replace with `logger.*` calls.
3. **Use structured context**: If a function logs multiple related messages, include the same context in each (e.g., `extra={"series_id": series.id}`) or use a context manager that attaches it.
4. **Validate object output**: Ensure any logged object produces a useful representation (add methods or translate to dict). If not, log the key fields explicitly.
5. **Batch repetitive events**: If a loop logs per item, consider collecting stats and logging a summary at the end.
## 3. Adding New Logs
When adding logs to new code paths:
- Log **important state transitions** (e.g., "Queue started", "Download completed", "Config reloaded").
- For error paths, include what failed and why (e.g., "Could not load config from X: {exc}").
- Prefer logging at the boundaries of operations, not deep inside utility functions unless it aids debugging.
- Write logs in full sentences, with a clear subject, verb, and object.
---
## 4. Example Patterns
```python
logger.info("Import completed", extra={"series_id": series.id, "count": len(imported)})
logger.debug(
"Fetched feed items",
extra={"feed_url": feed.url, "item_count": len(items)},
)
try:
result = download_episode(episode)
except Exception:
logger.exception("Failed to download episode %s", episode.id)
```
> 💡 When in doubt, favor **fewer, richer logs** over many noisy logs.
---
## 5. Logging Audit Task List
For a guided checklist of files and logging improvements, see **`docs/tasks.md`**. This is where we track which files have been reviewed and which logging items still need attention.
> ✅ After applying the guidelines above, update `docs/tasks.md` to indicate which tasks are complete.

View File

@@ -246,7 +246,84 @@ NFO files are created in the anime directory:
--- ---
## 5. API Reference ## 5. Folder Naming Convention
### 5.1 Expected Format
After the daily folder scan (when **Update on library scan** is enabled), Aniworld validates every series folder against its `tvshow.nfo` metadata. If the folder name does not match the expected convention, it is automatically renamed.
**Format:**
```
{title} ({year})
```
**Examples:**
| NFO `<title>` | NFO `<year>` | Expected Folder Name |
|---------------|--------------|----------------------|
| `Attack on Titan` | `2013` | `Attack on Titan (2013)` |
| `One Piece` | `1999` | `One Piece (1999)` |
| `Demon Slayer: Kimetsu no Yaiba` | `2019` | `Demon Slayer Kimetsu no Yaiba (2019)` |
### 5.2 Sanitization Rules
Illegal filesystem characters are removed or replaced to ensure cross-platform compatibility:
- Removed: `< > : " / \ | ? *` and null bytes
- Control characters stripped
- Multiple spaces collapsed to one
- Leading/trailing dots and whitespace trimmed
- Maximum length: 200 characters (truncated at word boundary if possible)
### 5.3 Skip Conditions
A folder is **not** renamed when any of the following apply:
- `tvshow.nfo` is missing `<title>` or `<year>` (or they are empty)
- The series has an **active or pending download**
- The target folder name already exists (duplicate)
- The resulting path would exceed the OS path-length limit
- The app lacks write permission to the anime directory
All skipped and renamed actions are logged.
---
## 6. Poster Check
### 6.1 Overview
During the daily folder scan, Aniworld checks every series folder for a valid `poster.jpg`. If the file is missing or smaller than 1 KB, the application attempts to re-download it from the URL stored in the series' `tvshow.nfo` file.
### 6.2 How It Works
1. **Scan** — After folder renaming, the scan iterates over all series folders that contain a `tvshow.nfo`.
2. **Validate** — For each folder, it checks whether `poster.jpg` exists and is at least 1 KB.
3. **Parse NFO** — If the poster is missing or too small, the scan reads `tvshow.nfo` and looks for a `<thumb aspect="poster">` (or any `<thumb>`) URL.
4. **Download** — If a URL is found, the poster is downloaded using `ImageDownloader` with a concurrency limit of 3 simultaneous downloads.
5. **Validate Download** — The downloaded image is validated with PIL to ensure it is not corrupted.
### 6.3 Skip Conditions
A folder is **not** processed for poster download when any of the following apply:
- `tvshow.nfo` does not exist in the folder.
- `poster.jpg` already exists and is ≥ 1 KB.
- No `<thumb>` URL is found in the NFO (the NFO may have been created before thumb tags were added).
- The `nfo.download_poster` setting is `false` (poster checks are still performed, but downloads are skipped if the setting is disabled; see [CONFIGURATION.md](CONFIGURATION.md)).
### 6.4 Logging
Every poster check action is logged:
- **INFO** — When a poster is successfully downloaded.
- **WARNING** — When a download fails or no URL is found.
- **ERROR** — When an unexpected exception occurs during download.
---
## 7. API Reference
### 5.1 Check NFO Status ### 5.1 Check NFO Status
@@ -675,21 +752,25 @@ The XML serialisation lives in `src/core/utils/nfo_generator.py`
## 11. Automatic NFO Repair ## 11. Automatic NFO Repair
Every time the server starts, Aniworld scans all existing `tvshow.nfo` files and NFO repair now runs as part of the scheduled daily folder scan rather than on every
automatically repairs any that are missing required tags. startup. When the scheduler triggers `FolderScanService.run_folder_scan()`, the first
step is `perform_nfo_repair_scan(background_loader=None)`. Each incomplete NFO is
queued as a background `asyncio` task, so the scan returns quickly while repairs
continue asynchronously.
### How It Works ### How It Works
1. **Scan**`perform_nfo_repair_scan()` in 1. **Scan**`perform_nfo_repair_scan()` in
`src/server/services/initialization_service.py` is called from the FastAPI `src/server/services/initialization_service.py` is called from
lifespan after `perform_media_scan_if_needed()`. `FolderScanService.run_folder_scan()` (`src/server/services/folder_scan_service.py`).
2. **Detect**`nfo_needs_repair(nfo_path)` from 2. **Detect**`nfo_needs_repair(nfo_path)` from
`src/core/services/nfo_repair_service.py` parses each `tvshow.nfo` with `src/core/services/nfo_repair_service.py` parses each `tvshow.nfo` with
`lxml` and checks for the 13 required tags listed below. `lxml` and checks for the 13 required tags listed below.
3. **Repair** — Series whose NFO is incomplete are queued for background reload 3. **Repair** — Series whose NFO is incomplete are queued for background reload
via `BackgroundLoaderService.add_series_loading_task()`. The background via `asyncio.create_task`. Each task creates its own isolated
loader re-fetches metadata from TMDB and rewrites the NFO with all tags :class:`NFOService` / :class:`TMDBClient` so concurrent tasks never share an
populated. ``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.
### Tags Checked (13 required) ### Tags Checked (13 required)
@@ -734,8 +815,7 @@ This calls `NFOService.update_tvshow_nfo()` directly and overwrites the existing
| File | Purpose | | File | Purpose |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------- | | ----------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `src/core/services/nfo_repair_service.py` | `REQUIRED_TAGS`, `parse_nfo_tags`, `find_missing_tags`, `nfo_needs_repair`, `NfoRepairService` | | `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` startup hook | | `src/server/services/folder_scan_service.py` | `perform_nfo_repair_scan` — invoked during the scheduled daily folder scan |
| `src/server/fastapi_app.py` | Wires `perform_nfo_repair_scan` into the lifespan |
--- ---

View File

@@ -59,6 +59,10 @@ The application now features a comprehensive configuration system that allows us
## Anime Management ## Anime Management
- **Anime Library Page**: Display list of anime series with missing episodes - **Anime Library Page**: Display list of anime series with missing episodes
- **Library Filters**:
- "Missing Episodes Only" (shows only series with missing episodes, including series that currently have no downloaded episodes)
- "No Episodes" (shows series that are present in the library but have zero downloaded episodes)
- "Show All Series" (overrides other filters to show every series)
- **Database-Backed Series Storage**: All series metadata and missing episodes stored in SQLite database - **Database-Backed Series Storage**: All series metadata and missing episodes stored in SQLite database
- **Automatic Database Synchronization**: Series loaded from database on startup, stays in sync with filesystem - **Automatic Database Synchronization**: Series loaded from database on startup, stays in sync with filesystem
- **Series Selection**: Select individual anime series and add episodes to download queue - **Series Selection**: Select individual anime series and add episodes to download queue

View File

@@ -117,4 +117,3 @@ For each task completed:
--- ---
## TODO List:

View File

@@ -2,3 +2,50 @@ API key : 299ae8f630a31bda814263c551361448
/mnt/server/serien/Serien/ /mnt/server/serien/Serien/
{
"name": "Aniworld",
"data_dir": "data",
"scheduler": {
"enabled": true,
"interval_minutes": 60,
"schedule_time": "03:00",
"schedule_days": [
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
"sun"
],
"auto_download_after_rescan": true,
"folder_scan_enabled": true
},
"logging": {
"level": "INFO",
"file": null,
"max_bytes": null,
"backup_count": 3
},
"backup": {
"enabled": false,
"path": "data/backups",
"keep_days": 30
},
"nfo": {
"tmdb_api_key": "9bc3e547caff878615cbdba2cc421d37",
"auto_create": true,
"update_on_scan": true,
"download_poster": true,
"download_logo": true,
"download_fanart": true,
"image_size": "original"
},
"other": {
"master_password_hash": "$pbkdf2-sha256$29000$HQNASKk1xpgTAgAgJGRMaQ$73TOCCM0UEZONyNXQEPa3SmIoXeG6C1l5mMFDNgYfMQ",
"anime_directory": "/data"
},
"version": "1.0.0"
}

178
docs/tasks.md Normal file
View File

@@ -0,0 +1,178 @@
# Tasks
## 1. Scheduled Folder Scan
### Task 1.1: Add folder scan scheduler configuration
**Where is that found**
- `src/server/models/config.py` (`SchedulerConfig`)
- `data/config.json` (example/default config)
- `src/server/web/templates/setup.html` (setup UI)
- `src/server/api/auth.py` (config save endpoint, if it validates scheduler fields)
**Goal. How it should be**
Add a new boolean field `folder_scan_enabled` (default `false`) to `SchedulerConfig`. When `true`, the scheduler will execute the folder maintenance routine during its scheduled run. Add the field to the setup page as a checkbox. Ensure existing configs without this field load successfully (Pydantic default handles this).
**Possible traps and issues**
- Backward compatibility: old `data/config.json` files must load without errors. Pydantic defaults solve this, but verify by loading an old config.
- The setup page JavaScript must include the new field in the payload sent to `/api/config`.
- Do not confuse this with `auto_download_after_rescan` — this is a separate toggle.
**Docs changes needed**
- `docs/CONFIGURATION.md`: Document the new `scheduler.folder_scan_enabled` option.
- `docs/ARCHITECTURE.md`: Mention folder scan in the scheduler section.
**Why this is needed**
Users need an opt-in toggle to enable automatic daily folder maintenance (NFO repair, folder renaming, poster checks) without forcing it on everyone.
---
### Task 1.2: Create FolderScanService skeleton
**Where is that found**
- New file: `src/server/services/folder_scan_service.py`
- `src/server/services/scheduler_service.py` (to call it)
**Goal. How it should be**
Create a new `FolderScanService` class with a single async entry point `async def run_folder_scan(self) -> None`. The method should:
1. Log start/completion with structlog.
2. Check prerequisites (`settings.anime_directory` exists, `settings.tmdb_api_key` is set).
3. Skip gracefully with a warning log if prerequisites are missing.
4. Use a module-level semaphore (similar to `_NFO_REPAIR_SEMAPHORE`) to limit concurrent TMDB operations to 3.
Keep the implementation empty for the sub-tasks (1.31.5) to fill in. Just add the skeleton and the semaphore.
**Possible traps and issues**
- Circular imports: `folder_scan_service.py` will import from `initialization_service`, `config.settings`, etc. Keep imports inside methods or at the bottom if circular issues arise.
- The service should follow the singleton pattern like `SchedulerService` and `DownloadService` if it holds state, or be stateless. For simplicity, make it a plain class instantiated per call or a module-level function set.
- Exception handling: any unhandled exception in the scheduled task should be caught and logged so it doesn't crash the scheduler.
**Docs changes needed**
- `docs/ARCHITECTURE.md`: Add `folder_scan_service.py` to the services list.
**Why this is needed**
Encapsulates the new daily maintenance logic in its own module, keeping `scheduler_service.py` clean and allowing the folder scan to be tested independently.
---
### Task 1.3: Integrate NFO repair into folder scan
**Where is that found**
- `src/server/services/folder_scan_service.py`
- `src/server/services/initialization_service.py` (`perform_nfo_repair_scan`)
**Goal. How it should be**
Inside `FolderScanService.run_folder_scan()`, call `perform_nfo_repair_scan(background_loader=None)` as the first step. Reuse the existing function exactly — do not copy its logic. Log a message before and after the call.
**Possible traps and issues**
- `perform_nfo_repair_scan` spawns `asyncio.create_task` for each repair. When called from the scheduler, these background tasks will still run after `run_folder_scan` returns. This is fine, but log that repairs are queued.
- The function already handles missing `tmdb_api_key` and `anime_directory`, so the caller doesn't need to double-check, but the skeleton from Task 1.2 already checks prerequisites.
- `perform_nfo_repair_scan` imports `nfo_needs_repair` and `NfoRepairService` inside the function, so no heavy import-time dependencies.
**Docs changes needed**
- `docs/NFO_GUIDE.md`: Update the "Automatic NFO Repair" section to state that repair now runs as part of the scheduled folder scan instead of every startup.
**Why this is needed**
Reuses the existing, tested NFO repair logic. Moves NFO repair from startup blocking to scheduled background maintenance.
---
### Task 1.4: Validate and rename series folders
**Where is that found**
- `src/server/services/folder_scan_service.py`
- `src/core/services/nfo_repair_service.py` (for `parse_nfo_tags` or similar NFO parsing)
- `src/server/database/models.py` / `src/server/database/system_settings_service.py` (if folder paths are stored in DB)
**Goal. How it should be**
After NFO repair, iterate over every subfolder in `settings.anime_directory` that contains a `tvshow.nfo`. For each folder:
1. Parse the NFO to extract `<title>` and `<year>` text values.
2. Compute the expected folder name: `f"{title} ({year})"`.
3. Sanitize the expected name for filesystem safety (remove/replace illegal characters like `/`, `\`, `:`, etc.).
4. Compare with the current folder name (`series_dir.name`).
5. If different, rename the folder using `series_dir.rename(expected_path)`.
6. If the series path is stored in the database (check `anime_service` or DB models), update the database record to point to the new path.
Skip folders where title or year is missing/empty. Log every rename action.
**Possible traps and issues**
- **Database path consistency**: If `Series` or `Episode` models store absolute or relative paths, renaming the folder on disk without updating the DB will break downloads, NFO updates, and the web UI. Must verify whether paths are stored in the DB and update them.
- **Active downloads**: A series currently being downloaded should not be renamed. Check the download queue or lock status before renaming. If no lock mechanism exists, this is a major trap — document it.
- **Filesystem permissions**: The app may not have write permission to the anime directory. Catch `PermissionError` and `OSError` and log gracefully.
- **Special characters**: Titles like `"A / B"` or `"Show: Subtitle"` contain characters illegal in folder names. Define a sanitization function (e.g., replace `/` with `-`, remove trailing dots on Windows, etc.).
- **Duplicate names**: Two different series could sanitize to the same name. Check if target path already exists before renaming.
- **Path length limits**: Very long titles might exceed OS path limits.
**Docs changes needed**
- `docs/NFO_GUIDE.md`: Add a section "Folder Naming Convention" explaining the `<title> (<year>)` format.
- `docs/CONFIGURATION.md`: Mention that enabling folder scan will rename folders.
**Why this is needed**
Enforces a consistent, predictable folder naming scheme across the library, making it easier for media center apps (Kodi, Jellyfin, Plex) to match metadata.
---
### Task 1.5: Check and download missing poster.jpg
**Where is that found**
- `src/server/services/folder_scan_service.py`
- `src/core/utils/image_downloader.py` (`ImageDownloader`)
- `src/core/services/nfo_service.py` or `src/core/services/nfo_repair_service.py` (to get poster URL from NFO or TMDB)
**Goal. How it should be**
After folder renaming, iterate over series folders again (or combine with Task 1.4 loop). For each folder:
1. Check if `poster.jpg` exists and has a size ≥ `ImageDownloader.min_file_size` (1 KB by default).
2. If missing or too small:
a. Parse `tvshow.nfo` for `<thumb aspect="poster">` or `<thumb>` URL.
b. If no URL in NFO, skip (do not query TMDB again to keep tasks small; the NFO should already have it after repair).
c. Use `ImageDownloader` (with context manager) to download the image to `series_dir / "poster.jpg"`.
d. Validate the downloaded image with `ImageDownloader._validate_image` (or similar existing validation).
3. Use the existing `_NFO_REPAIR_SEMAPHORE` or a new `POSTER_DOWNLOAD_SEMAPHORE` to limit concurrent downloads to 3.
**Possible traps and issues**
- **TMDB rate limiting**: Even downloading images hits TMDB CDN. The semaphore limits concurrency.
- **Invalid images**: A download might produce a 0-byte or corrupted file. `ImageDownloader` already validates with PIL; reuse that.
- **NFO without thumb URL**: If the NFO was created before thumb tags were added, there may be no URL. In that case, skip and log. A future task could query TMDB directly.
- **Write permissions**: Same as Task 1.4.
- **Async session sharing**: `ImageDownloader` manages its own `aiohttp` session. Use `async with ImageDownloader() as downloader:` to ensure cleanup.
**Docs changes needed**
- `docs/NFO_GUIDE.md`: Add "Poster Check" subsection under folder scan.
- `docs/CONFIGURATION.md`: Mention that `nfo.download_poster` setting also affects scheduled poster checks.
**Why this is needed**
Ensures every series has artwork, which is required by most media center front-ends for a polished library view.
---
## 2. Remove startup NFO repair
### Task 2.1: Remove perform_nfo_repair_scan from startup lifespan
**Where is that found**
- `src/server/fastapi_app.py` (lifespan startup block, lines ~245 and ~319)
- `src/server/services/initialization_service.py` (keep the function, just remove the call site)
- `tests/integration/test_nfo_repair_startup.py`
- `tests/unit/test_initialization_service.py` (tests that call `perform_nfo_repair_scan` directly can stay, but integration tests verifying startup wiring must change)
**Goal. How it should be**
1. In `src/server/fastapi_app.py`, remove the import of `perform_nfo_repair_scan` from the `initialization_service` import block.
2. Remove the line `await perform_nfo_repair_scan(background_loader)` from the lifespan startup sequence.
3. Update `tests/integration/test_nfo_repair_startup.py`:
- Remove or modify `test_perform_nfo_repair_scan_imported_in_lifespan` and `test_perform_nfo_repair_scan_called_after_media_scan` since the startup wiring is gone.
- Replace with a test that verifies `perform_nfo_repair_scan` is NOT called during startup (or simply delete the file if it has no other purpose).
4. `tests/unit/test_initialization_service.py` tests for `perform_nfo_repair_scan` can remain because they test the function itself, not the startup wiring.
**Possible traps and issues**
- **Test failures**: `test_nfo_repair_startup.py` will fail immediately after the code change. It must be updated in the same PR.
- **Documentation drift**: `docs/NFO_GUIDE.md`, `docs/CHANGELOG.md`, and `docs/ARCHITECTURE.md` all describe the startup NFO repair behavior. If docs are not updated, users will expect repair on every start.
- **Background loader parameter**: The `background_loader` variable was created partly for `perform_nfo_repair_scan`. After removal, check if `background_loader` is still needed for other startup steps (yes — `perform_media_scan_if_needed` uses it). Do not remove `background_loader` entirely.
- **Import cleanup**: Ensure no unused imports remain in `fastapi_app.py` after removal.
**Docs changes needed**
- `docs/NFO_GUIDE.md`: Update section 11 "Automatic NFO Repair" to remove startup references and state it runs via scheduler.
- `docs/CHANGELOG.md`: Add an entry under "Changed" or "Removed" noting that startup NFO repair is replaced by scheduled folder scan.
- `docs/ARCHITECTURE.md`: Update the startup sequence description.
**Why this is needed**
Running `perform_nfo_repair_scan` on every startup slows down server restarts, especially for large libraries. Moving it to a scheduled task keeps startup fast while still ensuring regular maintenance.

View File

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

View File

@@ -5,6 +5,7 @@ and checking NFO metadata files.
""" """
import asyncio import asyncio
import logging
import sys import sys
from pathlib import Path from pathlib import Path
@@ -14,48 +15,50 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.config.settings import settings from src.config.settings import settings
from src.core.services.series_manager_service import SeriesManagerService from src.core.services.series_manager_service import SeriesManagerService
logger = logging.getLogger(__name__)
async def scan_and_create_nfo(): async def scan_and_create_nfo():
"""Scan all series and create missing NFO files.""" """Scan all series and create missing NFO files."""
print("=" * 70) logger.info("%s", "=" * 70)
print("NFO Auto-Creation Tool") logger.info("NFO Auto-Creation Tool")
print("=" * 70) logger.info("%s", "=" * 70)
if not settings.tmdb_api_key: if not settings.tmdb_api_key:
print("\n❌ Error: TMDB_API_KEY not configured") logger.error("TMDB_API_KEY not configured")
print(" Set TMDB_API_KEY in .env file or environment") logger.error("Set TMDB_API_KEY in .env file or environment")
print(" Get API key from: https://www.themoviedb.org/settings/api") logger.error("Get API key from: https://www.themoviedb.org/settings/api")
return 1 return 1
if not settings.anime_directory: if not settings.anime_directory:
print("\n❌ Error: ANIME_DIRECTORY not configured") logger.error("ANIME_DIRECTORY not configured")
return 1 return 1
print(f"\nAnime Directory: {settings.anime_directory}") logger.info("Anime Directory: %s", settings.anime_directory)
print(f"Auto-create NFO: {settings.nfo_auto_create}") logger.info("Auto-create NFO: %s", settings.nfo_auto_create)
print(f"Update on scan: {settings.nfo_update_on_scan}") logger.info("Update on scan: %s", settings.nfo_update_on_scan)
print(f"Download poster: {settings.nfo_download_poster}") logger.info("Download poster: %s", settings.nfo_download_poster)
print(f"Download logo: {settings.nfo_download_logo}") logger.info("Download logo: %s", settings.nfo_download_logo)
print(f"Download fanart: {settings.nfo_download_fanart}") logger.info("Download fanart: %s", settings.nfo_download_fanart)
if not settings.nfo_auto_create: if not settings.nfo_auto_create:
print("\n⚠️ Warning: NFO_AUTO_CREATE is set to False") logger.warning("NFO_AUTO_CREATE is set to False")
print(" Enable it in .env to auto-create NFO files") logger.warning("Enable it in .env to auto-create NFO files")
print("\n Continuing anyway to demonstrate functionality...") logger.info("Continuing anyway to demonstrate functionality...")
# Override for demonstration # Override for demonstration
settings.nfo_auto_create = True settings.nfo_auto_create = True
print("\nInitializing series manager...") logger.info("Initializing series manager...")
manager = SeriesManagerService.from_settings() manager = SeriesManagerService.from_settings()
# Get series list first # Get series list first
serie_list = manager.get_serie_list() serie_list = manager.get_serie_list()
all_series = serie_list.get_all() all_series = serie_list.get_all()
print(f"Found {len(all_series)} series in directory") logger.info("Found %d series in directory", len(all_series))
if not all_series: if not all_series:
print("\n⚠️ No series found. Add some anime series first.") logger.warning("No series found. Add some anime series first.")
return 0 return 0
# Show series without NFO # Show series without NFO
@@ -65,24 +68,24 @@ async def scan_and_create_nfo():
series_without_nfo.append(serie) series_without_nfo.append(serie)
if series_without_nfo: if series_without_nfo:
print(f"\nSeries without NFO: {len(series_without_nfo)}") logger.info("Series without NFO: %d", len(series_without_nfo))
for serie in series_without_nfo[:5]: # Show first 5 for serie in series_without_nfo[:5]: # Show first 5
print(f" - {serie.name} ({serie.folder})") logger.debug("Missing NFO: %s (%s)", serie.name, serie.folder)
if len(series_without_nfo) > 5: if len(series_without_nfo) > 5:
print(f" ... and {len(series_without_nfo) - 5} more") logger.info("... and %d more", len(series_without_nfo) - 5)
else: else:
print("\nAll series already have NFO files!") logger.info("All series already have NFO files")
if not settings.nfo_update_on_scan: if not settings.nfo_update_on_scan:
print("\nNothing to do. Enable NFO_UPDATE_ON_SCAN to update existing NFOs.") logger.info("Nothing to do. Enable NFO_UPDATE_ON_SCAN to update existing NFOs.")
return 0 return 0
print("\nProcessing NFO files...") logger.info("Processing NFO files...")
print("(This may take a while depending on the number of series)") logger.info("This may take a while depending on the number of series")
try: try:
await manager.scan_and_process_nfo() await manager.scan_and_process_nfo()
print("\nNFO processing complete!") logger.info("NFO processing complete")
# Show updated stats # Show updated stats
serie_list.load_series() # Reload to get updated stats serie_list.load_series() # Reload to get updated stats
@@ -92,16 +95,16 @@ async def scan_and_create_nfo():
series_with_logo = [s for s in all_series if s.has_logo()] series_with_logo = [s for s in all_series if s.has_logo()]
series_with_fanart = [s for s in all_series if s.has_fanart()] series_with_fanart = [s for s in all_series if s.has_fanart()]
print("\nFinal Statistics:") logger.info("Final statistics", extra={
print(f" Series with NFO: {len(series_with_nfo)}/{len(all_series)}") "total_series": len(all_series),
print(f" Series with poster: {len(series_with_poster)}/{len(all_series)}") "with_nfo": len(series_with_nfo),
print(f" Series with logo: {len(series_with_logo)}/{len(all_series)}") "with_poster": len(series_with_poster),
print(f" Series with fanart: {len(series_with_fanart)}/{len(all_series)}") "with_logo": len(series_with_logo),
"with_fanart": len(series_with_fanart),
})
except Exception as e: except Exception:
print(f"\n❌ Error: {e}") logger.exception("Failed to process NFO files")
import traceback
traceback.print_exc()
return 1 return 1
finally: finally:
await manager.close() await manager.close()
@@ -111,15 +114,15 @@ async def scan_and_create_nfo():
async def check_nfo_status(): async def check_nfo_status():
"""Check NFO status for all series.""" """Check NFO status for all series."""
print("=" * 70) logger.info("%s", "=" * 70)
print("NFO Status Check") logger.info("NFO Status Check")
print("=" * 70) logger.info("%s", "=" * 70)
if not settings.anime_directory: if not settings.anime_directory:
print("\n❌ Error: ANIME_DIRECTORY not configured") logger.error("ANIME_DIRECTORY not configured")
return 1 return 1
print(f"\nAnime Directory: {settings.anime_directory}") logger.info("Anime Directory: %s", settings.anime_directory)
# Create series list (no NFO service needed for status check) # Create series list (no NFO service needed for status check)
from src.core.entities.SerieList import SerieList from src.core.entities.SerieList import SerieList
@@ -127,10 +130,10 @@ async def check_nfo_status():
all_series = serie_list.get_all() all_series = serie_list.get_all()
if not all_series: if not all_series:
print("\n⚠️ No series found") logger.warning("No series found")
return 0 return 0
print(f"\nTotal series: {len(all_series)}") logger.info("Total series: %d", len(all_series))
# Categorize series # Categorize series
with_nfo = [] with_nfo = []
@@ -142,47 +145,61 @@ async def check_nfo_status():
else: else:
without_nfo.append(serie) without_nfo.append(serie)
print(f"\nWith NFO: {len(with_nfo)} ({len(with_nfo) * 100 // len(all_series)}%)") logger.info(
print(f"Without NFO: {len(without_nfo)} ({len(without_nfo) * 100 // len(all_series)}%)") "Series NFO coverage",
extra={
"with_nfo": len(with_nfo),
"without_nfo": len(without_nfo),
"total": len(all_series),
},
)
if without_nfo: if without_nfo:
print("\nSeries missing NFO:") logger.info("Series missing NFO: %d", len(without_nfo))
for serie in without_nfo[:10]: for serie in without_nfo[:10]:
print(f"{serie.name} ({serie.folder})") logger.debug("Missing NFO: %s (%s)", serie.name, serie.folder)
if len(without_nfo) > 10: if len(without_nfo) > 10:
print(f" ... and {len(without_nfo) - 10} more") logger.info("... and %d more", len(without_nfo) - 10)
# Media file statistics # Media file statistics
with_poster = sum(1 for s in all_series if s.has_poster()) with_poster = sum(1 for s in all_series if s.has_poster())
with_logo = sum(1 for s in all_series if s.has_logo()) with_logo = sum(1 for s in all_series if s.has_logo())
with_fanart = sum(1 for s in all_series if s.has_fanart()) with_fanart = sum(1 for s in all_series if s.has_fanart())
print("\nMedia Files:") logger.info(
print(f" Posters: {with_poster}/{len(all_series)} ({with_poster * 100 // len(all_series)}%)") "Media file coverage",
print(f" Logos: {with_logo}/{len(all_series)} ({with_logo * 100 // len(all_series)}%)") extra={
print(f" Fanart: {with_fanart}/{len(all_series)} ({with_fanart * 100 // len(all_series)}%)") "posters": with_poster,
"logos": with_logo,
"fanart": with_fanart,
"total": len(all_series),
},
)
return 0 return 0
async def update_nfo_files(): async def update_nfo_files():
"""Update existing NFO files with fresh data from TMDB.""" """Update existing NFO files with fresh data from TMDB."""
print("=" * 70) logger.info("%s", "=" * 70)
print("NFO Update Tool") logger.info("NFO Update Tool")
print("=" * 70) logger.info("%s", "=" * 70)
if not settings.tmdb_api_key: if not settings.tmdb_api_key:
print("\n❌ Error: TMDB_API_KEY not configured") logger.error("TMDB_API_KEY not configured")
print(" Set TMDB_API_KEY in .env file or environment") logger.error("Set TMDB_API_KEY in .env file or environment")
print(" Get API key from: https://www.themoviedb.org/settings/api") logger.error("Get API key from: https://www.themoviedb.org/settings/api")
return 1 return 1
if not settings.anime_directory: if not settings.anime_directory:
print("\n❌ Error: ANIME_DIRECTORY not configured") logger.error("ANIME_DIRECTORY not configured")
return 1 return 1
print(f"\nAnime Directory: {settings.anime_directory}") logger.info("Anime Directory: %s", settings.anime_directory)
print(f"Download media: {settings.nfo_download_poster or settings.nfo_download_logo or settings.nfo_download_fanart}") logger.info(
"Download media: %s",
settings.nfo_download_poster or settings.nfo_download_logo or settings.nfo_download_fanart,
)
# Get series with NFO # Get series with NFO
from src.core.entities.SerieList import SerieList from src.core.entities.SerieList import SerieList
@@ -191,20 +208,20 @@ async def update_nfo_files():
series_with_nfo = [s for s in all_series if s.has_nfo()] series_with_nfo = [s for s in all_series if s.has_nfo()]
if not series_with_nfo: if not series_with_nfo:
print("\n⚠️ No series with NFO files found") logger.warning("No series with NFO files found")
print(" Run 'scan' command first to create NFO files") logger.info("Run 'scan' command first to create NFO files")
return 0 return 0
print(f"\nFound {len(series_with_nfo)} series with NFO files") logger.info("Found %d series with NFO files", len(series_with_nfo))
print("Updating NFO files with fresh data from TMDB...") logger.info("Updating NFO files with fresh data from TMDB...")
print("(This may take a while)") logger.info("This may take a while")
# Initialize NFO service using factory # Initialize NFO service using factory
from src.core.services.nfo_factory import create_nfo_service from src.core.services.nfo_factory import create_nfo_service
try: try:
nfo_service = create_nfo_service() nfo_service = create_nfo_service()
except ValueError as e: except ValueError as e:
print(f"\nError: {e}") logger.error("Error creating NFO service: %s", e)
return 1 return 1
success_count = 0 success_count = 0
@@ -212,7 +229,7 @@ async def update_nfo_files():
try: try:
for i, serie in enumerate(series_with_nfo, 1): for i, serie in enumerate(series_with_nfo, 1):
print(f"\n[{i}/{len(series_with_nfo)}] Updating: {serie.name}") logger.info("[%d/%d] Updating: %s", i, len(series_with_nfo), serie.name)
try: try:
await nfo_service.update_tvshow_nfo( await nfo_service.update_tvshow_nfo(
@@ -221,27 +238,25 @@ async def update_nfo_files():
settings.nfo_download_poster or settings.nfo_download_poster or
settings.nfo_download_logo or settings.nfo_download_logo or
settings.nfo_download_fanart settings.nfo_download_fanart
),
) )
) logger.info("Updated successfully: %s", serie.name)
print(f" ✅ Updated successfully")
success_count += 1 success_count += 1
# Small delay to respect API rate limits # Small delay to respect API rate limits
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
except Exception as e: except Exception as e:
print(f" ❌ Error: {e}") logger.exception("Failed to update NFO for %s", serie.name)
error_count += 1 error_count += 1
print("\n" + "=" * 70) logger.info("%s", "=" * 70)
print(f"Update complete!") logger.info("Update complete")
print(f" Success: {success_count}") logger.info("Success: %d", success_count)
print(f" Errors: {error_count}") logger.info("Errors: %d", error_count)
except Exception as e: except Exception:
print(f"\nFatal error: {e}") logger.exception("Fatal error during NFO update")
import traceback
traceback.print_exc()
return 1 return 1
finally: finally:
await nfo_service.close() await nfo_service.close()
@@ -251,16 +266,18 @@ async def update_nfo_files():
def main(): def main():
"""Main CLI entry point.""" """Main CLI entry point."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
if len(sys.argv) < 2: if len(sys.argv) < 2:
print("NFO Management Tool") logger.info("NFO Management Tool")
print("\nUsage:") logger.info("\nUsage:")
print(" python -m src.cli.nfo_cli scan # Scan and create missing NFO files") logger.info(" python -m src.cli.nfo_cli scan # Scan and create missing NFO files")
print(" python -m src.cli.nfo_cli status # Check NFO status for all series") logger.info(" python -m src.cli.nfo_cli status # Check NFO status for all series")
print(" python -m src.cli.nfo_cli update # Update existing NFO files with fresh data") logger.info(" python -m src.cli.nfo_cli update # Update existing NFO files with fresh data")
print("\nConfiguration:") logger.info("\nConfiguration:")
print(" Set TMDB_API_KEY in .env file") logger.info(" Set TMDB_API_KEY in .env file")
print(" Set NFO_AUTO_CREATE=true to enable auto-creation") logger.info(" Set NFO_AUTO_CREATE=true to enable auto-creation")
print(" Set NFO_UPDATE_ON_SCAN=true to update existing NFOs during scan") logger.info(" Set NFO_UPDATE_ON_SCAN=true to update existing NFOs during scan")
return 1 return 1
command = sys.argv[1].lower() command = sys.argv[1].lower()
@@ -272,8 +289,8 @@ def main():
elif command == "update": elif command == "update":
return asyncio.run(update_nfo_files()) return asyncio.run(update_nfo_files())
else: else:
print(f"Unknown command: {command}") logger.error("Unknown command: %s", command)
print("Use 'scan', 'status', or 'update'") logger.info("Use 'scan', 'status', or 'update'")
return 1 return 1

View File

@@ -15,7 +15,7 @@ import os
import re import re
import traceback import traceback
import uuid import uuid
from typing import Iterable, Iterator, Optional from typing import Callable, Iterable, Iterator, Optional
from events import Events from events import Events
@@ -43,12 +43,17 @@ class SerieScanner:
scanner = SerieScanner("/path/to/anime", loader) scanner = SerieScanner("/path/to/anime", loader)
scanner.scan() scanner.scan()
# Results are in scanner.keyDict # 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__( def __init__(
self, self,
basePath: str, basePath: str,
loader: Loader, loader: Loader,
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
) -> None: ) -> None:
""" """
Initialize the SerieScanner. Initialize the SerieScanner.
@@ -56,7 +61,11 @@ class SerieScanner:
Args: Args:
basePath: Base directory containing anime series basePath: Base directory containing anime series
loader: Loader instance for fetching series information 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: Raises:
ValueError: If basePath is invalid or doesn't exist ValueError: If basePath is invalid or doesn't exist
@@ -75,6 +84,7 @@ class SerieScanner:
self.directory: str = abs_path self.directory: str = abs_path
self.keyDict: dict[str, Serie] = {} self.keyDict: dict[str, Serie] = {}
self.loader: Loader = loader self.loader: Loader = loader
self._db_lookup: Optional[Callable[[str], Optional[Serie]]] = db_lookup
self._current_operation_id: Optional[str] = None self._current_operation_id: Optional[str] = None
self.events = Events() self.events = Events()
@@ -268,6 +278,30 @@ class SerieScanner:
) )
serie = self.__read_data_from_file(folder) 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 ( if (
serie is not None serie is not None
and serie.key and serie.key

View File

@@ -14,7 +14,7 @@ import asyncio
import logging import logging
import os import os
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional
from events import Events from events import Events
@@ -143,12 +143,16 @@ class SeriesApp:
def __init__( def __init__(
self, self,
directory_to_search: str, directory_to_search: str,
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
): ):
""" """
Initialize SeriesApp. Initialize SeriesApp.
Args: Args:
directory_to_search: Base directory for anime series 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 self.directory_to_search = directory_to_search
@@ -162,7 +166,7 @@ class SeriesApp:
self.loaders = Loaders() self.loaders = Loaders()
self.loader = self.loaders.GetLoader(key="aniworld.to") self.loader = self.loaders.GetLoader(key="aniworld.to")
self.serie_scanner = SerieScanner( 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 # Skip automatic loading from data files - series will be loaded
# from database by the service layer during application setup # from database by the service layer during application setup
@@ -171,23 +175,26 @@ class SeriesApp:
# Initialize empty list - series loaded later via load_series_from_list() # Initialize empty list - series loaded later via load_series_from_list()
# No need to call _init_list_sync() anymore # No need to call _init_list_sync() anymore
# Initialize NFO service if TMDB API key is configured # Initialize NFO service if a TMDB API key is configured
self.nfo_service: Optional[NFOService] = None self.nfo_service: Optional[NFOService] = None
if settings.tmdb_api_key:
try: try:
from src.core.services.nfo_factory import get_nfo_factory from src.core.services.nfo_factory import get_nfo_factory
factory = get_nfo_factory() factory = get_nfo_factory()
self.nfo_service = factory.create() self.nfo_service = factory.create()
logger.info("NFO service initialized successfully") logger.info("NFO service initialized successfully")
except (ValueError, Exception) as e: # pylint: disable=broad-except except ValueError:
logger.warning( logger.info(
"Failed to initialize NFO service: %s", str(e) "NFO service not available — TMDB API key not configured"
) )
self.nfo_service = None self.nfo_service = None
except Exception as e: # pylint: disable=broad-except
logger.warning("Failed to initialize NFO service: %s", str(e))
self.nfo_service = None
logger.info( logger.info(
"SeriesApp initialized for directory: %s", "SeriesApp initialized for directory: %s",
directory_to_search directory_to_search,
) )
@property @property
@@ -438,6 +445,9 @@ class SeriesApp:
try: try:
def download_progress_handler(progress_info): def download_progress_handler(progress_info):
"""Handle download progress events from loader.""" """Handle download progress events from loader."""
# Throttle progress logging to avoid spam
status = progress_info.get("status", "")
if status in ("downloading", "finished"):
logger.debug( logger.debug(
"download_progress_handler called with: %s", progress_info "download_progress_handler called with: %s", progress_info
) )

View File

@@ -121,11 +121,11 @@ class SerieList:
def load_series(self) -> None: def load_series(self) -> None:
"""Populate the in-memory map with metadata discovered on disk.""" """Populate the in-memory map with metadata discovered on disk."""
logging.info("Scanning anime folders in %s", self.directory) logger.info("Scanning anime folders in %s", self.directory)
try: try:
entries: Iterable[str] = os.listdir(self.directory) entries: Iterable[str] = os.listdir(self.directory)
except OSError as error: except OSError as error:
logging.error( logger.error(
"Unable to scan directory %s: %s", "Unable to scan directory %s: %s",
self.directory, self.directory,
error, error,
@@ -145,7 +145,7 @@ class SerieList:
for anime_folder in entries: for anime_folder in entries:
anime_path = os.path.join(self.directory, anime_folder, "data") anime_path = os.path.join(self.directory, anime_folder, "data")
if os.path.isfile(anime_path): if os.path.isfile(anime_path):
logging.debug("Found data file for folder %s", anime_folder) logger.debug("Found data file for folder %s", anime_folder)
serie = self._load_data(anime_folder, anime_path) serie = self._load_data(anime_folder, anime_path)
if serie: if serie:
@@ -159,7 +159,7 @@ class SerieList:
nfo_stats["with_nfo"] += 1 nfo_stats["with_nfo"] += 1
else: else:
nfo_stats["without_nfo"] += 1 nfo_stats["without_nfo"] += 1
logging.debug( logger.debug(
"Series '%s' (key: %s) is missing tvshow.nfo", "Series '%s' (key: %s) is missing tvshow.nfo",
serie.name, serie.name,
serie.key serie.key
@@ -173,7 +173,7 @@ class SerieList:
media_stats["with_poster"] += 1 media_stats["with_poster"] += 1
else: else:
media_stats["without_poster"] += 1 media_stats["without_poster"] += 1
logging.debug( logger.debug(
"Series '%s' (key: %s) is missing poster.jpg", "Series '%s' (key: %s) is missing poster.jpg",
serie.name, serie.name,
serie.key serie.key
@@ -184,7 +184,7 @@ class SerieList:
media_stats["with_logo"] += 1 media_stats["with_logo"] += 1
else: else:
media_stats["without_logo"] += 1 media_stats["without_logo"] += 1
logging.debug( logger.debug(
"Series '%s' (key: %s) is missing logo.png", "Series '%s' (key: %s) is missing logo.png",
serie.name, serie.name,
serie.key serie.key
@@ -195,7 +195,7 @@ class SerieList:
media_stats["with_fanart"] += 1 media_stats["with_fanart"] += 1
else: else:
media_stats["without_fanart"] += 1 media_stats["without_fanart"] += 1
logging.debug( logger.debug(
"Series '%s' (key: %s) is missing fanart.jpg", "Series '%s' (key: %s) is missing fanart.jpg",
serie.name, serie.name,
serie.key serie.key
@@ -203,20 +203,20 @@ class SerieList:
continue continue
logging.warning( logger.warning(
"Skipping folder %s because no metadata file was found", "Skipping folder %s because no metadata file was found",
anime_folder, anime_folder,
) )
# Log summary statistics # Log summary statistics
if nfo_stats["total"] > 0: if nfo_stats["total"] > 0:
logging.info( logger.info(
"NFO scan complete: %d series total, %d with NFO, %d without NFO", "NFO scan complete: %d series total, %d with NFO, %d without NFO",
nfo_stats["total"], nfo_stats["total"],
nfo_stats["with_nfo"], nfo_stats["with_nfo"],
nfo_stats["without_nfo"] nfo_stats["without_nfo"]
) )
logging.info( logger.info(
"Media scan complete: Poster (%d/%d), Logo (%d/%d), Fanart (%d/%d)", "Media scan complete: Poster (%d/%d), Logo (%d/%d), Fanart (%d/%d)",
media_stats["with_poster"], media_stats["with_poster"],
nfo_stats["total"], nfo_stats["total"],
@@ -241,14 +241,14 @@ class SerieList:
serie = Serie.load_from_file(data_path) serie = Serie.load_from_file(data_path)
# Store by key, not folder # Store by key, not folder
self.keyDict[serie.key] = serie self.keyDict[serie.key] = serie
logging.debug( logger.debug(
"Successfully loaded metadata for %s (key: %s)", "Successfully loaded metadata for %s (key: %s)",
anime_folder, anime_folder,
serie.key serie.key
) )
return serie return serie
except (OSError, JSONDecodeError, KeyError, ValueError) as error: except (OSError, JSONDecodeError, KeyError, ValueError) as error:
logging.error( logger.error(
"Failed to load metadata for folder %s from %s: %s", "Failed to load metadata for folder %s from %s: %s",
anime_folder, anime_folder,
data_path, data_path,

View File

@@ -64,6 +64,16 @@ class Serie:
f"episodeDict={self.episodeDict}{year_str})" f"episodeDict={self.episodeDict}{year_str})"
) )
def __repr__(self):
"""Concise developer representation of Serie object."""
season_count = len(self.episodeDict)
episode_count = sum(len(eps) for eps in self.episodeDict.values())
year_str = f", year={self.year}" if self.year else ""
return (
f"Serie(key={self.key!r}, name={self.name!r}"
f"{year_str}, seasons={season_count}, episodes={episode_count})"
)
@property @property
def key(self) -> str: def key(self) -> str:
""" """
@@ -261,7 +271,11 @@ class Serie:
'Dororo (2025)' 'Dororo (2025)'
""" """
if self._year: if self._year:
return f"{self._name} ({self._year})" import re
year_suffix = f" ({self._year})"
# Strip ALL trailing year suffixes before appending to prevent duplication
clean_name = re.sub(r'(\s*\(\d{4}\))+\s*$', '', self._name).strip()
return f"{clean_name}{year_suffix}"
return self._name return self._name
@property @property

View File

@@ -55,7 +55,8 @@ class RecoveryStrategies:
if attempt == max_retries - 1: if attempt == max_retries - 1:
raise raise
logger.warning( logger.warning(
f"Network error on attempt {attempt + 1}, retrying..." "Network error on attempt %d, retrying...",
attempt + 1,
) )
continue continue
@@ -72,7 +73,8 @@ class RecoveryStrategies:
if attempt == max_retries - 1: if attempt == max_retries - 1:
raise raise
logger.warning( logger.warning(
f"Download error on attempt {attempt + 1}, retrying..." "Download error on attempt %d, retrying...",
attempt + 1,
) )
continue continue
@@ -92,7 +94,7 @@ class FileCorruptionDetector:
# Video files should be at least 1MB # Video files should be at least 1MB
return file_size > 1024 * 1024 return file_size > 1024 * 1024
except Exception as e: except Exception as e:
logger.error(f"Error checking file validity: {e}") logger.error("Error checking file validity: %s", e)
return False return False
@@ -123,13 +125,18 @@ def with_error_recovery(
last_error = e last_error = e
if attempt < max_retries - 1: if attempt < max_retries - 1:
logger.warning( logger.warning(
f"Error in {context} (attempt {attempt + 1}/" "Error in %s (attempt %d/%d): %s, retrying...",
f"{max_retries}): {e}, retrying..." context,
attempt + 1,
max_retries,
e,
) )
else: else:
logger.error( logger.error(
f"Error in {context} failed after {max_retries} " "Error in %s failed after %d attempts: %s",
f"attempts: {e}" context,
max_retries,
e,
) )
if last_error: if last_error:

View File

@@ -12,6 +12,8 @@ from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
class OperationType(str, Enum): class OperationType(str, Enum):
"""Types of operations that can report progress.""" """Types of operations that can report progress."""
@@ -313,7 +315,7 @@ class CallbackManager:
callback.on_progress(context) callback.on_progress(context)
except Exception as e: except Exception as e:
# Log but don't let callback errors break the operation # Log but don't let callback errors break the operation
logging.error( logger.error(
"Error in progress callback %s: %s", "Error in progress callback %s: %s",
callback, callback,
e, e,
@@ -332,7 +334,7 @@ class CallbackManager:
callback.on_error(context) callback.on_error(context)
except Exception as e: except Exception as e:
# Log but don't let callback errors break the operation # Log but don't let callback errors break the operation
logging.error( logger.error(
"Error in error callback %s: %s", "Error in error callback %s: %s",
callback, callback,
e, e,
@@ -351,7 +353,7 @@ class CallbackManager:
callback.on_completion(context) callback.on_completion(context)
except Exception as e: except Exception as e:
# Log but don't let callback errors break the operation # Log but don't let callback errors break the operation
logging.error( logger.error(
"Error in completion callback %s: %s", "Error in completion callback %s: %s",
callback, callback,
e, e,

View File

@@ -42,9 +42,9 @@ def _cleanup_temp_file(temp_path: str) -> None:
if os.path.exists(path): if os.path.exists(path):
try: try:
os.remove(path) os.remove(path)
logging.debug(f"Removed temp file: {path}") logger.debug("Removed temp file: %s", path)
except OSError as exc: except OSError as exc:
logging.warning(f"Failed to remove temp file {path}: {exc}") logger.warning("Failed to remove temp file %s: %s", path, exc)
# Imported shared provider configuration # Imported shared provider configuration
from .provider_config import ( from .provider_config import (
@@ -56,6 +56,8 @@ from .provider_config import (
ProviderType, ProviderType,
) )
logger = logging.getLogger(__name__)
# Configure persistent loggers but don't add duplicate handlers when module # Configure persistent loggers but don't add duplicate handlers when module
# is imported multiple times (common in test environments). # is imported multiple times (common in test environments).
# Use absolute paths for log files to prevent security issues # Use absolute paths for log files to prevent security issues
@@ -142,16 +144,16 @@ class AniworldLoader(Loader):
def clear_cache(self): def clear_cache(self):
"""Clear the cached HTML data.""" """Clear the cached HTML data."""
logging.debug("Clearing HTML cache") logger.debug("Clearing HTML cache")
self._KeyHTMLDict = {} self._KeyHTMLDict = {}
self._EpisodeHTMLDict = {} self._EpisodeHTMLDict = {}
logging.debug("HTML cache cleared successfully") logger.debug("HTML cache cleared successfully")
def remove_from_cache(self): def remove_from_cache(self):
"""Remove episode HTML from cache.""" """Remove episode HTML from cache."""
logging.debug("Removing episode HTML from cache") logger.debug("Removing episode HTML from cache")
self._EpisodeHTMLDict = {} self._EpisodeHTMLDict = {}
logging.debug("Episode HTML cache cleared") logger.debug("Episode HTML cache cleared")
def search(self, word: str) -> list: def search(self, word: str) -> list:
"""Search for anime series. """Search for anime series.
@@ -162,30 +164,30 @@ class AniworldLoader(Loader):
Returns: Returns:
List of found series List of found series
""" """
logging.info(f"Searching for anime with keyword: '{word}'") logger.info("Searching for anime with keyword: '%s'", word)
search_url = ( search_url = (
f"{self.ANIWORLD_TO}/ajax/seriesSearch?keyword={quote(word)}" f"{self.ANIWORLD_TO}/ajax/seriesSearch?keyword={quote(word)}"
) )
logging.debug(f"Search URL: {search_url}") logger.debug("Search URL: %s", search_url)
anime_list = self.fetch_anime_list(search_url) anime_list = self.fetch_anime_list(search_url)
logging.info(f"Found {len(anime_list)} anime series for keyword '{word}'") logger.info("Found %s anime series for keyword '%s'", len(anime_list), word)
return anime_list return anime_list
def fetch_anime_list(self, url: str) -> list: def fetch_anime_list(self, url: str) -> list:
logging.debug(f"Fetching anime list from URL: {url}") logger.debug("Fetching anime list from URL: %s", url)
response = self.session.get(url, timeout=self.DEFAULT_REQUEST_TIMEOUT) response = self.session.get(url, timeout=self.DEFAULT_REQUEST_TIMEOUT)
response.raise_for_status() response.raise_for_status()
logging.debug(f"Response status code: {response.status_code}") logger.debug("Response status code: %s", response.status_code)
clean_text = response.text.strip() clean_text = response.text.strip()
try: try:
decoded_data = json.loads(html.unescape(clean_text)) decoded_data = json.loads(html.unescape(clean_text))
logging.debug(f"Successfully decoded JSON data on first attempt") logger.debug("Successfully decoded JSON data on first attempt")
return decoded_data if isinstance(decoded_data, list) else [] return decoded_data if isinstance(decoded_data, list) else []
except json.JSONDecodeError: except json.JSONDecodeError:
logging.warning("Initial JSON decode failed, attempting cleanup") logger.warning("Initial JSON decode failed, attempting cleanup")
try: try:
# Remove BOM and problematic characters # Remove BOM and problematic characters
clean_text = clean_text.encode('utf-8').decode('utf-8-sig') clean_text = clean_text.encode('utf-8').decode('utf-8-sig')
@@ -193,10 +195,10 @@ class AniworldLoader(Loader):
clean_text = re.sub(r'[\x00-\x1F\x7F-\x9F]', '', clean_text) clean_text = re.sub(r'[\x00-\x1F\x7F-\x9F]', '', clean_text)
# Parse the new text # Parse the new text
decoded_data = json.loads(clean_text) decoded_data = json.loads(clean_text)
logging.debug("Successfully decoded JSON after cleanup") logger.debug("Successfully decoded JSON after cleanup")
return decoded_data if isinstance(decoded_data, list) else [] return decoded_data if isinstance(decoded_data, list) else []
except (requests.RequestException, json.JSONDecodeError) as exc: except (requests.RequestException, json.JSONDecodeError) as exc:
logging.error(f"Failed to decode anime list from {url}: {exc}") logger.error("Failed to decode anime list from %s: %s", url, exc)
raise ValueError("Could not get valid anime: ") from exc raise ValueError("Could not get valid anime: ") from exc
def _get_language_key(self, language: str) -> int: def _get_language_key(self, language: str) -> int:
@@ -214,7 +216,7 @@ class AniworldLoader(Loader):
language_code = 2 language_code = 2
if language == "German Sub": if language == "German Sub":
language_code = 3 language_code = 3
logging.debug(f"Converted language '{language}' to code {language_code}") logger.debug("Converted language '%s' to code %s", language, language_code)
return language_code return language_code
def is_language( def is_language(
@@ -225,7 +227,7 @@ class AniworldLoader(Loader):
language: str = "German Dub" language: str = "German Dub"
) -> bool: ) -> bool:
"""Check if episode is available in specified language.""" """Check if episode is available in specified language."""
logging.debug(f"Checking if S{season:02}E{episode:03} ({key}) is available in {language}") logger.debug("Checking if S%02dE%03d (%s) is available in %s", season, episode, key, language)
language_code = self._get_language_key(language) language_code = self._get_language_key(language)
episode_soup = BeautifulSoup( episode_soup = BeautifulSoup(
@@ -244,7 +246,7 @@ class AniworldLoader(Loader):
languages.append(int(lang_key)) languages.append(int(lang_key))
is_available = language_code in languages is_available = language_code in languages
logging.debug(f"Available languages for S{season:02}E{episode:03}: {languages}, requested: {language_code}, available: {is_available}") logger.debug("Available languages for S%02dE%03d: %s, requested: %s, available: %s", season, episode, languages, language_code, is_available)
return is_available return is_available
def download( def download(
@@ -270,15 +272,15 @@ class AniworldLoader(Loader):
Returns: Returns:
bool: True if download succeeded, False otherwise bool: True if download succeeded, False otherwise
""" """
logging.info( logger.info(
f"Starting download for S{season:02}E{episode:03} " "Starting download for S%02dE%03d (%s) in %s",
f"({key}) in {language}" season, episode, key, language
) )
sanitized_anime_title = ''.join( sanitized_anime_title = ''.join(
char for char in self.get_title(key) char for char in self.get_title(key)
if char not in self.INVALID_PATH_CHARS if char not in self.INVALID_PATH_CHARS
) )
logging.debug(f"Sanitized anime title: {sanitized_anime_title}") logger.debug("Sanitized anime title: %s", sanitized_anime_title)
if season == 0: if season == 0:
output_file = ( output_file = (
@@ -298,26 +300,26 @@ class AniworldLoader(Loader):
f"Season {season}" f"Season {season}"
) )
output_path = os.path.join(folder_path, output_file) output_path = os.path.join(folder_path, output_file)
logging.debug(f"Output path: {output_path}") logger.debug("Output path: %s", output_path)
os.makedirs(os.path.dirname(output_path), exist_ok=True) os.makedirs(os.path.dirname(output_path), exist_ok=True)
temp_dir = "./Temp/" temp_dir = "./Temp/"
os.makedirs(os.path.dirname(temp_dir), exist_ok=True) os.makedirs(os.path.dirname(temp_dir), exist_ok=True)
temp_path = os.path.join(temp_dir, output_file) temp_path = os.path.join(temp_dir, output_file)
logging.debug(f"Temporary path: {temp_path}") logger.debug("Temporary path: %s", temp_path)
for provider in self.SUPPORTED_PROVIDERS: for provider in self.SUPPORTED_PROVIDERS:
logging.debug(f"Attempting download with provider: {provider}") logger.debug("Attempting download with provider: %s", provider)
link, header = self._get_direct_link_from_provider( link, header = self._get_direct_link_from_provider(
season, episode, key, language season, episode, key, language
) )
logging.debug("Direct link obtained from provider") logger.debug("Direct link obtained from provider")
cancel_flag = self._cancel_flag cancel_flag = self._cancel_flag
def events_progress_hook(d): def events_progress_hook(d):
if cancel_flag.is_set(): if cancel_flag.is_set():
logging.info("Cancellation detected in progress hook") logger.info("Cancellation detected in progress hook")
raise DownloadCancelled("Download cancelled by user") raise DownloadCancelled("Download cancelled by user")
# Fire the event for progress # Fire the event for progress
self.events.download_progress(d) self.events.download_progress(d)
@@ -329,60 +331,57 @@ class AniworldLoader(Loader):
'no_warnings': True, 'no_warnings': True,
'progress_with_newline': False, 'progress_with_newline': False,
'nocheckcertificate': True, 'nocheckcertificate': True,
'logger': logger,
'progress_hooks': [events_progress_hook], 'progress_hooks': [events_progress_hook],
} }
if header: if header:
ydl_opts['http_headers'] = header ydl_opts['http_headers'] = header
logging.debug("Using custom headers for download") logger.debug("Using custom headers for download")
try: try:
logging.debug("Starting YoutubeDL download") logger.info("Starting download: %s", output_file)
logging.debug(f"Download link: {link[:100]}...") logger.debug("Download link: %s...", link[:100])
logging.debug(f"YDL options: {ydl_opts}") logger.debug("YDL options: %s", ydl_opts)
with YoutubeDL(ydl_opts) as ydl: with YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(link, download=True) info = ydl.extract_info(link, download=True)
logging.debug( logger.debug(
f"Download info: " "Download info: title=%s, filesize=%s",
f"title={info.get('title')}, " info.get('title'), info.get('filesize')
f"filesize={info.get('filesize')}"
) )
if os.path.exists(temp_path): if os.path.exists(temp_path):
logging.debug("Moving file from temp to final destination") logger.debug("Moving file from temp to final destination")
# Use copyfile instead of copy to avoid metadata permission issues # Use copyfile instead of copy to avoid metadata permission issues
shutil.copyfile(temp_path, output_path) shutil.copyfile(temp_path, output_path)
os.remove(temp_path) os.remove(temp_path)
logging.info( logger.info("Download completed successfully: %s", output_file)
f"Download completed successfully: {output_file}"
)
self.clear_cache() self.clear_cache()
return True return True
else: else:
logging.error( logger.error("Download failed: temp file not found at %s", temp_path)
f"Download failed: temp file not found at {temp_path}"
)
self.clear_cache() self.clear_cache()
return False return False
except BrokenPipeError as e: except BrokenPipeError as e:
logging.error( logger.error(
f"Broken pipe error with provider {provider}: {e}. " "Broken pipe error with provider %s: %s. "
f"This usually means the stream connection was closed." "This usually means the stream connection was closed.",
provider, e
) )
_cleanup_temp_file(temp_path) _cleanup_temp_file(temp_path)
continue continue
except Exception as e: except Exception as e:
logging.error( logger.error(
f"YoutubeDL download failed with provider {provider}: " "YoutubeDL download failed with provider %s: %s: %s",
f"{type(e).__name__}: {e}" provider, type(e).__name__, e
) )
_cleanup_temp_file(temp_path) _cleanup_temp_file(temp_path)
continue continue
break break
# If we get here, all providers failed # If we get here, all providers failed
logging.error("All download providers failed") logger.error("All download providers failed")
_cleanup_temp_file(temp_path) _cleanup_temp_file(temp_path)
self.clear_cache() self.clear_cache()
return False return False
@@ -393,7 +392,7 @@ class AniworldLoader(Loader):
def get_title(self, key: str) -> str: def get_title(self, key: str) -> str:
"""Get anime title from series key.""" """Get anime title from series key."""
logging.debug(f"Getting title for key: {key}") logger.debug("Getting title for key: %s", key)
soup = BeautifulSoup( soup = BeautifulSoup(
self._get_key_html(key).content, self._get_key_html(key).content,
'html.parser' 'html.parser'
@@ -405,10 +404,10 @@ class AniworldLoader(Loader):
span_tag = h1_tag.find('span') if h1_tag else None span_tag = h1_tag.find('span') if h1_tag else None
if span_tag: if span_tag:
title = span_tag.text title = span_tag.text
logging.debug(f"Found title: {title}") logger.debug("Found title: %s", title)
return title return title
logging.warning(f"No title found for key: {key}") logger.warning("No title found for key: %s", key)
return "" return ""
def get_year(self, key: str) -> int | None: def get_year(self, key: str) -> int | None:
@@ -423,7 +422,7 @@ class AniworldLoader(Loader):
Returns: Returns:
int or None: Release year if found, None otherwise int or None: Release year if found, None otherwise
""" """
logging.debug(f"Getting year for key: {key}") logger.debug("Getting year for key: %s", key)
try: try:
soup = BeautifulSoup( soup = BeautifulSoup(
self._get_key_html(key).content, self._get_key_html(key).content,
@@ -439,7 +438,7 @@ class AniworldLoader(Loader):
match = re.search(r'(\d{4})', text) match = re.search(r'(\d{4})', text)
if match: if match:
year = int(match.group(1)) year = int(match.group(1))
logging.debug(f"Found year in metadata: {year}") logger.debug("Found year in metadata: %s", year)
return year return year
# Try alternative: look for year in genre/info section # Try alternative: look for year in genre/info section
@@ -449,14 +448,14 @@ class AniworldLoader(Loader):
match = re.search(r'\b(19\d{2}|20\d{2})\b', text) match = re.search(r'\b(19\d{2}|20\d{2})\b', text)
if match: if match:
year = int(match.group(1)) year = int(match.group(1))
logging.debug(f"Found year in info section: {year}") logger.debug("Found year in info section: %s", year)
return year return year
logging.debug(f"No year found for key: {key}") logger.debug("No year found for key: %s", key)
return None return None
except Exception as e: except Exception as e:
logging.warning(f"Error extracting year for key {key}: {e}") logger.warning("Error extracting year for key %s: %s", key, e)
return None return None
def _get_key_html(self, key: str): def _get_key_html(self, key: str):
@@ -469,18 +468,18 @@ class AniworldLoader(Loader):
Cached or fetched HTML response Cached or fetched HTML response
""" """
if key in self._KeyHTMLDict: if key in self._KeyHTMLDict:
logging.debug(f"Using cached HTML for key: {key}") logger.debug("Using cached HTML for key: %s", key)
return self._KeyHTMLDict[key] return self._KeyHTMLDict[key]
# Sanitize key parameter for URL # Sanitize key parameter for URL
safe_key = quote(key, safe='') safe_key = quote(key, safe='')
url = f"{self.ANIWORLD_TO}/anime/stream/{safe_key}" url = f"{self.ANIWORLD_TO}/anime/stream/{safe_key}"
logging.debug(f"Fetching HTML for key: {key} from {url}") logger.debug("Fetching HTML for key: %s from %s", key, url)
self._KeyHTMLDict[key] = self.session.get( self._KeyHTMLDict[key] = self.session.get(
url, url,
timeout=self.DEFAULT_REQUEST_TIMEOUT timeout=self.DEFAULT_REQUEST_TIMEOUT
) )
logging.debug(f"Cached HTML for key: {key}") logger.debug("Cached HTML for key: %s", key)
return self._KeyHTMLDict[key] return self._KeyHTMLDict[key]
def _get_episode_html(self, season: int, episode: int, key: str): def _get_episode_html(self, season: int, episode: int, key: str):
@@ -499,14 +498,14 @@ class AniworldLoader(Loader):
""" """
# Validate season and episode numbers # Validate season and episode numbers
if season < 1 or season > 999: if season < 1 or season > 999:
logging.error(f"Invalid season number: {season}") logger.error("Invalid season number: %s", season)
raise ValueError(f"Invalid season number: {season}") raise ValueError(f"Invalid season number: {season}")
if episode < 1 or episode > 9999: if episode < 1 or episode > 9999:
logging.error(f"Invalid episode number: {episode}") logger.error("Invalid episode number: %s", episode)
raise ValueError(f"Invalid episode number: {episode}") raise ValueError(f"Invalid episode number: {episode}")
if key in self._EpisodeHTMLDict: if key in self._EpisodeHTMLDict:
logging.debug(f"Using cached HTML for S{season:02}E{episode:03} ({key})") logger.debug("Using cached HTML for S%02dE%03d (%s)", season, episode, key)
return self._EpisodeHTMLDict[(key, season, episode)] return self._EpisodeHTMLDict[(key, season, episode)]
# Sanitize key parameter for URL # Sanitize key parameter for URL
@@ -515,10 +514,10 @@ class AniworldLoader(Loader):
f"{self.ANIWORLD_TO}/anime/stream/{safe_key}/" f"{self.ANIWORLD_TO}/anime/stream/{safe_key}/"
f"staffel-{season}/episode-{episode}" f"staffel-{season}/episode-{episode}"
) )
logging.debug(f"Fetching episode HTML from: {link}") logger.debug("Fetching episode HTML from: %s", link)
html = self.session.get(link, timeout=self.DEFAULT_REQUEST_TIMEOUT) html = self.session.get(link, timeout=self.DEFAULT_REQUEST_TIMEOUT)
self._EpisodeHTMLDict[(key, season, episode)] = html self._EpisodeHTMLDict[(key, season, episode)] = html
logging.debug(f"Cached episode HTML for S{season:02}E{episode:03} ({key})") logger.debug("Cached episode HTML for S%02dE%03d (%s)", season, episode, key)
return self._EpisodeHTMLDict[(key, season, episode)] return self._EpisodeHTMLDict[(key, season, episode)]
def _get_provider_from_html( def _get_provider_from_html(
@@ -538,7 +537,7 @@ class AniworldLoader(Loader):
2: 'https://aniworld.to/redirect/1766405'}, 2: 'https://aniworld.to/redirect/1766405'},
} }
""" """
logging.debug(f"Extracting providers from HTML for S{season:02}E{episode:03} ({key})") logger.debug("Extracting providers from HTML for S%02dE%03d (%s)", season, episode, key)
soup = BeautifulSoup( soup = BeautifulSoup(
self._get_episode_html(season, episode, key).content, self._get_episode_html(season, episode, key).content,
'html.parser' 'html.parser'
@@ -550,7 +549,7 @@ class AniworldLoader(Loader):
) )
if not episode_links: if not episode_links:
logging.warning(f"No episode links found for S{season:02}E{episode:03} ({key})") logger.warning("No episode links found for S%02dE%03d (%s)", season, episode, key)
return providers return providers
for link in episode_links: for link in episode_links:
@@ -578,9 +577,9 @@ class AniworldLoader(Loader):
providers[provider_name][lang_key] = ( providers[provider_name][lang_key] = (
f"{self.ANIWORLD_TO}{redirect_link}" f"{self.ANIWORLD_TO}{redirect_link}"
) )
logging.debug(f"Found provider: {provider_name}, lang_key: {lang_key}") logger.debug("Found provider: %s, lang_key: %s", provider_name, lang_key)
logging.debug(f"Total providers found: {len(providers)}") logger.debug("Total providers found: %s", len(providers))
return providers return providers
def _get_redirect_link( def _get_redirect_link(
@@ -591,7 +590,7 @@ class AniworldLoader(Loader):
language: str = "German Dub" language: str = "German Dub"
): ):
"""Get redirect link for episode in specified language.""" """Get redirect link for episode in specified language."""
logging.debug(f"Getting redirect link for S{season:02}E{episode:03} ({key}) in {language}") logger.debug("Getting redirect link for S%02dE%03d (%s) in %s", season, episode, key, language)
language_code = self._get_language_key(language) language_code = self._get_language_key(language)
if self.is_language(season, episode, key, language): if self.is_language(season, episode, key, language):
for (provider_name, lang_dict) in ( for (provider_name, lang_dict) in (
@@ -600,9 +599,9 @@ class AniworldLoader(Loader):
).items() ).items()
): ):
if language_code in lang_dict: if language_code in lang_dict:
logging.debug(f"Found redirect link with provider: {provider_name}") logger.debug("Found redirect link with provider: %s", provider_name)
return (lang_dict[language_code], provider_name) return (lang_dict[language_code], provider_name)
logging.warning(f"No redirect link found for S{season:02}E{episode:03} ({key}) in {language}") logger.warning("No redirect link found for S%02dE%03d (%s) in %s", season, episode, key, language)
return None return None
def _get_embeded_link( def _get_embeded_link(
@@ -613,18 +612,18 @@ class AniworldLoader(Loader):
language: str = "German Dub" language: str = "German Dub"
): ):
"""Get embedded link from redirect link.""" """Get embedded link from redirect link."""
logging.debug(f"Getting embedded link for S{season:02}E{episode:03} ({key}) in {language}") logger.debug("Getting embedded link for S%02dE%03d (%s) in %s", season, episode, key, language)
redirect_link, provider_name = ( redirect_link, provider_name = (
self._get_redirect_link(season, episode, key, language) self._get_redirect_link(season, episode, key, language)
) )
logging.debug(f"Redirect link: {redirect_link}, provider: {provider_name}") logger.debug("Redirect link: %s, provider: %s", redirect_link, provider_name)
embeded_link = self.session.get( embeded_link = self.session.get(
redirect_link, redirect_link,
timeout=self.DEFAULT_REQUEST_TIMEOUT, timeout=self.DEFAULT_REQUEST_TIMEOUT,
headers={'User-Agent': self.RANDOM_USER_AGENT} headers={'User-Agent': self.RANDOM_USER_AGENT}
).url ).url
logging.debug(f"Embedded link: {embeded_link}") logger.debug("Embedded link: %s", embeded_link)
return embeded_link return embeded_link
def _get_direct_link_from_provider( def _get_direct_link_from_provider(
@@ -635,15 +634,15 @@ class AniworldLoader(Loader):
language: str = "German Dub" language: str = "German Dub"
): ):
"""Get direct download link from streaming provider.""" """Get direct download link from streaming provider."""
logging.debug(f"Getting direct link from provider for S{season:02}E{episode:03} ({key}) in {language}") logger.debug("Getting direct link from provider for S%02dE%03d (%s) in %s", season, episode, key, language)
embeded_link = self._get_embeded_link( embeded_link = self._get_embeded_link(
season, episode, key, language season, episode, key, language
) )
if embeded_link is None: if embeded_link is None:
logging.error(f"No embedded link found for S{season:02}E{episode:03} ({key})") logger.error("No embedded link found for S%02dE%03d (%s)", season, episode, key)
return None return None
logging.debug(f"Using VOE provider to extract direct link") logger.debug("Using VOE provider to extract direct link")
return self.Providers.GetProvider( return self.Providers.GetProvider(
"VOE" "VOE"
).get_link(embeded_link, self.DEFAULT_REQUEST_TIMEOUT) ).get_link(embeded_link, self.DEFAULT_REQUEST_TIMEOUT)
@@ -657,23 +656,23 @@ class AniworldLoader(Loader):
Returns: Returns:
Dictionary mapping season numbers to episode counts Dictionary mapping season numbers to episode counts
""" """
logging.info(f"Getting season and episode count for slug: {slug}") logger.info("Getting season and episode count for slug: %s", slug)
# Sanitize slug parameter for URL # Sanitize slug parameter for URL
safe_slug = quote(slug, safe='') safe_slug = quote(slug, safe='')
base_url = f"{self.ANIWORLD_TO}/anime/stream/{safe_slug}/" base_url = f"{self.ANIWORLD_TO}/anime/stream/{safe_slug}/"
logging.debug(f"Base URL: {base_url}") logger.debug("Base URL: %s", base_url)
response = requests.get(base_url, timeout=self.DEFAULT_REQUEST_TIMEOUT) response = requests.get(base_url, timeout=self.DEFAULT_REQUEST_TIMEOUT)
soup = BeautifulSoup(response.content, 'html.parser') soup = BeautifulSoup(response.content, 'html.parser')
season_meta = soup.find('meta', itemprop='numberOfSeasons') season_meta = soup.find('meta', itemprop='numberOfSeasons')
number_of_seasons = int(season_meta['content']) if season_meta else 0 number_of_seasons = int(season_meta['content']) if season_meta else 0
logging.info(f"Found {number_of_seasons} seasons for '{slug}'") logger.info("Found %s seasons for '%s'", number_of_seasons, slug)
episode_counts = {} episode_counts = {}
for season in range(1, number_of_seasons + 1): for season in range(1, number_of_seasons + 1):
season_url = f"{base_url}staffel-{season}" season_url = f"{base_url}staffel-{season}"
logging.debug(f"Fetching episodes for season {season} from: {season_url}") logger.debug("Fetching episodes for season %s from: %s", season, season_url)
response = requests.get( response = requests.get(
season_url, season_url,
timeout=self.DEFAULT_REQUEST_TIMEOUT, timeout=self.DEFAULT_REQUEST_TIMEOUT,
@@ -688,7 +687,7 @@ class AniworldLoader(Loader):
) )
episode_counts[season] = len(unique_links) episode_counts[season] = len(unique_links)
logging.debug(f"Season {season} has {episode_counts[season]} episodes") logger.debug("Season %s has %s episodes", season, episode_counts[season])
logging.info(f"Episode count retrieval complete for '{slug}': {episode_counts}") logger.info("Episode count retrieval complete for '%s': %s", slug, episode_counts)
return episode_counts return episode_counts

View File

@@ -87,7 +87,7 @@ class ProviderConfigManager:
settings: Provider settings to apply. settings: Provider settings to apply.
""" """
self._provider_settings[provider_name] = settings self._provider_settings[provider_name] = settings
logger.info(f"Updated settings for provider: {provider_name}") logger.info("Updated settings for provider: %s", provider_name)
def update_provider_settings( def update_provider_settings(
self, provider_name: str, **kwargs self, provider_name: str, **kwargs
@@ -106,7 +106,7 @@ class ProviderConfigManager:
self._provider_settings[provider_name] = ProviderSettings( self._provider_settings[provider_name] = ProviderSettings(
name=provider_name, **kwargs name=provider_name, **kwargs
) )
logger.info(f"Created new settings for provider: {provider_name}") # noqa: E501 logger.info("Created new settings for provider: %s", provider_name) # noqa: E501
return True return True
settings = self._provider_settings[provider_name] settings = self._provider_settings[provider_name]
@@ -152,7 +152,7 @@ class ProviderConfigManager:
""" """
if provider_name in self._provider_settings: if provider_name in self._provider_settings:
self._provider_settings[provider_name].enabled = True self._provider_settings[provider_name].enabled = True
logger.info(f"Enabled provider: {provider_name}") logger.info("Enabled provider: %s", provider_name)
return True return True
return False return False
@@ -167,7 +167,7 @@ class ProviderConfigManager:
""" """
if provider_name in self._provider_settings: if provider_name in self._provider_settings:
self._provider_settings[provider_name].enabled = False self._provider_settings[provider_name].enabled = False
logger.info(f"Disabled provider: {provider_name}") logger.info("Disabled provider: %s", provider_name)
return True return True
return False return False
@@ -224,7 +224,7 @@ class ProviderConfigManager:
value: Setting value. value: Setting value.
""" """
self._global_settings[key] = value self._global_settings[key] = value
logger.info(f"Updated global setting {key}: {value}") logger.info("Updated global setting %s: %s", key, value)
def get_all_global_settings(self) -> Dict[str, Any]: def get_all_global_settings(self) -> Dict[str, Any]:
"""Get all global settings. """Get all global settings.
@@ -307,7 +307,7 @@ class ProviderConfigManager:
with open(config_path, "w", encoding="utf-8") as f: with open(config_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
logger.info(f"Saved configuration to {config_path}") logger.info("Saved configuration to %s", config_path)
return True return True
except Exception as e: except Exception as e:

View File

@@ -172,29 +172,32 @@ class EnhancedAniWorldLoader(Loader):
def _setup_logging(self): def _setup_logging(self):
"""Setup specialized logging for download errors and missing keys.""" """Setup specialized logging for download errors and missing keys."""
# Download error logger # Determine project root so log files land in a predictable location
self.download_error_logger = logging.getLogger("DownloadErrors") # regardless of the working directory at runtime.
download_error_handler = logging.FileHandler( _project_root = Path(__file__).parent.parent.parent.parent
"../../download_errors.log" _logs_dir = _project_root / "logs"
) _logs_dir.mkdir(parents=True, exist_ok=True)
download_error_handler.setLevel(logging.ERROR)
download_error_formatter = logging.Formatter( formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s' '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
) )
download_error_handler.setFormatter(download_error_formatter)
# Download error logger — records every failed download attempt
self.download_error_logger = logging.getLogger("DownloadErrors")
if not self.download_error_logger.handlers: if not self.download_error_logger.handlers:
self.download_error_logger.addHandler(download_error_handler) handler = logging.FileHandler(str(_logs_dir / "download_errors.log"))
handler.setLevel(logging.ERROR)
handler.setFormatter(formatter)
self.download_error_logger.addHandler(handler)
self.download_error_logger.setLevel(logging.ERROR) self.download_error_logger.setLevel(logging.ERROR)
# No key found logger # No-key logger — records episodes for which no stream key was found
self.nokey_logger = logging.getLogger("NoKeyFound") self.nokey_logger = logging.getLogger("NoKeyFound")
nokey_handler = logging.FileHandler("../../NoKeyFound.log")
nokey_handler.setLevel(logging.ERROR)
nokey_handler.setFormatter(download_error_formatter)
if not self.nokey_logger.handlers: if not self.nokey_logger.handlers:
self.nokey_logger.addHandler(nokey_handler) handler = logging.FileHandler(str(_logs_dir / "no_key_found.log"))
handler.setLevel(logging.ERROR)
handler.setFormatter(formatter)
self.nokey_logger.addHandler(handler)
self.nokey_logger.setLevel(logging.ERROR) self.nokey_logger.setLevel(logging.ERROR)
def ClearCache(self): def ClearCache(self):
@@ -221,7 +224,7 @@ class EnhancedAniWorldLoader(Loader):
try: try:
return self._fetch_anime_list_with_recovery(search_url) return self._fetch_anime_list_with_recovery(search_url)
except Exception as e: except Exception as e:
self.logger.error(f"Search failed for term '{word}': {e}") self.logger.error("Search failed for term '%s': %s", word, e)
raise RetryableError(f"Search failed: {e}") from e raise RetryableError(f"Search failed: {e}") from e
def _fetch_anime_list_with_recovery(self, url: str) -> list: def _fetch_anime_list_with_recovery(self, url: str) -> list:
@@ -563,6 +566,7 @@ class EnhancedAniWorldLoader(Loader):
"nocheckcertificate": True, "nocheckcertificate": True,
"socket_timeout": self.download_timeout, "socket_timeout": self.download_timeout,
"http_chunk_size": 1024 * 1024, # 1MB chunks "http_chunk_size": 1024 * 1024, # 1MB chunks
"logger": self.logger,
} }
if headers: if headers:
ydl_opts['http_headers'] = headers ydl_opts['http_headers'] = headers
@@ -622,7 +626,7 @@ class EnhancedAniWorldLoader(Loader):
self.logger.warning(warn_msg) self.logger.warning(warn_msg)
except Exception as e: except Exception as e:
self.logger.warning(f"Provider {provider_name} failed: {e}") self.logger.warning("Provider %s failed: %s", provider_name, e)
# Clean up any partial temp files left by this failed attempt # Clean up any partial temp files left by this failed attempt
_cleanup_temp_file(temp_path, self.logger) _cleanup_temp_file(temp_path, self.logger)
self.download_stats['retried_downloads'] += 1 self.download_stats['retried_downloads'] += 1
@@ -641,7 +645,7 @@ class EnhancedAniWorldLoader(Loader):
ydl.download([link]) ydl.download([link])
return True return True
except Exception as e: except Exception as e:
self.logger.error(f"yt-dlp download failed: {e}") self.logger.error("yt-dlp download failed: %s", e)
raise DownloadError(f"Download failed: {e}") from e raise DownloadError(f"Download failed: {e}") from e
@with_error_recovery(max_retries=2, context="get_title") @with_error_recovery(max_retries=2, context="get_title")
@@ -658,11 +662,11 @@ class EnhancedAniWorldLoader(Loader):
if span: if span:
return span.text.strip() return span.text.strip()
self.logger.warning(f"Could not extract title for key: {key}") self.logger.warning("Could not extract title for key: %s", key)
return f"Unknown_Title_{key}" return f"Unknown_Title_{key}"
except Exception as e: except Exception as e:
self.logger.error(f"Failed to get title for key {key}: {e}") self.logger.error("Failed to get title for key %s: %s", key, e)
raise RetryableError(f"Title extraction failed: {e}") from e raise RetryableError(f"Title extraction failed: {e}") from e
def GetSiteKey(self) -> str: def GetSiteKey(self) -> str:
@@ -959,7 +963,7 @@ class EnhancedAniWorldLoader(Loader):
return episode_counts return episode_counts
except Exception as e: except Exception as e:
self.logger.error(f"Failed to get episode counts for {slug}: {e}") self.logger.error("Failed to get episode counts for %s: %s", slug, e)
raise RetryableError(f"Episode count retrieval failed: {e}") from e raise RetryableError(f"Episode count retrieval failed: {e}") from e
def get_download_statistics(self) -> Dict[str, Any]: def get_download_statistics(self) -> Dict[str, Any]:

View File

@@ -207,7 +207,7 @@ class ProviderFailover:
""" """
if provider_name not in self._providers: if provider_name not in self._providers:
self._providers.append(provider_name) self._providers.append(provider_name)
logger.info(f"Added provider to failover chain: {provider_name}") logger.info("Added provider to failover chain: %s", provider_name)
def remove_provider(self, provider_name: str) -> bool: def remove_provider(self, provider_name: str) -> bool:
"""Remove a provider from the failover chain. """Remove a provider from the failover chain.

View File

@@ -151,7 +151,7 @@ class ProviderHealthMonitor:
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception as e: except Exception as e:
logger.error(f"Error in health check loop: {e}", exc_info=True) logger.exception("Error in health check loop: %s", e)
await asyncio.sleep(self._health_check_interval) await asyncio.sleep(self._health_check_interval)
async def _perform_health_checks(self) -> None: async def _perform_health_checks(self) -> None:
@@ -314,7 +314,7 @@ class ProviderHealthMonitor:
) )
best_provider = available[0][0] best_provider = available[0][0]
logger.debug(f"Best provider selected: {best_provider}") logger.debug("Best provider selected: %s", best_provider)
return best_provider return best_provider
def _get_recent_metrics( def _get_recent_metrics(
@@ -355,7 +355,7 @@ class ProviderHealthMonitor:
provider_name=provider_name provider_name=provider_name
) )
self._request_history[provider_name].clear() self._request_history[provider_name].clear()
logger.info(f"Reset metrics for provider: {provider_name}") logger.info("Reset metrics for provider: %s", provider_name)
return True return True
def get_health_summary(self) -> Dict[str, Any]: def get_health_summary(self) -> Dict[str, Any]:

View File

@@ -120,6 +120,37 @@ def nfo_needs_repair(nfo_path: Path) -> bool:
return bool(find_missing_tags(nfo_path)) return bool(find_missing_tags(nfo_path))
def _read_tmdb_id(nfo_path: Path) -> int | None:
"""Return the TMDB ID stored in an existing NFO, or ``None``.
Checks both ``<tmdbid>`` and ``<uniqueid type="tmdb">`` elements.
Args:
nfo_path: Absolute path to the ``tvshow.nfo`` file.
Returns:
Integer TMDB ID, or ``None`` if not found or not parseable.
"""
if not nfo_path.exists():
return None
try:
root = etree.parse(str(nfo_path)).getroot()
for uniqueid in root.findall(".//uniqueid"):
if uniqueid.get("type") == "tmdb" and uniqueid.text:
return int(uniqueid.text)
tmdbid_elem = root.find(".//tmdbid")
if tmdbid_elem is not None and tmdbid_elem.text:
return int(tmdbid_elem.text)
except (etree.XMLSyntaxError, ValueError):
pass
except Exception: # pylint: disable=broad-except
pass
return None
class NfoRepairService: class NfoRepairService:
"""Service that detects and repairs incomplete tvshow.nfo files. """Service that detects and repairs incomplete tvshow.nfo files.

View File

@@ -83,11 +83,12 @@ class NFOService:
>>> _extract_year_from_name("Attack on Titan") >>> _extract_year_from_name("Attack on Titan")
("Attack on Titan", None) ("Attack on Titan", None)
""" """
# Match year in parentheses at the end: (YYYY) # Match the last year in parentheses at the end: (YYYY)
match = re.search(r'\((\d{4})\)\s*$', serie_name) match = re.search(r'\((\d{4})\)\s*$', serie_name)
if match: if match:
year = int(match.group(1)) year = int(match.group(1))
clean_name = serie_name[:match.start()].strip() # Strip ALL trailing year suffixes to get a fully clean name
clean_name = re.sub(r'(\s*\(\d{4}\))+\s*$', '', serie_name).strip()
return clean_name, year return clean_name, year
return serie_name, None return serie_name, None
@@ -134,21 +135,23 @@ class NFOService:
clean_name, extracted_year = self._extract_year_from_name(serie_name) clean_name, extracted_year = self._extract_year_from_name(serie_name)
if year is None and extracted_year is not None: if year is None and extracted_year is not None:
year = extracted_year year = extracted_year
logger.info(f"Extracted year {year} from series name") logger.info("Extracted year %s from series name", year)
# Use clean name for search # Use clean name for search
search_name = clean_name search_name = clean_name
logger.info(f"Creating NFO for {search_name} (year: {year})") logger.info("Creating NFO for %s (year: %s)", search_name, year)
folder_path = self.anime_directory / serie_folder folder_path = self.anime_directory / serie_folder
if not folder_path.exists(): if not folder_path.exists():
logger.info(f"Creating series folder: {folder_path}") logger.info("Creating series folder: %s", folder_path)
folder_path.mkdir(parents=True, exist_ok=True) folder_path.mkdir(parents=True, exist_ok=True)
async with self.tmdb_client: try:
await self.tmdb_client._ensure_session()
# Search for TV show with clean name (without year) # Search for TV show with clean name (without year)
logger.debug(f"Searching TMDB for: {search_name}") logger.debug("Searching TMDB for: %s", search_name)
search_results = await self.tmdb_client.search_tv_show(search_name) search_results = await self.tmdb_client.search_tv_show(search_name)
if not search_results.get("results"): if not search_results.get("results"):
@@ -158,7 +161,7 @@ class NFOService:
tv_show = self._find_best_match(search_results["results"], search_name, year) tv_show = self._find_best_match(search_results["results"], search_name, year)
tv_id = tv_show["id"] tv_id = tv_show["id"]
logger.info(f"Found match: {tv_show['name']} (ID: {tv_id})") logger.info("Found match: %s (ID: %s)", tv_show['name'], tv_id)
# Get detailed information with multi-language image support # Get detailed information with multi-language image support
details = await self.tmdb_client.get_tv_show_details( details = await self.tmdb_client.get_tv_show_details(
@@ -172,6 +175,32 @@ class NFOService:
# Enrich with fallback languages for empty overview/tagline # Enrich with fallback languages for empty overview/tagline
# Pass search result overview as last resort fallback # Pass search result overview as last resort fallback
search_overview = tv_show.get("overview") or None search_overview = tv_show.get("overview") or None
if not search_overview:
try:
logger.debug(
"No overview in German search result, trying en-US search fallback for: %s",
search_name,
)
en_search_results = await self.tmdb_client.search_tv_show(
search_name,
language="en-US",
)
if en_search_results.get("results"):
en_match = self._find_best_match(
en_search_results["results"], search_name, year
)
search_overview = en_match.get("overview") or None
if search_overview:
logger.info(
"Using en-US search overview fallback for %s",
search_name,
)
except (TMDBAPIError, Exception) as exc:
logger.warning(
"Failed en-US search fallback for overview: %s",
exc,
)
details = await self._enrich_details_with_fallback( details = await self._enrich_details_with_fallback(
details, search_overview=search_overview details, search_overview=search_overview
) )
@@ -190,7 +219,7 @@ class NFOService:
# Save NFO file # Save NFO file
nfo_path = folder_path / "tvshow.nfo" nfo_path = folder_path / "tvshow.nfo"
nfo_path.write_text(nfo_xml, encoding="utf-8") nfo_path.write_text(nfo_xml, encoding="utf-8")
logger.info(f"Created NFO: {nfo_path}") logger.info("Created NFO: %s", nfo_path)
# Download media files # Download media files
await self._download_media_files( await self._download_media_files(
@@ -202,6 +231,8 @@ class NFOService:
) )
return nfo_path return nfo_path
finally:
await self.tmdb_client.close()
async def update_tvshow_nfo( async def update_tvshow_nfo(
self, self,
@@ -227,7 +258,7 @@ class NFOService:
if not nfo_path.exists(): if not nfo_path.exists():
raise FileNotFoundError(f"NFO file not found: {nfo_path}") raise FileNotFoundError(f"NFO file not found: {nfo_path}")
logger.info(f"Updating NFO for {serie_folder}") logger.info("Updating NFO for %s", serie_folder)
# Parse existing NFO to extract TMDB ID # Parse existing NFO to extract TMDB ID
try: try:
@@ -253,16 +284,16 @@ class NFOService:
f"Delete the NFO and create a new one instead." f"Delete the NFO and create a new one instead."
) )
logger.debug(f"Found TMDB ID: {tmdb_id}") logger.debug("Found TMDB ID: %s", tmdb_id)
except etree.XMLSyntaxError as e: except etree.XMLSyntaxError as e:
raise TMDBAPIError(f"Invalid XML in NFO file: {e}") raise TMDBAPIError(f"Invalid XML in NFO file: {e}")
except ValueError as e: except ValueError as e:
raise TMDBAPIError(f"Invalid TMDB ID format in NFO: {e}") raise TMDBAPIError(f"Invalid TMDB ID format in NFO: {e}")
# Fetch fresh data from TMDB try:
async with self.tmdb_client: await self.tmdb_client._ensure_session()
logger.debug(f"Fetching fresh data for TMDB ID: {tmdb_id}") logger.debug("Fetching fresh data for TMDB ID: %s", tmdb_id)
details = await self.tmdb_client.get_tv_show_details( details = await self.tmdb_client.get_tv_show_details(
tmdb_id, tmdb_id,
append_to_response="credits,external_ids,images" append_to_response="credits,external_ids,images"
@@ -286,7 +317,7 @@ class NFOService:
# Save updated NFO file # Save updated NFO file
nfo_path.write_text(nfo_xml, encoding="utf-8") nfo_path.write_text(nfo_xml, encoding="utf-8")
logger.info(f"Updated NFO: {nfo_path}") logger.info("Updated NFO: %s", nfo_path)
# Re-download media files if requested # Re-download media files if requested
if download_media: if download_media:
@@ -299,6 +330,8 @@ class NFOService:
) )
return nfo_path return nfo_path
finally:
await self.tmdb_client.close()
def parse_nfo_ids(self, nfo_path: Path) -> Dict[str, Optional[int]]: def parse_nfo_ids(self, nfo_path: Path) -> Dict[str, Optional[int]]:
"""Parse TMDB ID and TVDB ID from an existing NFO file. """Parse TMDB ID and TVDB ID from an existing NFO file.
@@ -318,7 +351,7 @@ class NFOService:
result = {"tmdb_id": None, "tvdb_id": None} result = {"tmdb_id": None, "tvdb_id": None}
if not nfo_path.exists(): if not nfo_path.exists():
logger.debug(f"NFO file not found: {nfo_path}") logger.debug("NFO file not found: %s", nfo_path)
return result return result
try: try:
@@ -375,9 +408,9 @@ class NFOService:
) )
except etree.XMLSyntaxError as e: except etree.XMLSyntaxError as e:
logger.error(f"Invalid XML in NFO file {nfo_path}: {e}") logger.error("Invalid XML in NFO file %s: %s", nfo_path, e)
except Exception as e: # pylint: disable=broad-except except Exception as e: # pylint: disable=broad-except
logger.error(f"Error parsing NFO file {nfo_path}: {e}") logger.error("Error parsing NFO file %s: %s", nfo_path, e)
return result return result
@@ -480,7 +513,7 @@ class NFOService:
for result in results: for result in results:
first_air_date = result.get("first_air_date", "") first_air_date = result.get("first_air_date", "")
if first_air_date.startswith(str(year)): if first_air_date.startswith(str(year)):
logger.debug(f"Found year match: {result['name']} ({first_air_date})") logger.debug("Found year match: %s (%s)", result['name'], first_air_date)
return result return result
# Return first result (usually best match) # Return first result (usually best match)
@@ -545,7 +578,7 @@ class NFOService:
skip_existing=True skip_existing=True
) )
logger.info(f"Media download results: {results}") logger.info("Media download results: %s", results)
return results return results

View File

@@ -136,7 +136,7 @@ class SeriesManagerService:
# If NFO exists, parse IDs and update database # If NFO exists, parse IDs and update database
if nfo_exists: if nfo_exists:
logger.debug(f"Parsing IDs from existing NFO for '{serie_name}'") logger.debug("Parsing IDs from existing NFO for '%s'", serie_name)
ids = self.nfo_service.parse_nfo_ids(nfo_path) ids = self.nfo_service.parse_nfo_ids(nfo_path)
if ids["tmdb_id"] or ids["tvdb_id"]: if ids["tmdb_id"] or ids["tvdb_id"]:
@@ -203,14 +203,14 @@ class SeriesManagerService:
download_logo=self.download_logo, download_logo=self.download_logo,
download_fanart=self.download_fanart download_fanart=self.download_fanart
) )
logger.info(f"Successfully created NFO for '{serie_name}'") logger.info("Successfully created NFO for '%s'", serie_name)
elif nfo_exists: elif nfo_exists:
logger.debug( logger.debug(
f"NFO exists for '{serie_name}', skipping download" f"NFO exists for '{serie_name}', skipping download"
) )
except TMDBAPIError as e: except TMDBAPIError as e:
logger.error(f"TMDB API error processing '{serie_name}': {e}") logger.error("TMDB API error processing '%s': %s", serie_name, e)
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Unexpected error processing NFO for '{serie_name}': {e}", f"Unexpected error processing NFO for '{serie_name}': {e}",
@@ -246,7 +246,7 @@ class SeriesManagerService:
logger.info("No series found in database to process") logger.info("No series found in database to process")
return return
logger.info(f"Processing NFO for {len(anime_series_list)} series...") logger.info("Processing NFO for %s series...", len(anime_series_list))
# Create tasks for concurrent processing # Create tasks for concurrent processing
# Each task creates its own database session # Each task creates its own database session

View File

@@ -12,6 +12,7 @@ Example:
import asyncio import asyncio
import logging import logging
import time
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -63,6 +64,11 @@ class TMDBClient:
self.max_connections = max_connections self.max_connections = max_connections
self.session: Optional[aiohttp.ClientSession] = None self.session: Optional[aiohttp.ClientSession] = None
self._cache: Dict[str, Any] = {} 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 def __aenter__(self):
"""Async context manager entry.""" """Async context manager entry."""
@@ -83,7 +89,7 @@ class TMDBClient:
self, self,
endpoint: str, endpoint: str,
params: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None,
max_retries: int = 3 max_retries: int = 5
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Make an async request to TMDB API with retries. """Make an async request to TMDB API with retries.
@@ -107,12 +113,27 @@ class TMDBClient:
# Cache key for deduplication # Cache key for deduplication
cache_key = f"{endpoint}:{str(sorted(params.items()))}" cache_key = f"{endpoint}:{str(sorted(params.items()))}"
if cache_key in self._cache: if cache_key in self._cache:
logger.debug(f"Cache hit for {endpoint}") logger.debug("Cache hit for %s", endpoint)
return self._cache[cache_key] return self._cache[cache_key]
delay = 1 delay = 2
last_error = None last_error = 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): for attempt in range(max_retries):
try: try:
# Re-ensure session before each attempt in case it was closed # Re-ensure session before each attempt in case it was closed
@@ -121,16 +142,16 @@ class TMDBClient:
if self.session is None: if self.session is None:
raise TMDBAPIError("Session is not available") raise TMDBAPIError("Session is not available")
logger.debug(f"TMDB API request: {endpoint} (attempt {attempt + 1})") 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: async with self.session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
if resp.status == 401: if resp.status == 401:
raise TMDBAPIError("Invalid TMDB API key") raise TMDBAPIError("Invalid TMDB API key")
elif resp.status == 404: elif resp.status == 404:
raise TMDBAPIError(f"Resource not found: {endpoint}") raise TMDBAPIError(f"Resource not found: {endpoint}")
elif resp.status == 429: elif resp.status == 429:
# Rate limit - wait longer # Rate limit - wait longer with exponential backoff
retry_after = int(resp.headers.get('Retry-After', delay * 2)) retry_after = int(resp.headers.get('Retry-After', max(delay * 2, 10)))
logger.warning(f"Rate limited, waiting {retry_after}s") logger.warning("Rate limited, waiting %ss", retry_after)
await asyncio.sleep(retry_after) await asyncio.sleep(retry_after)
continue continue
@@ -142,26 +163,35 @@ class TMDBClient:
except asyncio.TimeoutError as e: except asyncio.TimeoutError as e:
last_error = e last_error = e
if attempt < max_retries - 1: if attempt < max_retries - 1:
logger.warning(f"Request timeout (attempt {attempt + 1}), retrying in {delay}s") logger.warning("Request timeout (attempt %s), retrying in %ss", attempt + 1, delay)
await asyncio.sleep(delay) await asyncio.sleep(delay)
delay *= 2 delay = min(delay * 2, 30)
else: else:
logger.error(f"Request timed out after {max_retries} attempts") logger.error("Request timed out after %s attempts", max_retries)
except (aiohttp.ClientError, AttributeError) as e: except (aiohttp.ClientError, AttributeError) as e:
last_error = e last_error = e
# If connector/session was closed, try to recreate it # If connector/session was closed, try to recreate it
if "Connector is closed" in str(e) or isinstance(e, AttributeError): if "Connector is closed" in str(e) or isinstance(e, AttributeError):
logger.warning(f"Session issue detected, recreating session: {e}") logger.warning("Session issue detected, recreating session: %s", e)
self.session = None self.session = None
await self._ensure_session() 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: if attempt < max_retries - 1:
logger.warning(f"Request failed (attempt {attempt + 1}): {e}, retrying in {delay}s") logger.warning("Request failed (attempt %s): %s, retrying in %ss", attempt + 1, e, delay)
await asyncio.sleep(delay) await asyncio.sleep(delay)
delay *= 2 delay = min(delay * 2, 30)
else: else:
logger.error(f"Request failed after {max_retries} attempts: {e}") logger.error("Request failed after %s attempts: %s", max_retries, e)
raise TMDBAPIError(f"Request failed after {max_retries} attempts: {last_error}") raise TMDBAPIError(f"Request failed after {max_retries} attempts: {last_error}")
@@ -275,7 +305,7 @@ class TMDBClient:
url = f"{self.image_base_url}/{size}{image_path}" url = f"{self.image_base_url}/{size}{image_path}"
try: try:
logger.debug(f"Downloading image from {url}") logger.debug("Downloading image from %s", url)
async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=60)) as resp: async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=60)) as resp:
resp.raise_for_status() resp.raise_for_status()
@@ -286,7 +316,7 @@ class TMDBClient:
with open(local_path, "wb") as f: with open(local_path, "wb") as f:
f.write(await resp.read()) f.write(await resp.read())
logger.info(f"Downloaded image to {local_path}") logger.info("Downloaded image to %s", local_path)
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
raise TMDBAPIError(f"Failed to download image: {e}") raise TMDBAPIError(f"Failed to download image: {e}")

View File

@@ -125,7 +125,7 @@ class ImageDownloader:
# Check if file already exists # Check if file already exists
if skip_existing and local_path.exists(): if skip_existing and local_path.exists():
if local_path.stat().st_size >= self.min_file_size: if local_path.stat().st_size >= self.min_file_size:
logger.debug(f"Image already exists: {local_path}") logger.debug("Image already exists: %s", local_path)
return True return True
# Ensure parent directory exists # Ensure parent directory exists
@@ -137,15 +137,16 @@ class ImageDownloader:
for attempt in range(self.max_retries): for attempt in range(self.max_retries):
try: try:
logger.debug( logger.debug(
f"Downloading image from {url} " "Downloading image from %s (attempt %d)",
f"(attempt {attempt + 1})" url,
attempt + 1,
) )
# Use persistent session # Use persistent session
session = self._get_session() session = self._get_session()
async with session.get(url) as resp: async with session.get(url) as resp:
if resp.status == 404: if resp.status == 404:
logger.warning(f"Image not found: {url}") logger.warning("Image not found: %s", url)
return False return False
resp.raise_for_status() resp.raise_for_status()
@@ -168,21 +169,25 @@ class ImageDownloader:
local_path.unlink(missing_ok=True) local_path.unlink(missing_ok=True)
raise ImageDownloadError("Image validation failed") raise ImageDownloadError("Image validation failed")
logger.info(f"Downloaded image to {local_path}") logger.info("Downloaded image to %s", local_path)
return True return True
except (aiohttp.ClientError, IOError, ImageDownloadError) as e: except (aiohttp.ClientError, IOError, ImageDownloadError) as e:
last_error = e last_error = e
if attempt < self.max_retries - 1: if attempt < self.max_retries - 1:
logger.warning( logger.warning(
f"Download failed (attempt {attempt + 1}): {e}, " "Download failed (attempt %d): %s, retrying in %s",
f"retrying in {delay}s" attempt + 1,
e,
delay,
) )
await asyncio.sleep(delay) await asyncio.sleep(delay)
delay *= 2 delay *= 2
else: else:
logger.error( logger.error(
f"Download failed after {self.max_retries} attempts: {e}" "Download failed after %d attempts: %s",
self.max_retries,
e,
) )
raise ImageDownloadError( raise ImageDownloadError(
@@ -211,7 +216,7 @@ class ImageDownloader:
try: try:
return await self.download_image(url, local_path, skip_existing) return await self.download_image(url, local_path, skip_existing)
except ImageDownloadError as e: except ImageDownloadError as e:
logger.warning(f"Failed to download poster: {e}") logger.warning("Failed to download poster: %s", e)
return False return False
async def download_logo( async def download_logo(
@@ -236,7 +241,7 @@ class ImageDownloader:
try: try:
return await self.download_image(url, local_path, skip_existing) return await self.download_image(url, local_path, skip_existing)
except ImageDownloadError as e: except ImageDownloadError as e:
logger.warning(f"Failed to download logo: {e}") logger.warning("Failed to download logo: %s", e)
return False return False
async def download_fanart( async def download_fanart(
@@ -261,7 +266,7 @@ class ImageDownloader:
try: try:
return await self.download_image(url, local_path, skip_existing) return await self.download_image(url, local_path, skip_existing)
except ImageDownloadError as e: except ImageDownloadError as e:
logger.warning(f"Failed to download fanart: {e}") logger.warning("Failed to download fanart: %s", e)
return False return False
def validate_image(self, image_path: Path) -> bool: def validate_image(self, image_path: Path) -> bool:
@@ -280,13 +285,13 @@ class ImageDownloader:
# Check file size # Check file size
if image_path.stat().st_size < self.min_file_size: if image_path.stat().st_size < self.min_file_size:
logger.warning(f"Image file too small: {image_path}") logger.warning("Image file too small: %s", image_path)
return False return False
return True return True
except Exception as e: except Exception as e:
logger.warning(f"Image validation failed for {image_path}: {e}") logger.warning("Image validation failed for %s: %s", image_path, e)
return False return False
async def download_all_media( async def download_all_media(
@@ -341,7 +346,7 @@ class ImageDownloader:
for (media_type, _), result in zip(tasks, task_results): for (media_type, _), result in zip(tasks, task_results):
if isinstance(result, Exception): if isinstance(result, Exception):
logger.error(f"Error downloading {media_type}: {result}") logger.error("Error downloading %s: %s", media_type, result)
results[media_type] = False results[media_type] = False
else: else:
results[media_type] = result results[media_type] = result

View File

@@ -209,5 +209,5 @@ def validate_nfo_xml(xml_string: str) -> bool:
etree.fromstring(xml_string.encode('utf-8')) etree.fromstring(xml_string.encode('utf-8'))
return True return True
except etree.XMLSyntaxError as e: except etree.XMLSyntaxError as e:
logger.error(f"Invalid NFO XML: {e}") logger.error("Invalid NFO XML: %s", e)
return False return False

View File

@@ -36,10 +36,10 @@ class ConfigEncryption:
def _ensure_key_exists(self) -> None: def _ensure_key_exists(self) -> None:
"""Ensure encryption key exists or create one.""" """Ensure encryption key exists or create one."""
if not self.key_file.exists(): if not self.key_file.exists():
logger.info(f"Creating new encryption key at {self.key_file}") logger.info("Creating new encryption key at %s", self.key_file)
self._generate_new_key() self._generate_new_key()
else: else:
logger.info(f"Using existing encryption key from {self.key_file}") logger.info("Using existing encryption key from %s", self.key_file)
def _generate_new_key(self) -> None: def _generate_new_key(self) -> None:
"""Generate and store a new encryption key.""" """Generate and store a new encryption key."""
@@ -56,7 +56,7 @@ class ConfigEncryption:
logger.info("Generated new encryption key") logger.info("Generated new encryption key")
except IOError as e: except IOError as e:
logger.error(f"Failed to generate encryption key: {e}") logger.error("Failed to generate encryption key: %s", e)
raise raise
def _load_key(self) -> bytes: def _load_key(self) -> bytes:
@@ -77,7 +77,7 @@ class ConfigEncryption:
key = self.key_file.read_bytes() key = self.key_file.read_bytes()
return key return key
except IOError as e: except IOError as e:
logger.error(f"Failed to load encryption key: {e}") logger.error("Failed to load encryption key: %s", e)
raise raise
def _get_cipher(self) -> Fernet: def _get_cipher(self) -> Fernet:
@@ -117,7 +117,7 @@ class ConfigEncryption:
return encrypted_str return encrypted_str
except Exception as e: except Exception as e:
logger.error(f"Failed to encrypt value: {e}") logger.error("Failed to encrypt value: %s", e)
raise raise
def decrypt_value(self, encrypted_value: str) -> str: def decrypt_value(self, encrypted_value: str) -> str:
@@ -149,7 +149,7 @@ class ConfigEncryption:
return decrypted_str return decrypted_str
except Exception as e: except Exception as e:
logger.error(f"Failed to decrypt value: {e}") logger.error("Failed to decrypt value: %s", e)
raise raise
def encrypt_config(self, config: Dict[str, Any]) -> Dict[str, Any]: def encrypt_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
@@ -191,9 +191,9 @@ class ConfigEncryption:
'encrypted': True, 'encrypted': True,
'value': self.encrypt_value(value) 'value': self.encrypt_value(value)
} }
logger.debug(f"Encrypted config field: {key}") logger.debug("Encrypted config field: %s", key)
except Exception as e: except Exception as e:
logger.warning(f"Failed to encrypt {key}: {e}") logger.warning("Failed to encrypt %s: %s", key, e)
encrypted_config[key] = value encrypted_config[key] = value
else: else:
encrypted_config[key] = value encrypted_config[key] = value
@@ -222,9 +222,9 @@ class ConfigEncryption:
decrypted_config[key] = self.decrypt_value( decrypted_config[key] = self.decrypt_value(
value['value'] value['value']
) )
logger.debug(f"Decrypted config field: {key}") logger.debug("Decrypted config field: %s", key)
except Exception as e: except Exception as e:
logger.error(f"Failed to decrypt {key}: {e}") logger.error("Failed to decrypt %s: %s", key, e)
decrypted_config[key] = None decrypted_config[key] = None
else: else:
decrypted_config[key] = value decrypted_config[key] = value
@@ -248,7 +248,7 @@ class ConfigEncryption:
if self.key_file.exists(): if self.key_file.exists():
backup_path = self.key_file.with_suffix('.key.bak') backup_path = self.key_file.with_suffix('.key.bak')
self.key_file.rename(backup_path) self.key_file.rename(backup_path)
logger.info(f"Backed up old key to {backup_path}") logger.info("Backed up old key to %s", backup_path)
# Generate new key # Generate new key
if new_key_file: if new_key_file:

View File

@@ -276,13 +276,13 @@ class DatabaseIntegrityChecker:
removed += 1 removed += 1
self.session.commit() self.session.commit()
logger.info(f"Removed {removed} orphaned records") logger.info("Removed %s orphaned records", removed)
return removed return removed
except Exception as e: except Exception as e:
self.session.rollback() self.session.rollback()
logger.error(f"Error removing orphaned records: {e}") logger.error("Error removing orphaned records: %s", e)
raise raise

View File

@@ -39,13 +39,15 @@ class FileIntegrityManager:
self.checksums = json.load(f) self.checksums = json.load(f)
count = len(self.checksums) count = len(self.checksums)
logger.info( logger.info(
f"Loaded {count} checksums from {self.checksum_file}" "Loaded %d checksums from %s",
count,
self.checksum_file,
) )
except (json.JSONDecodeError, IOError) as e: except (json.JSONDecodeError, IOError) as e:
logger.error(f"Failed to load checksums: {e}") logger.error("Failed to load checksums: %s", e)
self.checksums = {} self.checksums = {}
else: else:
logger.info(f"Checksum file does not exist: {self.checksum_file}") logger.info("Checksum file does not exist: %s", self.checksum_file)
self.checksums = {} self.checksums = {}
def _save_checksums(self) -> None: def _save_checksums(self) -> None:
@@ -56,10 +58,12 @@ class FileIntegrityManager:
json.dump(self.checksums, f, indent=2) json.dump(self.checksums, f, indent=2)
count = len(self.checksums) count = len(self.checksums)
logger.debug( logger.debug(
f"Saved {count} checksums to {self.checksum_file}" "Saved %d checksums to %s",
count,
self.checksum_file,
) )
except IOError as e: except IOError as e:
logger.error(f"Failed to save checksums: {e}") logger.error("Failed to save checksums: %s", e)
def calculate_checksum( def calculate_checksum(
self, file_path: Path, algorithm: str = "sha256" self, file_path: Path, algorithm: str = "sha256"
@@ -94,12 +98,15 @@ class FileIntegrityManager:
checksum = hash_obj.hexdigest() checksum = hash_obj.hexdigest()
filename = file_path.name filename = file_path.name
logger.debug( logger.debug(
f"Calculated {algorithm} checksum for {filename}: {checksum}" "Calculated %s checksum for %s: %s",
algorithm,
filename,
checksum,
) )
return checksum return checksum
except IOError as e: except IOError as e:
logger.error(f"Failed to read file {file_path}: {e}") logger.error("Failed to read file %s: %s", file_path, e)
raise raise
def store_checksum( def store_checksum(
@@ -126,7 +133,7 @@ class FileIntegrityManager:
self.checksums[key] = checksum self.checksums[key] = checksum
self._save_checksums() self._save_checksums()
logger.info(f"Stored checksum for {file_path.name}") logger.info("Stored checksum for %s", file_path.name)
return checksum return checksum
def verify_checksum( def verify_checksum(
@@ -197,10 +204,10 @@ class FileIntegrityManager:
if key in self.checksums: if key in self.checksums:
del self.checksums[key] del self.checksums[key]
self._save_checksums() self._save_checksums()
logger.info(f"Removed checksum for {file_path.name}") logger.info("Removed checksum for %s", file_path.name)
return True return True
else: else:
logger.debug(f"No checksum found to remove for {file_path.name}") logger.debug("No checksum found to remove for %s", file_path.name)
return False return False
def has_checksum(self, file_path: Path) -> bool: def has_checksum(self, file_path: Path) -> bool:

View File

@@ -236,8 +236,8 @@ async def list_anime(
sort_by: Optional sorting parameter. Allowed: title, id, name, sort_by: Optional sorting parameter. Allowed: title, id, name,
missing_episodes missing_episodes
filter: Optional filter parameter. Allowed values: filter: Optional filter parameter. Allowed values:
- "no_episodes": Show only series with no downloaded - "missing_episodes": Show only series that have any missing episodes
episodes in folder - "no_episodes": Show only series that have no downloaded episodes
_auth: Ensures the caller is authenticated (value unused) _auth: Ensures the caller is authenticated (value unused)
anime_service: AnimeService instance provided via dependency anime_service: AnimeService instance provided via dependency
@@ -298,7 +298,7 @@ async def list_anime(
# Validate filter parameter # Validate filter parameter
if filter: if filter:
try: try:
allowed_filters = ["no_episodes"] allowed_filters = ["missing_episodes", "no_episodes"]
validate_filter_value(filter, allowed_filters) validate_filter_value(filter, allowed_filters)
except ValueError as e: except ValueError as e:
raise ValidationError(message=str(e)) raise ValidationError(message=str(e))
@@ -724,13 +724,17 @@ async def add_series(
if series_app and hasattr(series_app, 'loader'): if series_app and hasattr(series_app, 'loader'):
try: try:
year = series_app.loader.get_year(key) year = series_app.loader.get_year(key)
logger.info(f"Fetched year for {key}: {year}") logger.info("Fetched year for %s: %s", key, year)
except Exception as e: except Exception as e:
logger.warning(f"Could not fetch year for {key}: {e}") logger.warning("Could not fetch year for %s: %s", key, e)
# Create folder name with year if available # Create folder name with year if available
if year: if year:
folder_name_with_year = f"{name} ({year})" year_suffix = f" ({year})"
if name.endswith(year_suffix):
folder_name_with_year = name
else:
folder_name_with_year = f"{name}{year_suffix}"
else: else:
folder_name_with_year = name folder_name_with_year = name

View File

@@ -76,6 +76,8 @@ async def setup_auth(req: SetupRequest):
config.scheduler.schedule_days = req.scheduler_schedule_days config.scheduler.schedule_days = req.scheduler_schedule_days
if req.scheduler_auto_download_after_rescan is not None: if req.scheduler_auto_download_after_rescan is not None:
config.scheduler.auto_download_after_rescan = req.scheduler_auto_download_after_rescan config.scheduler.auto_download_after_rescan = req.scheduler_auto_download_after_rescan
if req.scheduler_folder_scan_enabled is not None:
config.scheduler.folder_scan_enabled = req.scheduler_folder_scan_enabled
# Update logging configuration # Update logging configuration
if req.logging_level: if req.logging_level:

View File

@@ -22,7 +22,7 @@ class HealthStatus(BaseModel):
status: str status: str
timestamp: str timestamp: str
version: str = "1.0.0" version: str = "1.0.1"
service: str = "aniworld-api" service: str = "aniworld-api"
series_app_initialized: bool = False series_app_initialized: bool = False
anime_directory_configured: bool = False anime_directory_configured: bool = False
@@ -60,7 +60,7 @@ class DetailedHealthStatus(BaseModel):
status: str status: str
timestamp: str timestamp: str
version: str = "1.0.0" version: str = "1.0.1"
dependencies: DependencyHealth dependencies: DependencyHealth
startup_time: datetime startup_time: datetime
@@ -91,7 +91,7 @@ async def check_database_health(db: AsyncSession) -> DatabaseHealth:
message="Database connection successful", message="Database connection successful",
) )
except Exception as e: except Exception as e:
logger.error(f"Database health check failed: {e}") logger.error("Database health check failed: %s", e)
return DatabaseHealth( return DatabaseHealth(
status="unhealthy", status="unhealthy",
connection_time_ms=0, connection_time_ms=0,
@@ -121,7 +121,7 @@ async def check_filesystem_health() -> Dict[str, Any]:
"message": "Filesystem check completed", "message": "Filesystem check completed",
} }
except Exception as e: except Exception as e:
logger.error(f"Filesystem health check failed: {e}") logger.error("Filesystem health check failed: %s", e)
return { return {
"status": "unhealthy", "status": "unhealthy",
"message": f"Filesystem check failed: {str(e)}", "message": f"Filesystem check failed: {str(e)}",
@@ -164,7 +164,7 @@ def get_system_metrics() -> SystemMetrics:
uptime_seconds=uptime_seconds, uptime_seconds=uptime_seconds,
) )
except Exception as e: except Exception as e:
logger.error(f"System metrics collection failed: {e}") logger.error("System metrics collection failed: %s", e)
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to collect system metrics: {str(e)}" status_code=500, detail=f"Failed to collect system metrics: {str(e)}"
) )
@@ -236,7 +236,7 @@ async def detailed_health_check(
startup_time=startup_time, startup_time=startup_time,
) )
except Exception as e: except Exception as e:
logger.error(f"Detailed health check failed: {e}") logger.error("Detailed health check failed: %s", e)
raise HTTPException(status_code=500, detail="Health check failed") raise HTTPException(status_code=500, detail="Health check failed")

View File

@@ -243,7 +243,7 @@ async def get_missing_nfo(
) )
except Exception as e: except Exception as e:
logger.error(f"Error getting missing NFOs: {e}", exc_info=True) logger.exception("Error getting missing NFOs: %s", e)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get missing NFOs: {str(e)}" detail=f"Failed to get missing NFOs: {str(e)}"
@@ -334,7 +334,7 @@ async def check_nfo(
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Error checking NFO for {serie_id}: {e}", exc_info=True) logger.exception("Error checking NFO for %s: %s", serie_id, e)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to check NFO: {str(e)}" detail=f"Failed to check NFO: {str(e)}"
@@ -429,7 +429,7 @@ async def create_nfo(
except HTTPException: except HTTPException:
raise raise
except TMDBAPIError as e: except TMDBAPIError as e:
logger.warning(f"TMDB API error creating NFO for {serie_id}: {e}") logger.warning("TMDB API error creating NFO for %s: %s", serie_id, e)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"TMDB API error: {str(e)}" detail=f"TMDB API error: {str(e)}"
@@ -524,7 +524,7 @@ async def update_nfo(
except HTTPException: except HTTPException:
raise raise
except TMDBAPIError as e: except TMDBAPIError as e:
logger.warning(f"TMDB API error updating NFO for {serie_id}: {e}") logger.warning("TMDB API error updating NFO for %s: %s", serie_id, e)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"TMDB API error: {str(e)}" detail=f"TMDB API error: {str(e)}"

View File

@@ -31,6 +31,7 @@ def _build_response(config: SchedulerConfig) -> Dict[str, Any]:
"schedule_time": config.schedule_time, "schedule_time": config.schedule_time,
"schedule_days": config.schedule_days, "schedule_days": config.schedule_days,
"auto_download_after_rescan": config.auto_download_after_rescan, "auto_download_after_rescan": config.auto_download_after_rescan,
"folder_scan_enabled": config.folder_scan_enabled,
}, },
"status": { "status": {
"is_running": runtime.get("is_running", False), "is_running": runtime.get("is_running", False),

View File

@@ -39,7 +39,7 @@ def get_settings() -> Union[DevelopmentSettings, ProductionSettings]:
Example: Example:
>>> settings = get_settings() >>> settings = get_settings()
>>> print(settings.log_level) >>> print(settings.log_level)
DEBUG INFO
""" """
if ENVIRONMENT in {"development", "testing"}: if ENVIRONMENT in {"development", "testing"}:
return get_development_settings() return get_development_settings()

View File

@@ -215,7 +215,7 @@ class DevelopmentSettings(BaseSettings):
@property @property
def debug_enabled(self) -> bool: def debug_enabled(self) -> bool:
"""Check if debug mode is enabled.""" """Check if debug mode is enabled."""
return True return False
@property @property
def reload_enabled(self) -> bool: def reload_enabled(self) -> bool:

View File

@@ -95,10 +95,10 @@ def setup_logging() -> Dict[str, logging.Logger]:
# Log initial setup # Log initial setup
root_logger.info("=" * 80) root_logger.info("=" * 80)
root_logger.info("FastAPI Server Logging Initialized") root_logger.info("FastAPI Server Logging Initialized")
root_logger.info(f"Log Level: {settings.log_level.upper()}") root_logger.info("Log Level: %s", settings.log_level.upper())
root_logger.info(f"Server Log: {server_log_file.absolute()}") root_logger.info("Server Log: %s", server_log_file.absolute())
root_logger.info(f"Error Log: {error_log_file.absolute()}") root_logger.info("Error Log: %s", error_log_file.absolute())
root_logger.info(f"Access Log: {access_log_file.absolute()}") root_logger.info("Access Log: %s", access_log_file.absolute())
root_logger.info("=" * 80) root_logger.info("=" * 80)
return { return {

View File

@@ -88,7 +88,7 @@ async def init_db() -> None:
try: try:
# Get database URL # Get database URL
db_url = _get_database_url() db_url = _get_database_url()
logger.info(f"Initializing database: {db_url}") logger.info("Initializing database: %s", db_url)
# Build engine kwargs based on database type # Build engine kwargs based on database type
is_sqlite = "sqlite" in db_url is_sqlite = "sqlite" in db_url
@@ -143,7 +143,7 @@ async def init_db() -> None:
logger.info("Database initialization complete") logger.info("Database initialization complete")
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize database: {e}") logger.error("Failed to initialize database: %s", e)
raise raise
@@ -171,7 +171,7 @@ async def close_db() -> None:
conn.commit() conn.commit()
logger.info("SQLite WAL checkpoint completed") logger.info("SQLite WAL checkpoint completed")
except Exception as e: except Exception as e:
logger.warning(f"WAL checkpoint failed (non-critical): {e}") logger.warning("WAL checkpoint failed (non-critical): %s", e)
if _engine: if _engine:
logger.info("Closing async database engine...") logger.info("Closing async database engine...")
@@ -188,7 +188,7 @@ async def close_db() -> None:
logger.info("Database connections closed") logger.info("Database connections closed")
except Exception as e: except Exception as e:
logger.error(f"Error closing database: {e}") logger.error("Error closing database: %s", e)
def get_engine() -> AsyncEngine: def get_engine() -> AsyncEngine:

View File

@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
# Schema Version Constants # Schema Version Constants
# ============================================================================= # =============================================================================
CURRENT_SCHEMA_VERSION = "1.0.0" CURRENT_SCHEMA_VERSION = "1.0.1"
SCHEMA_VERSION_TABLE = "schema_version" SCHEMA_VERSION_TABLE = "schema_version"
# Expected tables in the current schema # Expected tables in the current schema
@@ -98,7 +98,7 @@ async def initialize_database(
seed_data=True seed_data=True
) )
if result["success"]: if result["success"]:
logger.info(f"Database initialized: {result['schema_version']}") logger.info("Database initialized: %s", result['schema_version'])
""" """
if engine is None: if engine is None:
engine = get_engine() engine = get_engine()
@@ -117,7 +117,7 @@ async def initialize_database(
if create_schema: if create_schema:
tables = await create_database_schema(engine) tables = await create_database_schema(engine)
result["tables_created"] = tables result["tables_created"] = tables
logger.info(f"Created {len(tables)} tables") logger.info("Created %s tables", len(tables))
# Validate schema if requested # Validate schema if requested
if validate_schema: if validate_schema:
@@ -148,7 +148,7 @@ async def initialize_database(
return result return result
except Exception as e: except Exception as e:
logger.error(f"Database initialization failed: {e}", exc_info=True) logger.exception("Database initialization failed: %s", e)
raise RuntimeError(f"Failed to initialize database: {e}") from e raise RuntimeError(f"Failed to initialize database: {e}") from e
@@ -194,14 +194,14 @@ async def create_database_schema(
created_tables = [t for t in new_tables if t not in existing_tables] created_tables = [t for t in new_tables if t not in existing_tables]
if created_tables: if created_tables:
logger.info(f"Created tables: {', '.join(created_tables)}") logger.info("Created tables: %s", ', '.join(created_tables))
else: else:
logger.info("All tables already exist") logger.info("All tables already exist")
return new_tables return new_tables
except Exception as e: except Exception as e:
logger.error(f"Failed to create schema: {e}", exc_info=True) logger.exception("Failed to create schema: %s", e)
raise RuntimeError(f"Schema creation failed: {e}") from e raise RuntimeError(f"Schema creation failed: {e}") from e
@@ -295,7 +295,7 @@ async def validate_database_schema(
return result return result
except Exception as e: except Exception as e:
logger.error(f"Schema validation failed: {e}", exc_info=True) logger.exception("Schema validation failed: %s", e)
return { return {
"valid": False, "valid": False,
"missing_tables": [], "missing_tables": [],
@@ -319,7 +319,7 @@ async def get_schema_version(engine: Optional[AsyncEngine] = None) -> str:
engine: Optional database engine (uses default if not provided) engine: Optional database engine (uses default if not provided)
Returns: 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: if engine is None:
engine = get_engine() engine = get_engine()
@@ -342,7 +342,7 @@ async def get_schema_version(engine: Optional[AsyncEngine] = None) -> str:
return "unknown" return "unknown"
except Exception as e: except Exception as e:
logger.error(f"Failed to get schema version: {e}") logger.error("Failed to get schema version: %s", e)
return "error" return "error"
@@ -409,7 +409,7 @@ async def seed_initial_data(engine: Optional[AsyncEngine] = None) -> None:
logger.info("Data will be populated via normal application usage") logger.info("Data will be populated via normal application usage")
except Exception as e: except Exception as e:
logger.error(f"Failed to seed initial data: {e}", exc_info=True) logger.exception("Failed to seed initial data: %s", e)
raise raise
@@ -484,12 +484,12 @@ async def check_database_health(
f"(connectivity: {result['connectivity_ms']}ms)" f"(connectivity: {result['connectivity_ms']}ms)"
) )
else: else:
logger.warning(f"Database health issues: {result['issues']}") logger.warning("Database health issues: %s", result['issues'])
return result return result
except Exception as e: except Exception as e:
logger.error(f"Database health check failed: {e}") logger.error("Database health check failed: %s", e)
return { return {
"healthy": False, "healthy": False,
"accessible": False, "accessible": False,
@@ -547,13 +547,13 @@ async def create_database_backup(
backup_path = backup_dir / f"aniworld_{timestamp}.db" backup_path = backup_dir / f"aniworld_{timestamp}.db"
try: try:
logger.info(f"Creating database backup: {backup_path}") logger.info("Creating database backup: %s", backup_path)
shutil.copy2(db_path, backup_path) shutil.copy2(db_path, backup_path)
logger.info(f"Backup created successfully: {backup_path}") logger.info("Backup created successfully: %s", backup_path)
return backup_path return backup_path
except Exception as e: except Exception as e:
logger.error(f"Failed to create backup: {e}", exc_info=True) logger.exception("Failed to create backup: %s", e)
raise RuntimeError(f"Backup creation failed: {e}") from e raise RuntimeError(f"Backup creation failed: {e}") from e

View File

@@ -107,7 +107,7 @@ class AnimeSeriesService:
db.add(series) db.add(series)
await db.flush() await db.flush()
await db.refresh(series) await db.refresh(series)
logger.info(f"Created anime series: {series.name} (key={series.key}, year={year})") logger.info("Created anime series: %s (key=%s, year=%s)", series.name, series.key, year)
return series return series
@staticmethod @staticmethod
@@ -149,6 +149,26 @@ class AnimeSeriesService:
) )
return result.scalar_one_or_none() 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 @staticmethod
async def get_all( async def get_all(
db: AsyncSession, db: AsyncSession,
@@ -205,7 +225,7 @@ class AnimeSeriesService:
await db.flush() await db.flush()
await db.refresh(series) await db.refresh(series)
logger.info(f"Updated anime series: {series.name} (id={series_id})") logger.info("Updated anime series: %s (id=%s)", series.name, series_id)
return series return series
@staticmethod @staticmethod
@@ -226,7 +246,7 @@ class AnimeSeriesService:
) )
deleted = result.rowcount > 0 deleted = result.rowcount > 0
if deleted: if deleted:
logger.info(f"Deleted anime series with id={series_id}") logger.info("Deleted anime series with id=%s", series_id)
return deleted return deleted
@staticmethod @staticmethod
@@ -253,20 +273,16 @@ class AnimeSeriesService:
return list(result.scalars().all()) return list(result.scalars().all())
@staticmethod @staticmethod
async def get_series_with_no_episodes( async def get_series_with_missing_episodes(
db: AsyncSession, db: AsyncSession,
limit: Optional[int] = None, limit: Optional[int] = None,
offset: int = 0, offset: int = 0,
) -> List[AnimeSeries]: ) -> List[AnimeSeries]:
"""Get anime series that have no episodes found in folder. """Get anime series that currently have missing episodes.
Since episodes in the database represent MISSING episodes Episodes in the database represent missing episodes (from episodeDict).
(from episodeDict), this returns series that have episodes This returns series that have at least one missing episode recorded in
in the DB with is_downloaded=False, meaning they have missing the database (is_downloaded=False).
episodes and no files were found in the folder for those episodes.
Returns series where:
- At least one episode exists in database with is_downloaded=False
Args: Args:
db: Database session db: Database session
@@ -274,20 +290,68 @@ class AnimeSeriesService:
offset: Offset for pagination offset: Offset for pagination
Returns: Returns:
List of AnimeSeries with missing episodes (not in folder) List of AnimeSeries that have missing episodes.
""" """
# Subquery to find series IDs with at least one undownloaded episode # Subquery to find series IDs with at least one missing episode
undownloaded_series_ids = ( missing_series_ids = (
select(Episode.series_id) select(Episode.series_id)
.where(Episode.is_downloaded == False) .where(Episode.is_downloaded == False)
.distinct() .distinct()
.subquery() .subquery()
) )
# Select series that have undownloaded episodes
query = ( query = (
select(AnimeSeries) select(AnimeSeries)
.where(AnimeSeries.id.in_(select(undownloaded_series_ids.c.series_id))) .where(AnimeSeries.id.in_(select(missing_series_ids.c.series_id)))
.order_by(AnimeSeries.name)
.offset(offset)
)
if limit:
query = query.limit(limit)
result = await db.execute(query)
return list(result.scalars().all())
@staticmethod
async def get_series_with_no_episodes(
db: AsyncSession,
limit: Optional[int] = None,
offset: int = 0,
) -> List[AnimeSeries]:
"""Get anime series that have no downloaded episodes.
A series has "no episodes" if it has at least one missing episode
(is_downloaded=False) and no downloaded episodes (is_downloaded=True).
Args:
db: Database session
limit: Optional limit for results
offset: Offset for pagination
Returns:
List of AnimeSeries where no episodes are downloaded.
"""
# Series with missing episodes
missing_series_ids = (
select(Episode.series_id)
.where(Episode.is_downloaded == False)
.distinct()
.subquery()
)
# Series with any downloaded episodes
downloaded_series_ids = (
select(Episode.series_id)
.where(Episode.is_downloaded == True)
.distinct()
.subquery()
)
query = (
select(AnimeSeries)
.where(AnimeSeries.id.in_(select(missing_series_ids.c.series_id)))
.where(~AnimeSeries.id.in_(select(downloaded_series_ids.c.series_id)))
.order_by(AnimeSeries.name) .order_by(AnimeSeries.name)
.offset(offset) .offset(offset)
) )
@@ -657,7 +721,7 @@ class EpisodeService:
updated_count += 1 updated_count += 1
await db.flush() await db.flush()
logger.info(f"Bulk marked {updated_count} episodes as downloaded") logger.info("Bulk marked %s episodes as downloaded", updated_count)
return updated_count return updated_count
@@ -806,7 +870,7 @@ class DownloadQueueService:
await db.flush() await db.flush()
await db.refresh(item) await db.refresh(item)
logger.debug(f"Set error on download queue item {item_id}") logger.debug("Set error on download queue item %s", item_id)
return item return item
@staticmethod @staticmethod
@@ -825,7 +889,7 @@ class DownloadQueueService:
) )
deleted = result.rowcount > 0 deleted = result.rowcount > 0
if deleted: if deleted:
logger.info(f"Deleted download queue item with id={item_id}") logger.info("Deleted download queue item with id=%s", item_id)
return deleted return deleted
@staticmethod @staticmethod
@@ -887,7 +951,7 @@ class DownloadQueueService:
) )
count = result.rowcount count = result.rowcount
logger.info(f"Bulk deleted {count} download queue items") logger.info("Bulk deleted %s download queue items", count)
return count return count
@@ -908,7 +972,7 @@ class DownloadQueueService:
""" """
result = await db.execute(delete(DownloadQueueItem)) result = await db.execute(delete(DownloadQueueItem))
count = result.rowcount count = result.rowcount
logger.info(f"Cleared all {count} download queue items") logger.info("Cleared all %s download queue items", count)
return count return count
@@ -962,7 +1026,7 @@ class UserSessionService:
db.add(session) db.add(session)
await db.flush() await db.flush()
await db.refresh(session) await db.refresh(session)
logger.info(f"Created user session: {session_id}") logger.info("Created user session: %s", session_id)
return session return session
@staticmethod @staticmethod
@@ -1049,7 +1113,7 @@ class UserSessionService:
session.revoke() session.revoke()
await db.flush() await db.flush()
logger.info(f"Revoked user session: {session_id}") logger.info("Revoked user session: %s", session_id)
return True return True
@staticmethod @staticmethod
@@ -1071,7 +1135,7 @@ class UserSessionService:
) )
) )
count = result.rowcount count = result.rowcount
logger.info(f"Cleaned up {count} expired sessions") logger.info("Cleaned up %s expired sessions", count)
return count return count
@staticmethod @staticmethod

View File

@@ -6,6 +6,7 @@ configuration, middleware setup, static file serving, and Jinja2 template
integration. integration.
""" """
import asyncio import asyncio
import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -51,7 +52,7 @@ async def _check_incomplete_series_on_startup(background_loader) -> None:
Args: Args:
background_loader: BackgroundLoaderService instance background_loader: BackgroundLoaderService instance
""" """
logger = setup_logging(log_level="INFO") logger = logging.getLogger("aniworld")
try: try:
from src.server.database.connection import get_db_session from src.server.database.connection import get_db_session
@@ -96,11 +97,11 @@ async def _check_incomplete_series_on_startup(background_loader) -> None:
else: else:
logger.info("All series data is complete. No background loading needed.") logger.info("All series data is complete. No background loading needed.")
except Exception as e: except Exception:
logger.error(f"Error checking incomplete series: {e}", exc_info=True) logger.exception("Error checking incomplete series")
except Exception as e: except Exception:
logger.error(f"Failed to check incomplete series on startup: {e}", exc_info=True) logger.exception("Failed to check incomplete series on startup")
@asynccontextmanager @asynccontextmanager
@@ -241,7 +242,6 @@ async def lifespan(_application: FastAPI):
from src.server.services.initialization_service import ( from src.server.services.initialization_service import (
perform_initial_setup, perform_initial_setup,
perform_media_scan_if_needed, perform_media_scan_if_needed,
perform_nfo_repair_scan,
perform_nfo_scan_if_needed, perform_nfo_scan_if_needed,
) )
@@ -312,10 +312,6 @@ async def lifespan(_application: FastAPI):
# Run media scan only on first run # Run media scan only on first run
await perform_media_scan_if_needed(background_loader) await perform_media_scan_if_needed(background_loader)
# Scan every series NFO on startup and repair any that are
# missing required tags by queuing them for background reload
await perform_nfo_repair_scan(background_loader)
else: else:
logger.info( logger.info(
"Download service initialization skipped - " "Download service initialization skipped - "
@@ -484,7 +480,7 @@ async def lifespan(_application: FastAPI):
app = FastAPI( app = FastAPI(
title="Aniworld Download Manager", title="Aniworld Download Manager",
description="Modern web interface for Aniworld anime download management", description="Modern web interface for Aniworld anime download management",
version="1.0.0", version="1.0.1",
docs_url="/api/docs", docs_url="/api/docs",
redoc_url="/api/redoc", redoc_url="/api/redoc",
lifespan=lifespan lifespan=lifespan

View File

@@ -74,7 +74,8 @@ def register_exception_handlers(app: FastAPI) -> None:
) -> JSONResponse: ) -> JSONResponse:
"""Handle authentication errors (401).""" """Handle authentication errors (401)."""
logger.warning( logger.warning(
f"Authentication error: {exc.message}", "Authentication error: %s",
exc.message,
extra={"details": exc.details, "path": str(request.url.path)}, extra={"details": exc.details, "path": str(request.url.path)},
) )
return JSONResponse( return JSONResponse(
@@ -94,7 +95,8 @@ def register_exception_handlers(app: FastAPI) -> None:
) -> JSONResponse: ) -> JSONResponse:
"""Handle authorization errors (403).""" """Handle authorization errors (403)."""
logger.warning( logger.warning(
f"Authorization error: {exc.message}", "Authorization error: %s",
exc.message,
extra={"details": exc.details, "path": str(request.url.path)}, extra={"details": exc.details, "path": str(request.url.path)},
) )
return JSONResponse( return JSONResponse(
@@ -114,7 +116,8 @@ def register_exception_handlers(app: FastAPI) -> None:
) -> JSONResponse: ) -> JSONResponse:
"""Handle validation errors (422).""" """Handle validation errors (422)."""
logger.info( logger.info(
f"Validation error: {exc.message}", "Validation error: %s",
exc.message,
extra={"details": exc.details, "path": str(request.url.path)}, extra={"details": exc.details, "path": str(request.url.path)},
) )
return JSONResponse( return JSONResponse(
@@ -134,7 +137,8 @@ def register_exception_handlers(app: FastAPI) -> None:
) -> JSONResponse: ) -> JSONResponse:
"""Handle bad request errors (400).""" """Handle bad request errors (400)."""
logger.info( logger.info(
f"Bad request error: {exc.message}", "Bad request error: %s",
exc.message,
extra={"details": exc.details, "path": str(request.url.path)}, extra={"details": exc.details, "path": str(request.url.path)},
) )
return JSONResponse( return JSONResponse(
@@ -154,7 +158,8 @@ def register_exception_handlers(app: FastAPI) -> None:
) -> JSONResponse: ) -> JSONResponse:
"""Handle not found errors (404).""" """Handle not found errors (404)."""
logger.info( logger.info(
f"Not found error: {exc.message}", "Not found error: %s",
exc.message,
extra={"details": exc.details, "path": str(request.url.path)}, extra={"details": exc.details, "path": str(request.url.path)},
) )
return JSONResponse( return JSONResponse(
@@ -174,7 +179,8 @@ def register_exception_handlers(app: FastAPI) -> None:
) -> JSONResponse: ) -> JSONResponse:
"""Handle conflict errors (409).""" """Handle conflict errors (409)."""
logger.info( logger.info(
f"Conflict error: {exc.message}", "Conflict error: %s",
exc.message,
extra={"details": exc.details, "path": str(request.url.path)}, extra={"details": exc.details, "path": str(request.url.path)},
) )
return JSONResponse( return JSONResponse(
@@ -194,7 +200,8 @@ def register_exception_handlers(app: FastAPI) -> None:
) -> JSONResponse: ) -> JSONResponse:
"""Handle rate limit errors (429).""" """Handle rate limit errors (429)."""
logger.warning( logger.warning(
f"Rate limit exceeded: {exc.message}", "Rate limit exceeded: %s",
exc.message,
extra={"details": exc.details, "path": str(request.url.path)}, extra={"details": exc.details, "path": str(request.url.path)},
) )
return JSONResponse( return JSONResponse(
@@ -214,7 +221,8 @@ def register_exception_handlers(app: FastAPI) -> None:
) -> JSONResponse: ) -> JSONResponse:
"""Handle generic API exceptions.""" """Handle generic API exceptions."""
logger.error( logger.error(
f"API error: {exc.message}", "API error: %s",
exc.message,
extra={ extra={
"error_code": exc.error_code, "error_code": exc.error_code,
"details": exc.details, "details": exc.details,
@@ -238,12 +246,13 @@ def register_exception_handlers(app: FastAPI) -> None:
) -> JSONResponse: ) -> JSONResponse:
"""Handle unexpected exceptions.""" """Handle unexpected exceptions."""
logger.exception( logger.exception(
f"Unexpected error: {str(exc)}", "Unexpected error: %s",
str(exc),
extra={"path": str(request.url.path)}, extra={"path": str(request.url.path)},
) )
# Log full traceback for debugging # Log full traceback for debugging
logger.debug(f"Traceback: {traceback.format_exc()}") logger.debug("Traceback: %s", traceback.format_exc())
# Return generic error response for security # Return generic error response for security
return JSONResponse( return JSONResponse(

View File

@@ -315,11 +315,11 @@ class RequestSanitizationMiddleware(BaseHTTPMiddleware):
None if malicious content detected, sanitized value otherwise None if malicious content detected, sanitized value otherwise
""" """
if self.check_sql_injection and self._check_sql_injection(value): if self.check_sql_injection and self._check_sql_injection(value):
logger.warning(f"Potential SQL injection detected: {value[:100]}") logger.warning("Potential SQL injection detected: %s", value[:100])
return None return None
if self.check_xss and self._check_xss(value): if self.check_xss and self._check_xss(value):
logger.warning(f"Potential XSS detected: {value[:100]}") logger.warning("Potential XSS detected: %s", value[:100])
return None return None
return value return value
@@ -341,7 +341,7 @@ class RequestSanitizationMiddleware(BaseHTTPMiddleware):
content_type content_type
and not any(ct in content_type for ct in self.allowed_content_types) and not any(ct in content_type for ct in self.allowed_content_types)
): ):
logger.warning(f"Unsupported content type: {content_type}") logger.warning("Unsupported content type: %s", content_type)
return JSONResponse( return JSONResponse(
status_code=415, status_code=415,
content={"detail": "Unsupported Media Type"}, content={"detail": "Unsupported Media Type"},
@@ -350,7 +350,7 @@ class RequestSanitizationMiddleware(BaseHTTPMiddleware):
# Check request size # Check request size
content_length = request.headers.get("content-length") content_length = request.headers.get("content-length")
if content_length and int(content_length) > self.max_request_size: if content_length and int(content_length) > self.max_request_size:
logger.warning(f"Request too large: {content_length} bytes") logger.warning("Request too large: %s bytes", content_length)
return JSONResponse( return JSONResponse(
status_code=413, status_code=413,
content={"detail": "Request Entity Too Large"}, content={"detail": "Request Entity Too Large"},
@@ -361,7 +361,7 @@ class RequestSanitizationMiddleware(BaseHTTPMiddleware):
if isinstance(value, str): if isinstance(value, str):
sanitized = self._sanitize_value(value) sanitized = self._sanitize_value(value)
if sanitized is None: if sanitized is None:
logger.warning(f"Malicious query parameter detected: {key}") logger.warning("Malicious query parameter detected: %s", key)
return JSONResponse( return JSONResponse(
status_code=400, status_code=400,
content={"detail": "Malicious request detected"}, content={"detail": "Malicious request detected"},
@@ -372,7 +372,7 @@ class RequestSanitizationMiddleware(BaseHTTPMiddleware):
if isinstance(value, str): if isinstance(value, str):
sanitized = self._sanitize_value(value) sanitized = self._sanitize_value(value)
if sanitized is None: if sanitized is None:
logger.warning(f"Malicious path parameter detected: {key}") logger.warning("Malicious path parameter detected: %s", key)
return JSONResponse( return JSONResponse(
status_code=400, status_code=400,
content={"detail": "Malicious request detected"}, content={"detail": "Malicious request detected"},

View File

@@ -73,6 +73,9 @@ class SetupRequest(BaseModel):
scheduler_auto_download_after_rescan: Optional[bool] = Field( scheduler_auto_download_after_rescan: Optional[bool] = Field(
default=False, description="Auto-download missing episodes after rescan" default=False, description="Auto-download missing episodes after rescan"
) )
scheduler_folder_scan_enabled: Optional[bool] = Field(
default=False, description="Run folder maintenance during scheduled run"
)
# Logging configuration # Logging configuration
logging_level: Optional[str] = Field( logging_level: Optional[str] = Field(

View File

@@ -39,6 +39,11 @@ class SchedulerConfig(BaseModel):
description="Automatically queue and start downloads for all missing " description="Automatically queue and start downloads for all missing "
"episodes after a scheduled rescan completes.", "episodes after a scheduled rescan completes.",
) )
folder_scan_enabled: bool = Field(
default=False,
description="Run folder maintenance (NFO repair, folder renaming, "
"poster checks) during the scheduled run.",
)
@field_validator("schedule_time") @field_validator("schedule_time")
@classmethod @classmethod

View File

@@ -524,18 +524,19 @@ class AnimeService:
"series_id": db_series.id, "series_id": db_series.id,
} }
# If filter is "no_episodes", get series with no # If filter is "missing_episodes", get series with any missing episodes
# downloaded episodes if filter_type == "missing_episodes":
if filter_type == "no_episodes": series_missing = (
# Use service method to get series with await AnimeSeriesService.get_series_with_missing_episodes(db)
# undownloaded episodes
series_no_downloads = (
await AnimeSeriesService
.get_series_with_no_episodes(db)
) )
series_with_no_episodes = { series_with_missing_episodes = {s.folder for s in series_missing}
s.folder for s in series_no_downloads
} # If filter is "no_episodes", get series with no downloaded episodes
if filter_type == "no_episodes":
series_no_downloads = (
await AnimeSeriesService.get_series_with_no_episodes(db)
)
series_with_no_episodes = {s.folder for s in series_no_downloads}
# Build result list with enriched metadata # Build result list with enriched metadata
result_list = [] result_list = []
@@ -547,6 +548,9 @@ class AnimeService:
episode_dict = getattr(serie, "episodeDict", {}) or {} episode_dict = getattr(serie, "episodeDict", {}) or {}
# Apply filter if specified # Apply filter if specified
if filter_type == "missing_episodes":
if folder not in series_with_missing_episodes:
continue
if filter_type == "no_episodes": if filter_type == "no_episodes":
if folder not in series_with_no_episodes: if folder not in series_with_no_episodes:
continue continue
@@ -941,12 +945,12 @@ class AnimeService:
# Get the serie from in-memory cache # Get the serie from in-memory cache
if not hasattr(self._app, 'list') or not hasattr(self._app.list, 'keyDict'): if not hasattr(self._app, 'list') or not hasattr(self._app.list, 'keyDict'):
logger.warning(f"Series list not available for episode sync: {series_key}") logger.warning("Series list not available for episode sync: %s", series_key)
return 0 return 0
serie = self._app.list.keyDict.get(series_key) serie = self._app.list.keyDict.get(series_key)
if not serie: if not serie:
logger.warning(f"Series not found in memory for episode sync: {series_key}") logger.warning("Series not found in memory for episode sync: %s", series_key)
return 0 return 0
episodes_added = 0 episodes_added = 0
@@ -955,7 +959,7 @@ class AnimeService:
# Get series from database # Get series from database
series_db = await AnimeSeriesService.get_by_key(db, series_key) series_db = await AnimeSeriesService.get_by_key(db, series_key)
if not series_db: if not series_db:
logger.warning(f"Series not found in database: {series_key}") logger.warning("Series not found in database: %s", series_key)
return 0 return 0
# Get existing episodes from database # Get existing episodes from database
@@ -996,7 +1000,7 @@ class AnimeService:
try: try:
await self._broadcast_series_updated(series_key) await self._broadcast_series_updated(series_key)
except Exception as e: except Exception as e:
logger.warning(f"Failed to broadcast series update: {e}") logger.warning("Failed to broadcast series update: %s", e)
return episodes_added return episodes_added

View File

@@ -22,6 +22,7 @@ from typing import Any, Dict, List, Optional
import structlog import structlog
from src.core.services.nfo_factory import get_nfo_factory
from src.server.services.websocket_service import WebSocketService from src.server.services.websocket_service import WebSocketService
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@@ -188,7 +189,7 @@ class BackgroundLoaderService:
""" """
# Check if task already exists # Check if task already exists
if key in self.active_tasks: if key in self.active_tasks:
logger.debug(f"Task for series {key} already exists, skipping") logger.debug("Task for series %s already exists, skipping", key)
return return
task = SeriesLoadingTask( task = SeriesLoadingTask(
@@ -202,7 +203,7 @@ class BackgroundLoaderService:
self.active_tasks[key] = task self.active_tasks[key] = task
await self.task_queue.put(task) await self.task_queue.put(task)
logger.info(f"Added loading task for series: {key}") logger.info("Added loading task for series: %s", key)
# Broadcast initial status # Broadcast initial status
await self._broadcast_status(task) await self._broadcast_status(task)
@@ -277,7 +278,7 @@ class BackgroundLoaderService:
Args: Args:
worker_id: Unique identifier for this worker instance worker_id: Unique identifier for this worker instance
""" """
logger.info(f"Background worker {worker_id} started processing tasks") logger.info("Background worker %s started processing tasks", worker_id)
while not self._shutdown: while not self._shutdown:
try: try:
@@ -301,14 +302,14 @@ class BackgroundLoaderService:
# No task available, continue loop # No task available, continue loop
continue continue
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info(f"Worker {worker_id} task cancelled") logger.info("Worker %s task cancelled", worker_id)
break break
except Exception as e: except Exception as e:
logger.exception(f"Error in background worker {worker_id}: {e}") logger.exception("Error in background worker %s: %s", worker_id, e)
# Continue processing other tasks # Continue processing other tasks
continue continue
logger.info(f"Background worker {worker_id} stopped") logger.info("Background worker %s stopped", worker_id)
async def _load_series_data(self, task: SeriesLoadingTask) -> None: async def _load_series_data(self, task: SeriesLoadingTask) -> None:
"""Load all missing data for a series. """Load all missing data for a series.
@@ -362,10 +363,10 @@ class BackgroundLoaderService:
# Broadcast completion # Broadcast completion
await self._broadcast_status(task) await self._broadcast_status(task)
logger.info(f"Successfully loaded all data for series: {task.key}") logger.info("Successfully loaded all data for series: %s", task.key)
except Exception as e: except Exception as e:
logger.exception(f"Error loading series data: {e}") logger.exception("Error loading series data: %s", e)
task.status = LoadingStatus.FAILED task.status = LoadingStatus.FAILED
task.error = str(e) task.error = str(e)
task.completed_at = datetime.now(timezone.utc) task.completed_at = datetime.now(timezone.utc)
@@ -400,14 +401,14 @@ class BackgroundLoaderService:
# Check if directory exists # Check if directory exists
if series_dir.exists() and series_dir.is_dir(): if series_dir.exists() and series_dir.is_dir():
logger.debug(f"Found series directory: {series_dir}") logger.debug("Found series directory: %s", series_dir)
return series_dir return series_dir
else: else:
logger.warning(f"Series directory not found: {series_dir}") logger.warning("Series directory not found: %s", series_dir)
return None return None
except Exception as e: except Exception as e:
logger.error(f"Error finding series directory for {task.key}: {e}") logger.error("Error finding series directory for %s: %s", task.key, e)
return None return None
async def _scan_series_episodes(self, series_dir: Path, task: SeriesLoadingTask) -> Dict[str, List[str]]: async def _scan_series_episodes(self, series_dir: Path, task: SeriesLoadingTask) -> Dict[str, List[str]]:
@@ -440,13 +441,13 @@ class BackgroundLoaderService:
if episodes: if episodes:
episodes_by_season[season_name] = episodes episodes_by_season[season_name] = episodes
logger.debug(f"Found {len(episodes)} episodes in {season_name}") logger.debug("Found %s episodes in %s", len(episodes), season_name)
logger.info(f"Scanned {len(episodes_by_season)} seasons for {task.key}") logger.info("Scanned %s seasons for %s", len(episodes_by_season), task.key)
return episodes_by_season return episodes_by_season
except Exception as e: except Exception as e:
logger.error(f"Error scanning episodes for {task.key}: {e}") logger.error("Error scanning episodes for %s: %s", task.key, e)
return {} return {}
async def _load_episodes(self, task: SeriesLoadingTask, db: Any) -> None: async def _load_episodes(self, task: SeriesLoadingTask, db: Any) -> None:
@@ -466,7 +467,7 @@ class BackgroundLoaderService:
# Find series directory without full rescan # Find series directory without full rescan
series_dir = await self._find_series_directory(task) series_dir = await self._find_series_directory(task)
if not series_dir: if not series_dir:
logger.error(f"Cannot load episodes - directory not found for {task.key}") logger.error("Cannot load episodes - directory not found for %s", task.key)
task.progress["episodes"] = False task.progress["episodes"] = False
return return
@@ -474,7 +475,7 @@ class BackgroundLoaderService:
episodes_by_season = await self._scan_series_episodes(series_dir, task) episodes_by_season = await self._scan_series_episodes(series_dir, task)
if not episodes_by_season: if not episodes_by_season:
logger.warning(f"No episodes found for {task.key}") logger.warning("No episodes found for %s", task.key)
task.progress["episodes"] = False task.progress["episodes"] = False
return return
@@ -489,10 +490,10 @@ class BackgroundLoaderService:
series_db.loading_status = "loading_episodes" series_db.loading_status = "loading_episodes"
await db.commit() await db.commit()
logger.info(f"Episodes loaded for series: {task.key} ({len(episodes_by_season)} seasons)") logger.info("Episodes loaded for series: %s (%s seasons)", task.key, len(episodes_by_season))
except Exception as e: except Exception as e:
logger.exception(f"Failed to load episodes for {task.key}: {e}") logger.exception("Failed to load episodes for %s: %s", task.key, e)
raise raise
async def _load_nfo_and_images(self, task: SeriesLoadingTask, db: Any) -> bool: async def _load_nfo_and_images(self, task: SeriesLoadingTask, db: Any) -> bool:
@@ -521,7 +522,7 @@ class BackgroundLoaderService:
# Check if NFO already exists # Check if NFO already exists
if self.series_app.nfo_service.has_nfo(task.folder): if self.series_app.nfo_service.has_nfo(task.folder):
logger.info(f"NFO already exists for {task.key}, skipping creation") logger.info("NFO already exists for %s, skipping creation", task.key)
# Update task progress # Update task progress
task.progress["nfo"] = True task.progress["nfo"] = True
@@ -536,23 +537,36 @@ class BackgroundLoaderService:
if not series_db.has_nfo: if not series_db.has_nfo:
series_db.has_nfo = True series_db.has_nfo = True
series_db.nfo_created_at = datetime.now(timezone.utc) series_db.nfo_created_at = datetime.now(timezone.utc)
logger.info(f"Updated database with existing NFO for {task.key}") logger.info("Updated database with existing NFO for %s", task.key)
if not series_db.logo_loaded: if not series_db.logo_loaded:
series_db.logo_loaded = True series_db.logo_loaded = True
if not series_db.images_loaded: if not series_db.images_loaded:
series_db.images_loaded = True series_db.images_loaded = True
await db.commit() await db.commit()
logger.info(f"Existing NFO found and database updated for series: {task.key}") logger.info("Existing NFO found and database updated for series: %s", task.key)
return False return False
# NFO doesn't exist, create it # NFO doesn't exist, create it
await self._broadcast_status(task, "Generating NFO file...") await self._broadcast_status(task, "Generating NFO file...")
logger.info(f"Creating new NFO for {task.key}") logger.info("Creating new NFO for %s", task.key)
# Use existing NFOService to create NFO with all images # Create a fresh NFOService for this task to avoid shared TMDB session closure
# This reuses all existing TMDB API logic and image downloading try:
nfo_path = await self.series_app.nfo_service.create_tvshow_nfo( factory = get_nfo_factory()
nfo_service = factory.create()
except ValueError:
logger.warning(
"NFOService unavailable for %s, skipping NFO/images",
task.key
)
task.progress["nfo"] = False
task.progress["logo"] = False
task.progress["images"] = False
return False
try:
nfo_path = await nfo_service.create_tvshow_nfo(
serie_name=task.name, serie_name=task.name,
serie_folder=task.folder, serie_folder=task.folder,
year=task.year, year=task.year,
@@ -560,6 +574,8 @@ class BackgroundLoaderService:
download_logo=True, download_logo=True,
download_fanart=True download_fanart=True
) )
finally:
await nfo_service.close()
# Update task progress # Update task progress
task.progress["nfo"] = True task.progress["nfo"] = True
@@ -577,11 +593,11 @@ class BackgroundLoaderService:
series_db.loading_status = "loading_nfo" series_db.loading_status = "loading_nfo"
await db.commit() await db.commit()
logger.info(f"NFO and images created and loaded for series: {task.key}") logger.info("NFO and images created and loaded for series: %s", task.key)
return True return True
except Exception as e: except Exception as e:
logger.exception(f"Failed to load NFO/images for {task.key}: {e}") logger.exception("Failed to load NFO/images for %s: %s", task.key, e)
# Don't fail the entire task if NFO fails # Don't fail the entire task if NFO fails
task.progress["nfo"] = False task.progress["nfo"] = False
task.progress["logo"] = False task.progress["logo"] = False
@@ -611,7 +627,7 @@ class BackgroundLoaderService:
# Scan for missing episodes using the targeted scan method # Scan for missing episodes using the targeted scan method
# This populates the episodeDict without triggering a full rescan # This populates the episodeDict without triggering a full rescan
logger.info(f"Scanning missing episodes for {task.key}") logger.info("Scanning missing episodes for %s", task.key)
missing_episodes = self.series_app.serie_scanner.scan_single_series( missing_episodes = self.series_app.serie_scanner.scan_single_series(
key=task.key, key=task.key,
folder=task.folder folder=task.folder
@@ -628,12 +644,12 @@ class BackgroundLoaderService:
# Notify anime_service to sync episodes to database # Notify anime_service to sync episodes to database
# Use sync_single_series_after_scan which gets data from serie_scanner.keyDict # Use sync_single_series_after_scan which gets data from serie_scanner.keyDict
if self.anime_service: if self.anime_service:
logger.debug(f"Calling anime_service.sync_single_series_after_scan for {task.key}") logger.debug("Calling anime_service.sync_single_series_after_scan for %s", task.key)
await self.anime_service.sync_single_series_after_scan(task.key) await self.anime_service.sync_single_series_after_scan(task.key)
else: else:
logger.warning(f"anime_service not available, episodes will not be synced to DB for {task.key}") logger.warning("anime_service not available, episodes will not be synced to DB for %s", task.key)
else: else:
logger.info(f"No missing episodes found for {task.key}") logger.info("No missing episodes found for %s", task.key)
# Update series status in database # Update series status in database
from src.server.database.service import AnimeSeriesService from src.server.database.service import AnimeSeriesService
@@ -648,7 +664,7 @@ class BackgroundLoaderService:
task.progress["episodes"] = True task.progress["episodes"] = True
except Exception as e: except Exception as e:
logger.exception(f"Failed to scan missing episodes for {task.key}: {e}") logger.exception("Failed to scan missing episodes for %s: %s", task.key, e)
task.progress["episodes"] = False task.progress["episodes"] = False
async def _broadcast_status( async def _broadcast_status(

View File

@@ -170,14 +170,17 @@ class InMemoryCacheBackend(CacheBackend):
"""Get value from cache.""" """Get value from cache."""
async with self._lock: async with self._lock:
if key not in self.cache: if key not in self.cache:
logger.debug("Cache miss for key: %s", key)
return None return None
item = self.cache[key] item = self.cache[key]
if self._is_expired(item): if self._is_expired(item):
logger.debug("Cache expired for key: %s", key)
del self.cache[key] del self.cache[key]
return None return None
logger.debug("Cache hit for key: %s", key)
return item["value"] return item["value"]
async def set( async def set(
@@ -196,6 +199,7 @@ class InMemoryCacheBackend(CacheBackend):
"expiry": expiry, "expiry": expiry,
"created": datetime.utcnow(), "created": datetime.utcnow(),
} }
logger.debug("Cached key: %s (ttl=%s)", key, ttl)
return True return True
async def delete(self, key: str) -> bool: async def delete(self, key: str) -> bool:
@@ -203,7 +207,9 @@ class InMemoryCacheBackend(CacheBackend):
async with self._lock: async with self._lock:
if key in self.cache: if key in self.cache:
del self.cache[key] del self.cache[key]
logger.debug("Deleted cache key: %s", key)
return True return True
logger.debug("Cache delete skipped; key not found: %s", key)
return False return False
async def exists(self, key: str) -> bool: async def exists(self, key: str) -> bool:
@@ -223,6 +229,7 @@ class InMemoryCacheBackend(CacheBackend):
"""Clear all cached values.""" """Clear all cached values."""
async with self._lock: async with self._lock:
self.cache.clear() self.cache.clear()
logger.debug("Cleared in-memory cache")
return True return True
async def get_many(self, keys: List[str]) -> Dict[str, Any]: async def get_many(self, keys: List[str]) -> Dict[str, Any]:
@@ -281,13 +288,14 @@ class RedisCacheBackend(CacheBackend):
import aioredis import aioredis
self._redis = await aioredis.create_redis_pool(self.redis_url) self._redis = await aioredis.create_redis_pool(self.redis_url)
logger.debug("Connected to Redis at %s", self.redis_url)
except ImportError: except ImportError:
logger.error( logger.error(
"aioredis not installed. Install with: pip install aioredis" "aioredis not installed. Install with: pip install aioredis"
) )
raise raise
except Exception as e: except Exception as e:
logger.error(f"Failed to connect to Redis: {e}") logger.error("Failed to connect to Redis: %s", e)
raise raise
return self._redis return self._redis
@@ -308,7 +316,7 @@ class RedisCacheBackend(CacheBackend):
return pickle.loads(data) return pickle.loads(data)
except Exception as e: except Exception as e:
logger.error(f"Redis get error: {e}") logger.error("Redis get error: %s", e)
return None return None
async def set( async def set(
@@ -327,7 +335,7 @@ class RedisCacheBackend(CacheBackend):
return True return True
except Exception as e: except Exception as e:
logger.error(f"Redis set error: {e}") logger.error("Redis set error: %s", e)
return False return False
async def delete(self, key: str) -> bool: async def delete(self, key: str) -> bool:
@@ -338,7 +346,7 @@ class RedisCacheBackend(CacheBackend):
return result > 0 return result > 0
except Exception as e: except Exception as e:
logger.error(f"Redis delete error: {e}") logger.error("Redis delete error: %s", e)
return False return False
async def exists(self, key: str) -> bool: async def exists(self, key: str) -> bool:
@@ -348,7 +356,7 @@ class RedisCacheBackend(CacheBackend):
return await redis.exists(self._make_key(key)) return await redis.exists(self._make_key(key))
except Exception as e: except Exception as e:
logger.error(f"Redis exists error: {e}") logger.error("Redis exists error: %s", e)
return False return False
async def clear(self) -> bool: async def clear(self) -> bool:
@@ -361,7 +369,7 @@ class RedisCacheBackend(CacheBackend):
return True return True
except Exception as e: except Exception as e:
logger.error(f"Redis clear error: {e}") logger.error("Redis clear error: %s", e)
return False return False
async def get_many(self, keys: List[str]) -> Dict[str, Any]: async def get_many(self, keys: List[str]) -> Dict[str, Any]:
@@ -379,7 +387,7 @@ class RedisCacheBackend(CacheBackend):
return result return result
except Exception as e: except Exception as e:
logger.error(f"Redis get_many error: {e}") logger.error("Redis get_many error: %s", e)
return {} return {}
async def set_many( async def set_many(
@@ -392,7 +400,7 @@ class RedisCacheBackend(CacheBackend):
return True return True
except Exception as e: except Exception as e:
logger.error(f"Redis set_many error: {e}") logger.error("Redis set_many error: %s", e)
return False return False
async def delete_pattern(self, pattern: str) -> int: async def delete_pattern(self, pattern: str) -> int:
@@ -409,7 +417,7 @@ class RedisCacheBackend(CacheBackend):
return 0 return 0
except Exception as e: except Exception as e:
logger.error(f"Redis delete_pattern error: {e}") logger.error("Redis delete_pattern error: %s", e)
return 0 return 0
async def close(self) -> None: async def close(self) -> None:

View File

@@ -8,6 +8,7 @@ This service handles:
""" """
import json import json
import logging
import shutil import shutil
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -15,6 +16,8 @@ from typing import Dict, List, Optional
from src.server.models.config import AppConfig, ConfigUpdate, ValidationResult from src.server.models.config import AppConfig, ConfigUpdate, ValidationResult
logger = logging.getLogger(__name__)
class ConfigServiceError(Exception): class ConfigServiceError(Exception):
"""Base exception for configuration service errors.""" """Base exception for configuration service errors."""
@@ -41,7 +44,7 @@ class ConfigService:
""" """
# Current configuration schema version # Current configuration schema version
CONFIG_VERSION = "1.0.0" CONFIG_VERSION = "1.0.1"
def __init__( def __init__(
self, self,
@@ -136,7 +139,7 @@ class ConfigService:
self.create_backup() self.create_backup()
except ConfigBackupError as e: except ConfigBackupError as e:
# Log but don't fail save operation # Log but don't fail save operation
print(f"Warning: Failed to create backup: {e}") logger.warning("Failed to create backup: %s", e)
# Save configuration with version # Save configuration with version
data = config.model_dump() data = config.model_dump()

View File

@@ -0,0 +1,342 @@
"""Folder rename service for validating and renaming series folders.
After NFO repair, this service iterates over every subfolder in
``settings.anime_directory`` that contains a ``tvshow.nfo``. For each
folder it parses the NFO to extract ``<title>`` and ``<year>``, computes
the expected folder name ``f"{title} ({year})"``, sanitises it for
filesystem safety, and renames the folder if the current name differs.
Database records (``AnimeSeries.folder``, ``Episode.file_path``,
``DownloadQueueItem.file_destination``) are updated atomically to
reflect the new paths.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from lxml import etree
from src.config.settings import settings
from src.server.database.connection import get_db_session
from src.server.database.service import (
AnimeSeriesService,
DownloadQueueService,
EpisodeService,
)
from src.server.utils.dependencies import get_download_service
from src.server.utils.filesystem import sanitize_folder_name
logger = logging.getLogger(__name__)
# Characters that are invalid in filesystem paths across platforms
INVALID_PATH_CHARS = '<>:"/\\|?*\x00'
def _parse_nfo_title_and_year(nfo_path: Path) -> Tuple[Optional[str], Optional[str]]:
"""Parse a tvshow.nfo and return (title, year) text values.
Args:
nfo_path: Absolute path to the ``tvshow.nfo`` file.
Returns:
Tuple of (title, year) where either may be ``None`` if missing
or empty.
"""
try:
tree = etree.parse(str(nfo_path))
root = tree.getroot()
title_elem = root.find("./title")
year_elem = root.find("./year")
title = title_elem.text.strip() if title_elem is not None and title_elem.text and title_elem.text.strip() else None
year = year_elem.text.strip() if year_elem is not None and year_elem.text and year_elem.text.strip() else None
return title, year
except etree.XMLSyntaxError as exc:
logger.warning("Malformed XML in %s: %s", nfo_path, exc)
return None, None
except Exception as exc: # pylint: disable=broad-except
logger.warning("Unexpected error parsing %s: %s", nfo_path, exc)
return None, None
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.
Returns:
Sanitised folder name in the format ``"{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)
def _is_series_being_downloaded(series_folder: str) -> bool:
"""Check whether the given series has an active or pending download.
Args:
series_folder: The series folder name (as stored in the DB).
Returns:
``True`` if the series appears in the active download or the
pending queue.
"""
try:
download_service = get_download_service()
active = download_service._active_download # pylint: disable=protected-access
if active and active.serie_folder == series_folder:
return True
for item in download_service._pending_queue: # pylint: disable=protected-access
if item.serie_folder == series_folder:
return True
return False
except Exception as exc: # pylint: disable=broad-except
logger.warning(
"Could not check download status for %s: %s", series_folder, exc
)
# Safer to skip renaming if we can't verify download status.
return True
async def _update_database_paths(
old_folder: str,
new_folder: str,
anime_dir: Path,
) -> None:
"""Update all database records that reference the old folder path.
Updates:
- ``AnimeSeries.folder`` → ``new_folder``
- ``Episode.file_path`` → adjusted to new folder
- ``DownloadQueueItem.file_destination`` → adjusted to new folder
Args:
old_folder: Previous folder name.
new_folder: New folder name.
anime_dir: Root anime directory path.
"""
old_series_path = anime_dir / old_folder
new_series_path = anime_dir / new_folder
async with get_db_session() as db:
# 1. Update AnimeSeries.folder
series = await AnimeSeriesService.get_by_key(db, old_folder)
if series is None:
# Fallback: try to find by folder name
all_series = await AnimeSeriesService.get_all(db)
for s in all_series:
if s.folder == old_folder:
series = s
break
if series is None:
logger.warning(
"No database record found for folder '%s', skipping DB update",
old_folder,
)
return
await AnimeSeriesService.update(db, series.id, folder=new_folder)
logger.info(
"Updated AnimeSeries.folder: %s%s (id=%s)",
old_folder,
new_folder,
series.id,
)
# 2. Update Episode.file_path for all episodes of this series
episodes = await EpisodeService.get_by_series(db, series.id)
for episode in episodes:
if episode.file_path:
old_file_path = Path(episode.file_path)
# Only update if the path is under the old series folder
try:
old_file_path.relative_to(old_series_path)
new_file_path = new_series_path / old_file_path.relative_to(
old_series_path
)
episode.file_path = str(new_file_path)
logger.debug(
"Updated Episode.file_path: %s%s",
old_file_path,
new_file_path,
)
except ValueError:
# Path is not under old_series_path, skip
pass
await db.flush()
# 3. Update DownloadQueueItem.file_destination for pending items
queue_items = await DownloadQueueService.get_all(db, with_series=True)
for item in queue_items:
if item.series_id == series.id and item.file_destination:
old_dest = Path(item.file_destination)
try:
old_dest.relative_to(old_series_path)
new_dest = new_series_path / old_dest.relative_to(
old_series_path
)
item.file_destination = str(new_dest)
logger.debug(
"Updated DownloadQueueItem.file_destination: %s%s",
old_dest,
new_dest,
)
except ValueError:
pass
await db.flush()
logger.info(
"Database paths updated for series '%s''%s'",
old_folder,
new_folder,
)
async def validate_and_rename_series_folders() -> Dict[str, int]:
"""Validate and rename series folders to match NFO metadata.
Iterates over every subfolder in ``settings.anime_directory`` that
contains a ``tvshow.nfo``. For each folder:
1. Parse the NFO to extract ``<title>`` and ``<year>``.
2. Compute the expected folder name: ``f"{title} ({year})"``.
3. Sanitise the expected name for filesystem safety.
4. Compare with the current folder name.
5. If different, rename the folder and update the database.
Skips folders where title or year is missing/empty. Logs every
rename action.
Returns:
Dictionary with counts:
- ``"scanned"``: total folders scanned
- ``"renamed"``: folders renamed
- ``"skipped"``: folders skipped (missing title/year)
- ``"errors"``: folders that caused an error
"""
if not settings.anime_directory:
logger.warning("Folder rename skipped — anime directory not configured")
return {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}
anime_dir = Path(settings.anime_directory)
if not anime_dir.is_dir():
logger.warning(
"Folder rename skipped — anime directory not found: %s", anime_dir
)
return {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}
stats = {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 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
stats["scanned"] += 1
title, year = _parse_nfo_title_and_year(nfo_path)
if not title or not year:
logger.info(
"Skipping rename for '%s' — missing title or year in NFO",
series_dir.name,
)
stats["skipped"] += 1
continue
expected_name = _compute_expected_folder_name(title, year)
current_name = series_dir.name
if expected_name == current_name:
logger.debug(
"Folder name already correct: '%s'", current_name
)
continue
# Check for active downloads
if _is_series_being_downloaded(current_name):
logger.info(
"Skipping rename for '%s' — series has active or pending downloads",
current_name,
)
stats["skipped"] += 1
continue
expected_path = anime_dir / expected_name
# Check for duplicate target
if expected_path.exists():
logger.warning(
"Cannot rename '%s''%s' — target already exists",
current_name,
expected_name,
)
stats["errors"] += 1
continue
# Check path length limits
if len(str(expected_path)) > 4096:
logger.warning(
"Cannot rename '%s''%s' — path exceeds OS limit",
current_name,
expected_name,
)
stats["errors"] += 1
continue
try:
series_dir.rename(expected_path)
logger.info(
"Renamed folder: '%s''%s'", current_name, expected_name
)
stats["renamed"] += 1
# Update database records
await _update_database_paths(current_name, expected_name, anime_dir)
except PermissionError as exc:
logger.error(
"Permission denied renaming '%s''%s': %s",
current_name,
expected_name,
exc,
)
stats["errors"] += 1
except OSError as exc:
logger.error(
"OS error renaming '%s''%s': %s",
current_name,
expected_name,
exc,
)
stats["errors"] += 1
logger.info(
"Folder rename scan complete: scanned=%d, renamed=%d, skipped=%d, errors=%d",
stats["scanned"],
stats["renamed"],
stats["skipped"],
stats["errors"],
)
return stats

View File

@@ -0,0 +1,377 @@
"""Folder scan service for daily maintenance tasks.
Encapsulates the daily folder-scan logic (orphaned-file detection,
metadata refresh, and missing-episode queuing) so that the scheduler
remains clean and the scan can be tested independently.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
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
logger = structlog.get_logger(__name__)
# Module-level semaphore to limit concurrent TMDB operations to 3.
_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."""
class FolderScanService:
"""Performs daily maintenance scans over the anime library folder.
The service is intentionally stateless; a new instance can be created
for every scheduled invocation or test case.
"""
async def run_folder_scan(self) -> None:
"""Execute the daily folder scan.
Checks prerequisites, logs progress, and delegates to sub-task
helpers. Any unhandled exception is caught and logged so the
scheduler task never crashes.
"""
logger.info("Folder scan started")
try:
if not self._prerequisites_met():
return
# 1.3 — Repair incomplete NFO files in the background.
logger.info("Starting NFO repair scan as part of folder scan")
await perform_nfo_repair_scan(background_loader=None)
logger.info("NFO repair scan queued; repairs will continue in background")
# 1.4 — Validate and rename series folders after NFO repair.
logger.info("Starting folder rename validation")
from src.server.services.folder_rename_service import (
validate_and_rename_series_folders,
)
rename_stats = await validate_and_rename_series_folders()
logger.info(
"Folder rename validation complete",
scanned=rename_stats["scanned"],
renamed=rename_stats["renamed"],
skipped=rename_stats["skipped"],
errors=rename_stats["errors"],
)
# 1.5 — Check and download missing poster.jpg files.
logger.info("Starting poster check")
poster_stats = await self.check_and_download_missing_posters()
logger.info(
"Poster check complete",
scanned=poster_stats["scanned"],
downloaded=poster_stats["downloaded"],
skipped=poster_stats["skipped"],
errors=poster_stats["errors"],
)
logger.info("Folder scan completed")
except Exception as exc: # pylint: disable=broad-exception-caught
logger.error("Folder scan failed", error=str(exc), exc_info=True)
# ------------------------------------------------------------------
# Poster check helpers
# ------------------------------------------------------------------
async def check_and_download_missing_posters(self) -> dict[str, int]:
"""Iterate over series folders and download missing poster.jpg files.
For each folder containing a ``tvshow.nfo``:
1. Check if ``poster.jpg`` exists and is at least
:attr:`ImageDownloader.min_file_size` bytes.
2. If missing or too small, parse ``tvshow.nfo`` for a ``<thumb>``
URL (preferring ``aspect="poster"``).
3. Download the image via :class:`ImageDownloader` under a
semaphore that limits concurrency to 3.
Returns:
Dictionary with counts:
- ``"scanned"``: total folders scanned
- ``"downloaded"``: posters successfully downloaded
- ``"skipped"``: folders skipped (no NFO, no thumb URL,
or poster already valid)
- ``"errors"``: folders that caused a download error
"""
from src.config.settings import settings # noqa: PLC0415
stats = {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
if not settings.anime_directory:
logger.warning("Poster check skipped — anime directory not configured")
return stats
anime_dir = Path(settings.anime_directory)
if not anime_dir.is_dir():
logger.warning(
"Poster check skipped — anime directory not found: %s", anime_dir
)
return stats
# Gather all series directories that contain a tvshow.nfo
series_dirs = [
d for d in anime_dir.iterdir()
if d.is_dir() and (d / "tvshow.nfo").exists()
]
if not series_dirs:
logger.debug("No series folders found for poster check")
return stats
# Process each series folder concurrently with semaphore
tasks = [
self._check_and_download_poster(series_dir, stats)
for series_dir in series_dirs
]
await asyncio.gather(*tasks, return_exceptions=True)
return stats
async def _check_and_download_poster(
self, series_dir: Path, stats: dict[str, int]
) -> None:
"""Check and download poster for a single series folder.
Args:
series_dir: Path to the series folder.
stats: Mutable stats dictionary to update.
"""
stats["scanned"] += 1
poster_path = series_dir / "poster.jpg"
# Check if poster already exists and is large enough
if poster_path.exists():
try:
# Default min_file_size from ImageDownloader is 1024 bytes (1 KB)
if poster_path.stat().st_size >= 1024:
logger.debug(
"Poster already valid for '%s'", series_dir.name
)
stats["skipped"] += 1
return
except OSError:
pass # Fall through to re-download
# Parse NFO for thumb URL
nfo_path = series_dir / "tvshow.nfo"
poster_url = self._extract_poster_url_from_nfo(nfo_path)
if not poster_url:
logger.info(
"No poster URL found in NFO for '%s', skipping",
series_dir.name,
)
stats["skipped"] += 1
return
# Respect the nfo_download_poster setting
from src.config.settings import settings as app_settings # noqa: PLC0415
if not app_settings.nfo_download_poster:
logger.debug(
"Poster download disabled by nfo_download_poster setting for '%s'",
series_dir.name,
)
stats["skipped"] += 1
return
# Download poster with semaphore
async with _POSTER_DOWNLOAD_SEMAPHORE:
try:
async with ImageDownloader() as downloader:
success = await downloader.download_poster(
poster_url, series_dir, skip_existing=False
)
if success:
logger.info(
"Downloaded poster for '%s'", series_dir.name
)
stats["downloaded"] += 1
else:
logger.warning(
"Failed to download poster for '%s'", series_dir.name
)
stats["errors"] += 1
except Exception as exc: # pylint: disable=broad-except
logger.error(
"Error downloading poster for '%s': %s",
series_dir.name,
exc,
)
stats["errors"] += 1
@staticmethod
def _extract_poster_url_from_nfo(nfo_path: Path) -> Optional[str]:
"""Parse tvshow.nfo and extract the poster thumb URL.
Prefers ``<thumb aspect="poster">``; falls back to the first
``<thumb>`` element if no aspect attribute is present.
Args:
nfo_path: Absolute path to the ``tvshow.nfo`` file.
Returns:
The poster URL string, or ``None`` if not found.
"""
if not nfo_path.exists():
return None
try:
tree = etree.parse(str(nfo_path))
root = tree.getroot()
# Prefer thumb with aspect="poster"
for thumb in root.findall(".//thumb"):
if thumb.get("aspect") == "poster" and thumb.text:
return thumb.text.strip()
# Fallback to first thumb with text
for thumb in root.findall(".//thumb"):
if thumb.text:
return thumb.text.strip()
return None
except etree.XMLSyntaxError:
logger.warning("Malformed XML in %s", nfo_path)
return None
except Exception: # pylint: disable=broad-except
return None
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def _prerequisites_met(self) -> bool:
"""Verify that the environment is ready for a folder scan.
Returns:
True when ``settings.anime_directory`` exists and
``settings.tmdb_api_key`` is configured.
"""
from src.config.settings import settings # noqa: PLC0415
if not settings.tmdb_api_key:
logger.warning("Folder scan skipped — TMDB API key not configured")
return False
if not settings.anime_directory:
logger.warning("Folder scan skipped — anime directory not configured")
return False
anime_dir = Path(settings.anime_directory)
if not anime_dir.is_dir():
logger.warning(
"Folder scan skipped — anime directory not found: %s", anime_dir
)
return False
return True

View File

@@ -342,7 +342,7 @@ async def perform_nfo_scan_if_needed(progress_service=None):
if not settings.tmdb_api_key if not settings.tmdb_api_key
else "Skipped - NFO features disabled" else "Skipped - NFO features disabled"
) )
logger.info(f"NFO scan skipped: {message}") logger.info("NFO scan skipped: %s", message)
if progress_service: if progress_service:
await progress_service.complete_progress( await progress_service.complete_progress(
@@ -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.
Runs on every application startup (not guarded by a run-once DB flag).
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: async def _check_media_scan_status() -> bool:
"""Check if initial media scan has been completed. """Check if initial media scan has been completed.

View File

@@ -151,7 +151,7 @@ class EmailNotificationService:
start_tls=True, start_tls=True,
) )
logger.info(f"Email notification sent to {to_address}") logger.info("Email notification sent to %s", to_address)
return True return True
except ImportError: except ImportError:
@@ -160,7 +160,7 @@ class EmailNotificationService:
) )
return False return False
except Exception as e: except Exception as e:
logger.error(f"Failed to send email notification: {e}") logger.error("Failed to send email notification: %s", e)
return False return False
@@ -205,7 +205,7 @@ class WebhookNotificationService:
timeout=aiohttp.ClientTimeout(total=self.timeout), timeout=aiohttp.ClientTimeout(total=self.timeout),
) as response: ) as response:
if response.status < 400: if response.status < 400:
logger.info(f"Webhook notification sent to {url}") logger.info("Webhook notification sent to %s", url)
return True return True
else: else:
logger.warning( logger.warning(
@@ -213,9 +213,9 @@ class WebhookNotificationService:
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.warning(f"Webhook timeout (attempt {attempt + 1}/{self.max_retries}): {url}") logger.warning("Webhook timeout (attempt %s/%s): %s", attempt + 1, self.max_retries, url)
except Exception as e: except Exception as e:
logger.error(f"Failed to send webhook (attempt {attempt + 1}/{self.max_retries}): {e}") logger.error("Failed to send webhook (attempt %s/%s): %s", attempt + 1, self.max_retries, e)
if attempt < self.max_retries - 1: if attempt < self.max_retries - 1:
await asyncio.sleep(2 ** attempt) # Exponential backoff await asyncio.sleep(2 ** attempt) # Exponential backoff
@@ -436,7 +436,7 @@ class NotificationService:
await self.in_app_service.add_notification(notification) await self.in_app_service.add_notification(notification)
results["in_app"] = True results["in_app"] = True
except Exception as e: except Exception as e:
logger.error(f"Failed to send in-app notification: {e}") logger.error("Failed to send in-app notification: %s", e)
results["in_app"] = False results["in_app"] = False
# Send email notification # Send email notification
@@ -452,7 +452,7 @@ class NotificationService:
) )
results["email"] = success results["email"] = success
except Exception as e: except Exception as e:
logger.error(f"Failed to send email notification: {e}") logger.error("Failed to send email notification: %s", e)
results["email"] = False results["email"] = False
# Send webhook notifications # Send webhook notifications
@@ -476,7 +476,7 @@ class NotificationService:
success = await self.webhook_service.send_webhook(str(url), payload) success = await self.webhook_service.send_webhook(str(url), payload)
webhook_results.append(success) webhook_results.append(success)
except Exception as e: except Exception as e:
logger.error(f"Failed to send webhook notification to {url}: {e}") logger.error("Failed to send webhook notification to %s: %s", url, e)
webhook_results.append(False) webhook_results.append(False)
results["webhook"] = all(webhook_results) if webhook_results else False results["webhook"] = all(webhook_results) if webhook_results else False

View File

@@ -145,6 +145,7 @@ class SchedulerService:
schedule_time=config.schedule_time, schedule_time=config.schedule_time,
schedule_days=config.schedule_days, schedule_days=config.schedule_days,
auto_download=config.auto_download_after_rescan, auto_download=config.auto_download_after_rescan,
folder_scan=config.folder_scan_enabled,
) )
if not self._scheduler or not self._scheduler.running: if not self._scheduler or not self._scheduler.running:
@@ -204,6 +205,9 @@ class SchedulerService:
"auto_download_after_rescan": ( "auto_download_after_rescan": (
self._config.auto_download_after_rescan if self._config else False self._config.auto_download_after_rescan if self._config else False
), ),
"folder_scan_enabled": (
self._config.folder_scan_enabled if self._config else False
),
"last_run": self._last_scan_time.isoformat() if self._last_scan_time else None, "last_run": self._last_scan_time.isoformat() if self._last_scan_time else None,
"next_run": next_run, "next_run": next_run,
"scan_in_progress": self._scan_in_progress, "scan_in_progress": self._scan_in_progress,
@@ -352,6 +356,28 @@ class SchedulerService:
else: else:
logger.debug("Auto-download after rescan is disabled — skipping") logger.debug("Auto-download after rescan is disabled — skipping")
# Folder scan (daily maintenance)
if self._config and self._config.folder_scan_enabled:
logger.info("Folder scan is enabled — starting")
try:
from src.server.services.folder_scan_service import ( # noqa: PLC0415
FolderScanService,
)
folder_scan_service = FolderScanService()
await folder_scan_service.run_folder_scan()
except Exception as fs_exc: # pylint: disable=broad-exception-caught
logger.error(
"Folder scan failed",
error=str(fs_exc),
exc_info=True,
)
await self._broadcast(
"folder_scan_error", {"error": str(fs_exc)}
)
else:
logger.debug("Folder scan is disabled — skipping")
except Exception as exc: # pylint: disable=broad-exception-caught except Exception as exc: # pylint: disable=broad-exception-caught
logger.error("Scheduled rescan failed", error=str(exc), exc_info=True) logger.error("Scheduled rescan failed", error=str(exc), exc_info=True)
await self._broadcast( await self._broadcast(

View File

@@ -57,6 +57,44 @@ _rate_limit_lock = Lock()
_RATE_LIMIT_WINDOW_SECONDS = 60.0 _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: def get_series_app() -> SeriesApp:
""" """
Dependency to get SeriesApp instance. 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: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,

View File

@@ -82,7 +82,7 @@ class LogManager:
log_path = self.log_dir / log_file log_path = self.log_dir / log_file
if not log_path.exists(): if not log_path.exists():
logger.warning(f"Log file not found: {log_file}") logger.warning("Log file not found: %s", log_file)
return False return False
stat = log_path.stat() stat = log_path.stat()
@@ -99,10 +99,10 @@ class LogManager:
# Compress the rotated file # Compress the rotated file
self._compress_log(rotated_path) self._compress_log(rotated_path)
logger.info(f"Rotated log file: {log_file} -> {rotated_name}") logger.info("Rotated log file: %s -> %s", log_file, rotated_name)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to rotate log file {log_file}: {e}") logger.error("Failed to rotate log file %s: %s", log_file, e)
return False return False
def _compress_log(self, log_path: Path) -> bool: def _compress_log(self, log_path: Path) -> bool:
@@ -122,10 +122,10 @@ class LogManager:
shutil.copyfileobj(f_in, f_out) shutil.copyfileobj(f_in, f_out)
log_path.unlink() log_path.unlink()
logger.debug(f"Compressed log file: {log_path.name}") logger.debug("Compressed log file: %s", log_path.name)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to compress log {log_path}: {e}") logger.error("Failed to compress log %s: %s", log_path, e)
return False return False
def archive_old_logs( def archive_old_logs(
@@ -160,10 +160,10 @@ class LogManager:
f"Failed to archive {log_file.filename}: {e}" f"Failed to archive {log_file.filename}: {e}"
) )
logger.info(f"Archived {archived_count} old log files") logger.info("Archived %s old log files", archived_count)
return archived_count return archived_count
except Exception as e: except Exception as e:
logger.error(f"Failed to archive logs: {e}") logger.error("Failed to archive logs: %s", e)
return 0 return 0
def search_logs( def search_logs(
@@ -209,7 +209,7 @@ class LogManager:
) )
return results return results
except Exception as e: except Exception as e:
logger.error(f"Failed to search logs: {e}") logger.error("Failed to search logs: %s", e)
return {} return {}
def export_logs( def export_logs(
@@ -243,7 +243,7 @@ class LogManager:
arcname=log_file.filename, arcname=log_file.filename,
) )
logger.info(f"Exported logs to: {tar_path}") logger.info("Exported logs to: %s", tar_path)
return True return True
else: else:
# Concatenate all logs # Concatenate all logs
@@ -253,10 +253,10 @@ class LogManager:
with open(log_file.path, "r") as in_f: with open(log_file.path, "r") as in_f:
out_f.write(in_f.read()) out_f.write(in_f.read())
logger.info(f"Exported logs to: {output_path}") logger.info("Exported logs to: %s", output_path)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to export logs: {e}") logger.error("Failed to export logs: %s", e)
return False return False
def get_log_stats(self) -> Dict[str, Any]: def get_log_stats(self) -> Dict[str, Any]:
@@ -294,7 +294,7 @@ class LogManager:
"newest_file": log_files[0].filename, "newest_file": log_files[0].filename,
} }
except Exception as e: except Exception as e:
logger.error(f"Failed to get log stats: {e}") logger.error("Failed to get log stats: %s", e)
return {} return {}
def cleanup_logs( def cleanup_logs(
@@ -330,16 +330,16 @@ class LogManager:
log_file.path.unlink() log_file.path.unlink()
total_size -= log_file.size_bytes total_size -= log_file.size_bytes
deleted_count += 1 deleted_count += 1
logger.debug(f"Deleted log file: {log_file.filename}") logger.debug("Deleted log file: %s", log_file.filename)
except Exception as e: except Exception as e:
logger.warning( logger.warning(
f"Failed to delete {log_file.filename}: {e}" f"Failed to delete {log_file.filename}: {e}"
) )
logger.info(f"Cleaned up {deleted_count} log files") logger.info("Cleaned up %s log files", deleted_count)
return deleted_count return deleted_count
except Exception as e: except Exception as e:
logger.error(f"Failed to cleanup logs: {e}") logger.error("Failed to cleanup logs: %s", e)
return 0 return 0
def set_log_level(self, logger_name: str, level: str) -> bool: def set_log_level(self, logger_name: str, level: str) -> bool:
@@ -357,10 +357,10 @@ class LogManager:
target_logger = logging.getLogger(logger_name) target_logger = logging.getLogger(logger_name)
target_logger.setLevel(log_level) target_logger.setLevel(log_level)
logger.info(f"Set {logger_name} log level to {level}") logger.info("Set %s log level to %s", logger_name, level)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to set log level: {e}") logger.error("Failed to set log level: %s", e)
return False return False

View File

@@ -416,9 +416,9 @@ def cleanup_old_logs(log_dir: Union[str, Path],
try: try:
if log_file.stat().st_mtime < cutoff_time: if log_file.stat().st_mtime < cutoff_time:
log_file.unlink() log_file.unlink()
logger.info(f"Deleted old log file: {log_file}") logger.info("Deleted old log file: %s", log_file)
except Exception as e: except Exception as e:
logger.error(f"Failed to delete log file {log_file}: {e}") logger.error("Failed to delete log file %s: %s", log_file, e)
# Initialize default logging configuration # Initialize default logging configuration

View File

@@ -161,7 +161,7 @@ class MetricsCollector:
Duration in seconds. Duration in seconds.
""" """
if timer_name not in self._timers: if timer_name not in self._timers:
logger.warning(f"Timer {timer_name} not started") logger.warning("Timer %s not started", timer_name)
return 0.0 return 0.0
duration = time.time() - self._timers[timer_name] duration = time.time() - self._timers[timer_name]

View File

@@ -60,7 +60,7 @@ class SystemUtilities:
path=path, path=path,
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to get disk usage for {path}: {e}") logger.error("Failed to get disk usage for %s: %s", path, e)
return None return None
@staticmethod @staticmethod
@@ -93,7 +93,7 @@ class SystemUtilities:
return disk_infos return disk_infos
except Exception as e: except Exception as e:
logger.error(f"Failed to get all disk usage: {e}") logger.error("Failed to get all disk usage: %s", e)
return [] return []
@staticmethod @staticmethod
@@ -115,7 +115,7 @@ class SystemUtilities:
path = Path(directory) path = Path(directory)
if not path.exists(): if not path.exists():
logger.warning(f"Directory not found: {directory}") logger.warning("Directory not found: %s", directory)
return 0 return 0
deleted_count = 0 deleted_count = 0
@@ -130,16 +130,16 @@ class SystemUtilities:
try: try:
file_path.unlink() file_path.unlink()
deleted_count += 1 deleted_count += 1
logger.debug(f"Deleted file: {file_path}") logger.debug("Deleted file: %s", file_path)
except Exception as e: except Exception as e:
logger.warning( logger.warning(
f"Failed to delete {file_path}: {e}" f"Failed to delete {file_path}: {e}"
) )
logger.info(f"Cleaned up {deleted_count} files from {directory}") logger.info("Cleaned up %s files from %s", deleted_count, directory)
return deleted_count return deleted_count
except Exception as e: except Exception as e:
logger.error(f"Failed to cleanup directory {directory}: {e}") logger.error("Failed to cleanup directory %s: %s", directory, e)
return 0 return 0
@staticmethod @staticmethod
@@ -171,12 +171,12 @@ class SystemUtilities:
f"Deleted empty directory: {dir_path}" f"Deleted empty directory: {dir_path}"
) )
except Exception as e: except Exception as e:
logger.debug(f"Cannot delete {dir_path}: {e}") logger.debug("Cannot delete %s: %s", dir_path, e)
logger.info(f"Cleaned up {deleted_count} empty directories") logger.info("Cleaned up %s empty directories", deleted_count)
return deleted_count return deleted_count
except Exception as e: except Exception as e:
logger.error(f"Failed to cleanup empty directories: {e}") logger.error("Failed to cleanup empty directories: %s", e)
return 0 return 0
@staticmethod @staticmethod
@@ -201,7 +201,7 @@ class SystemUtilities:
return total_size return total_size
except Exception as e: except Exception as e:
logger.error(f"Failed to get directory size for {directory}: {e}") logger.error("Failed to get directory size for %s: %s", directory, e)
return 0 return 0
@staticmethod @staticmethod
@@ -232,7 +232,7 @@ class SystemUtilities:
), ),
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to get process info for {pid}: {e}") logger.error("Failed to get process info for %s: %s", pid, e)
return None return None
@staticmethod @staticmethod
@@ -260,7 +260,7 @@ class SystemUtilities:
return processes return processes
except Exception as e: except Exception as e:
logger.error(f"Failed to get all processes: {e}") logger.error("Failed to get all processes: %s", e)
return [] return []
@staticmethod @staticmethod
@@ -285,7 +285,7 @@ class SystemUtilities:
"python_version": platform.python_version(), "python_version": platform.python_version(),
} }
except Exception as e: except Exception as e:
logger.error(f"Failed to get system info: {e}") logger.error("Failed to get system info: %s", e)
return {} return {}
@staticmethod @staticmethod
@@ -308,7 +308,7 @@ class SystemUtilities:
"dropped_out": net_io.dropout, "dropped_out": net_io.dropout,
} }
except Exception as e: except Exception as e:
logger.error(f"Failed to get network info: {e}") logger.error("Failed to get network info: %s", e)
return {} return {}
@staticmethod @staticmethod
@@ -330,7 +330,7 @@ class SystemUtilities:
dest_path = Path(dest) dest_path = Path(dest)
if not src_path.exists(): if not src_path.exists():
logger.error(f"Source file not found: {src}") logger.error("Source file not found: %s", src)
return False return False
# Create temporary file # Create temporary file
@@ -342,10 +342,10 @@ class SystemUtilities:
# Atomic rename # Atomic rename
temp_path.replace(dest_path) temp_path.replace(dest_path)
logger.debug(f"Atomically copied {src} to {dest}") logger.debug("Atomically copied %s to %s", src, dest)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to copy file {src} to {dest}: {e}") logger.error("Failed to copy file %s to %s: %s", src, dest, e)
return False return False

View File

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

View File

@@ -1561,6 +1561,8 @@ class AniWorldApp {
document.getElementById('scheduled-rescan-enabled').checked = !!config.enabled; document.getElementById('scheduled-rescan-enabled').checked = !!config.enabled;
document.getElementById('scheduled-rescan-time').value = config.schedule_time || '03:00'; document.getElementById('scheduled-rescan-time').value = config.schedule_time || '03:00';
document.getElementById('auto-download-after-rescan').checked = !!config.auto_download_after_rescan; document.getElementById('auto-download-after-rescan').checked = !!config.auto_download_after_rescan;
const folderScanEl = document.getElementById('folder-scan-enabled');
if (folderScanEl) folderScanEl.checked = !!config.folder_scan_enabled;
// Update day-of-week checkboxes // Update day-of-week checkboxes
const days = Array.isArray(config.schedule_days) ? config.schedule_days : ['mon','tue','wed','thu','fri','sat','sun']; const days = Array.isArray(config.schedule_days) ? config.schedule_days : ['mon','tue','wed','thu','fri','sat','sun'];
@@ -1603,6 +1605,8 @@ class AniWorldApp {
const enabled = document.getElementById('scheduled-rescan-enabled').checked; const enabled = document.getElementById('scheduled-rescan-enabled').checked;
const scheduleTime = document.getElementById('scheduled-rescan-time').value || '03:00'; const scheduleTime = document.getElementById('scheduled-rescan-time').value || '03:00';
const autoDownload = document.getElementById('auto-download-after-rescan').checked; const autoDownload = document.getElementById('auto-download-after-rescan').checked;
const folderScanEl = document.getElementById('folder-scan-enabled');
const folderScan = folderScanEl ? folderScanEl.checked : false;
// Collect checked day-of-week values // Collect checked day-of-week values
const scheduleDays = ['mon','tue','wed','thu','fri','sat','sun'] const scheduleDays = ['mon','tue','wed','thu','fri','sat','sun']
@@ -1618,7 +1622,8 @@ class AniWorldApp {
enabled: enabled, enabled: enabled,
schedule_time: scheduleTime, schedule_time: scheduleTime,
schedule_days: scheduleDays, schedule_days: scheduleDays,
auto_download_after_rescan: autoDownload auto_download_after_rescan: autoDownload,
folder_scan_enabled: folderScan
}) })
}); });

View File

@@ -35,6 +35,11 @@ AniWorld.SchedulerConfig = (function() {
autoDownload.checked = config.auto_download_after_rescan || false; autoDownload.checked = config.auto_download_after_rescan || false;
} }
const folderScan = document.getElementById('folder-scan-enabled');
if (folderScan) {
folderScan.checked = config.folder_scan_enabled || false;
}
// Update schedule day checkboxes // Update schedule day checkboxes
const days = config.schedule_days || ['mon','tue','wed','thu','fri','sat','sun']; const days = config.schedule_days || ['mon','tue','wed','thu','fri','sat','sun'];
['mon','tue','wed','thu','fri','sat','sun'].forEach(function(day) { ['mon','tue','wed','thu','fri','sat','sun'].forEach(function(day) {
@@ -82,12 +87,16 @@ AniWorld.SchedulerConfig = (function() {
const autoDownloadEl = document.getElementById('auto-download-after-rescan'); const autoDownloadEl = document.getElementById('auto-download-after-rescan');
const autoDownload = autoDownloadEl ? autoDownloadEl.checked : false; const autoDownload = autoDownloadEl ? autoDownloadEl.checked : false;
const folderScanEl = document.getElementById('folder-scan-enabled');
const folderScan = folderScanEl ? folderScanEl.checked : false;
// POST directly to the scheduler config endpoint // POST directly to the scheduler config endpoint
const payload = { const payload = {
enabled: enabled, enabled: enabled,
schedule_time: scheduleTime, schedule_time: scheduleTime,
schedule_days: scheduleDays, schedule_days: scheduleDays,
auto_download_after_rescan: autoDownload auto_download_after_rescan: autoDownload,
folder_scan_enabled: folderScan
}; };
const response = await AniWorld.ApiClient.post(API.SCHEDULER_CONFIG, payload); const response = await AniWorld.ApiClient.post(API.SCHEDULER_CONFIG, payload);

View File

@@ -16,7 +16,7 @@ AniWorld.SeriesManager = (function() {
// State // State
let seriesData = []; let seriesData = [];
let filteredSeriesData = []; let filteredSeriesData = [];
let showMissingOnly = false; let filterMode = 'all'; // 'all' | 'missing_episodes' | 'no_episodes'
let sortAlphabetical = false; let sortAlphabetical = false;
/** /**
@@ -24,15 +24,16 @@ AniWorld.SeriesManager = (function() {
*/ */
function init() { function init() {
bindEvents(); bindEvents();
updateFilterButtonUI();
} }
/** /**
* Bind UI events for filtering and sorting * Bind UI events for filtering and sorting
*/ */
function bindEvents() { function bindEvents() {
const missingOnlyBtn = document.getElementById('show-missing-only'); const filterBtn = document.getElementById('show-missing-only');
if (missingOnlyBtn) { if (filterBtn) {
missingOnlyBtn.addEventListener('click', toggleMissingOnlyFilter); filterBtn.addEventListener('click', toggleFilterMode);
} }
const sortBtn = document.getElementById('sort-alphabetical'); const sortBtn = document.getElementById('sort-alphabetical');
@@ -49,7 +50,10 @@ AniWorld.SeriesManager = (function() {
try { try {
AniWorld.UI.showLoading(); AniWorld.UI.showLoading();
const response = await AniWorld.ApiClient.get(API.ANIME_LIST); const url = filterMode && filterMode !== 'all'
? `${API.ANIME_LIST}?filter=${encodeURIComponent(filterMode)}`
: API.ANIME_LIST;
const response = await AniWorld.ApiClient.get(url);
if (!response) { if (!response) {
return []; return [];
@@ -111,28 +115,28 @@ AniWorld.SeriesManager = (function() {
} }
/** /**
* Toggle missing episodes only filter * Cycle through filter modes:
* - all: Show all series
* - missing_episodes: Show only series with missing episodes
* - no_episodes: Show only series with zero downloaded episodes
*/ */
function toggleMissingOnlyFilter() { async function toggleFilterMode() {
showMissingOnly = !showMissingOnly;
const button = document.getElementById('show-missing-only'); const button = document.getElementById('show-missing-only');
button.setAttribute('data-active', showMissingOnly);
button.classList.toggle('active', showMissingOnly);
const icon = button.querySelector('i'); const icon = button.querySelector('i');
const text = button.querySelector('span'); const text = button.querySelector('span');
if (showMissingOnly) { // Cycle through modes
icon.className = 'fas fa-filter-circle-xmark'; if (filterMode === 'all') {
text.textContent = 'Show All Series'; filterMode = 'missing_episodes';
} else if (filterMode === 'missing_episodes') {
filterMode = 'no_episodes';
} else { } else {
icon.className = 'fas fa-filter'; filterMode = 'all';
text.textContent = 'Missing Episodes Only';
} }
applyFiltersAndSort(); // Update button UI and reload list based on new filter.
renderSeries(); updateFilterButtonUI();
await loadSeries();
// Clear selection when filter changes // Clear selection when filter changes
if (AniWorld.SelectionManager) { if (AniWorld.SelectionManager) {
@@ -140,6 +144,34 @@ AniWorld.SeriesManager = (function() {
} }
} }
/**
* Update the filter button UI to reflect current filter mode
*/
function updateFilterButtonUI() {
const button = document.getElementById('show-missing-only');
if (!button) {
return;
}
const icon = button.querySelector('i');
const text = button.querySelector('span');
const isActive = filterMode !== 'all';
button.setAttribute('data-active', isActive);
button.classList.toggle('active', isActive);
if (filterMode === 'missing_episodes') {
icon.className = 'fas fa-filter';
text.textContent = 'Missing Episodes Only';
} else if (filterMode === 'no_episodes') {
icon.className = 'fas fa-ban';
text.textContent = 'No Episodes';
} else {
icon.className = 'fas fa-filter-circle-xmark';
text.textContent = 'Show All Series';
}
}
/** /**
* Toggle alphabetical sorting * Toggle alphabetical sorting
*/ */
@@ -193,13 +225,6 @@ AniWorld.SeriesManager = (function() {
} }
}); });
// Apply missing episodes filter
if (showMissingOnly) {
filtered = filtered.filter(function(serie) {
return serie.missing_episodes > 0;
});
}
filteredSeriesData = filtered; filteredSeriesData = filtered;
} }
@@ -212,9 +237,14 @@ AniWorld.SeriesManager = (function() {
(seriesData.length > 0 ? seriesData : []); (seriesData.length > 0 ? seriesData : []);
if (dataToRender.length === 0) { if (dataToRender.length === 0) {
const message = showMissingOnly ? let message;
'No series with missing episodes found.' : if (filterMode === 'missing_episodes') {
'No series found. Try searching for anime or rescanning your directory.'; message = 'No series with missing episodes found.';
} else if (filterMode === 'no_episodes') {
message = 'No series with zero downloaded episodes found.';
} else {
message = 'No series found. Try searching for anime or rescanning your directory.';
}
grid.innerHTML = grid.innerHTML =
'<div class="text-center" style="grid-column: 1 / -1; padding: 2rem;">' + '<div class="text-center" style="grid-column: 1 / -1; padding: 2rem;">' +

View File

@@ -309,6 +309,17 @@
</label> </label>
</div> </div>
<div class="config-item">
<label class="checkbox-label">
<input type="checkbox" id="folder-scan-enabled">
<span class="checkbox-custom"></span>
<span data-text="folder-scan-enabled">Run folder maintenance (NFO repair, renaming, poster checks)</span>
</label>
<small class="config-hint" data-text="folder-scan-hint">
Automatically repair NFOs, rename folders, and check posters during scheduled runs.
</small>
</div>
<div class="config-item scheduler-status" id="scheduler-status"> <div class="config-item scheduler-status" id="scheduler-status">
<div class="scheduler-info"> <div class="scheduler-info">

View File

@@ -479,6 +479,13 @@
<span>Auto-download missing episodes after rescan</span> <span>Auto-download missing episodes after rescan</span>
</label> </label>
</div> </div>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" id="scheduler_folder_scan" name="scheduler_folder_scan">
<span>Run folder maintenance (NFO repair, renaming, poster checks)</span>
</label>
<div class="form-help">Automatically repair NFOs, rename folders, and check posters during scheduled runs</div>
</div>
</div> </div>
</div> </div>
@@ -761,6 +768,7 @@
scheduler_schedule_time: document.getElementById('scheduler_schedule_time').value || '03:00', scheduler_schedule_time: document.getElementById('scheduler_schedule_time').value || '03:00',
scheduler_schedule_days: Array.from(document.querySelectorAll('.scheduler-day-setup-cb:checked')).map(cb => cb.value), scheduler_schedule_days: Array.from(document.querySelectorAll('.scheduler-day-setup-cb:checked')).map(cb => cb.value),
scheduler_auto_download_after_rescan: document.getElementById('scheduler_auto_download').checked, scheduler_auto_download_after_rescan: document.getElementById('scheduler_auto_download').checked,
scheduler_folder_scan_enabled: document.getElementById('scheduler_folder_scan').checked,
logging_level: document.getElementById('logging_level').value, logging_level: document.getElementById('logging_level').value,
logging_file: document.getElementById('logging_file').value.trim() || null, logging_file: document.getElementById('logging_file').value.trim() || null,
logging_max_bytes: document.getElementById('logging_max_bytes').value ? logging_max_bytes: document.getElementById('logging_max_bytes').value ?

View File

@@ -334,6 +334,25 @@ async def test_add_series_sanitizes_folder_name(authenticated_client):
assert "?" not in folder assert "?" not in folder
@pytest.mark.asyncio
async def test_add_series_does_not_duplicate_year(authenticated_client):
"""Test that add_series doesn't duplicate year when name already contains it."""
response = await authenticated_client.post(
"/api/anime/add",
json={
"link": "https://aniworld.to/anime/stream/eighty-six",
"name": "86 Eighty Six (2021)"
}
)
assert response.status_code == 202
data = response.json()
# Folder should contain year only once
folder = data["folder"]
assert folder.count("(2021)") == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_series_returns_missing_episodes(authenticated_client): async def test_add_series_returns_missing_episodes(authenticated_client):
"""Test that add_series returns loading progress info.""" """Test that add_series returns loading progress info."""

View File

@@ -4,12 +4,15 @@ This test verifies that the /api/anime/add endpoint can handle
multiple concurrent requests without blocking. multiple concurrent requests without blocking.
""" """
import asyncio import asyncio
import logging
import time import time
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
logger = logging.getLogger(__name__)
from src.server.fastapi_app import app from src.server.fastapi_app import app
from src.server.services.auth_service import auth_service from src.server.services.auth_service import auth_service
from src.server.services.background_loader_service import get_background_loader_service from src.server.services.background_loader_service import get_background_loader_service
@@ -103,7 +106,7 @@ async def test_concurrent_anime_add_requests(authenticated_client):
f"indicating possible blocking issues" f"indicating possible blocking issues"
) )
print(f"3 concurrent anime add requests completed in {total_time:.2f}s") logger.info("3 concurrent anime add requests completed in %.2fs", total_time)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -130,4 +133,4 @@ async def test_same_anime_concurrent_add(authenticated_client):
keys = [r.json().get("key") for r in responses] keys = [r.json().get("key") for r in responses]
assert keys[0] == keys[1], "Both responses should have the same key" assert keys[0] == keys[1], "Both responses should have the same key"
print(f"Concurrent same-anime requests handled correctly: {statuses}") logger.info("Concurrent same-anime requests handled correctly: %s", statuses)

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

@@ -91,6 +91,13 @@ def _setup_loader_mocks(loader_service):
loader_service._broadcast_status = AsyncMock() loader_service._broadcast_status = AsyncMock()
def _mock_nfo_factory(mock_nfo_service):
"""Create a mock NFO factory that returns the given mock service."""
mock_factory = MagicMock()
mock_factory.create = MagicMock(return_value=mock_nfo_service)
return mock_factory
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_anime_loads_nfo_only_for_new_anime( async def test_add_anime_loads_nfo_only_for_new_anime(
temp_anime_dir, temp_anime_dir,
@@ -112,6 +119,12 @@ async def test_add_anime_loads_nfo_only_for_new_anime(
) )
_setup_loader_mocks(loader_service) _setup_loader_mocks(loader_service)
# Set up mock NFO service via factory
mock_nfo_service = AsyncMock()
mock_nfo_service.create_tvshow_nfo = AsyncMock(return_value="/anime/New Anime (2024)/tvshow.nfo")
mock_factory = _mock_nfo_factory(mock_nfo_service)
with patch("src.server.services.background_loader_service.get_nfo_factory", return_value=mock_factory):
await loader_service.start() await loader_service.start()
try: try:
@@ -132,9 +145,9 @@ async def test_add_anime_loads_nfo_only_for_new_anime(
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
assert mock_series_app.nfo_service.create_tvshow_nfo.call_count == 1 assert mock_nfo_service.create_tvshow_nfo.call_count == 1
call_args = mock_series_app.nfo_service.create_tvshow_nfo.call_args call_args = mock_nfo_service.create_tvshow_nfo.call_args
assert call_args is not None assert call_args is not None
kwargs = call_args.kwargs kwargs = call_args.kwargs
@@ -145,7 +158,7 @@ async def test_add_anime_loads_nfo_only_for_new_anime(
assert kwargs["download_logo"] is True assert kwargs["download_logo"] is True
assert kwargs["download_fanart"] is True assert kwargs["download_fanart"] is True
all_calls = mock_series_app.nfo_service.create_tvshow_nfo.call_args_list all_calls = mock_nfo_service.create_tvshow_nfo.call_args_list
for call_obj in all_calls: for call_obj in all_calls:
call_kwargs = call_obj.kwargs call_kwargs = call_obj.kwargs
assert call_kwargs["serie_name"] != "Existing Anime 1" assert call_kwargs["serie_name"] != "Existing Anime 1"
@@ -216,6 +229,12 @@ async def test_multiple_anime_added_each_loads_independently(
) )
_setup_loader_mocks(loader_service) _setup_loader_mocks(loader_service)
# Set up mock NFO service via factory
mock_nfo_service = AsyncMock()
mock_nfo_service.create_tvshow_nfo = AsyncMock(return_value="/anime/tvshow.nfo")
mock_factory = _mock_nfo_factory(mock_nfo_service)
with patch("src.server.services.background_loader_service.get_nfo_factory", return_value=mock_factory):
await loader_service.start() await loader_service.start()
try: try:
@@ -238,9 +257,9 @@ async def test_multiple_anime_added_each_loads_independently(
await asyncio.sleep(2.0) await asyncio.sleep(2.0)
assert mock_series_app.nfo_service.create_tvshow_nfo.call_count == 3 assert mock_nfo_service.create_tvshow_nfo.call_count == 3
all_calls = mock_series_app.nfo_service.create_tvshow_nfo.call_args_list all_calls = mock_nfo_service.create_tvshow_nfo.call_args_list
called_names = [call_obj.kwargs["serie_name"] for call_obj in all_calls] called_names = [call_obj.kwargs["serie_name"] for call_obj in all_calls]
called_folders = [call_obj.kwargs["serie_folder"] for call_obj in all_calls] called_folders = [call_obj.kwargs["serie_folder"] for call_obj in all_calls]
@@ -275,6 +294,12 @@ async def test_nfo_service_receives_correct_parameters(
) )
_setup_loader_mocks(loader_service) _setup_loader_mocks(loader_service)
# Set up mock NFO service via factory
mock_nfo_service = AsyncMock()
mock_nfo_service.create_tvshow_nfo = AsyncMock(return_value="/anime/Test Anime Series (2024)/tvshow.nfo")
mock_factory = _mock_nfo_factory(mock_nfo_service)
with patch("src.server.services.background_loader_service.get_nfo_factory", return_value=mock_factory):
await loader_service.start() await loader_service.start()
try: try:
@@ -295,9 +320,9 @@ async def test_nfo_service_receives_correct_parameters(
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
assert mock_series_app.nfo_service.create_tvshow_nfo.call_count == 1 assert mock_nfo_service.create_tvshow_nfo.call_count == 1
call_kwargs = mock_series_app.nfo_service.create_tvshow_nfo.call_args.kwargs call_kwargs = mock_nfo_service.create_tvshow_nfo.call_args.kwargs
assert call_kwargs["serie_name"] == test_name assert call_kwargs["serie_name"] == test_name
assert call_kwargs["serie_folder"] == test_folder assert call_kwargs["serie_folder"] == test_folder

View File

@@ -0,0 +1,109 @@
"""Integration tests for folder rename service wiring.
These tests verify that:
1. FolderScanService.run_folder_scan calls validate_and_rename_series_folders.
2. The rename logic is properly integrated into the scheduled folder scan.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
class TestFolderRenameScanCalledInFolderScan:
"""Verify validate_and_rename_series_folders is invoked from FolderScanService."""
def test_validate_and_rename_imported_in_folder_scan_service(self):
"""folder_scan_service.py imports validate_and_rename_series_folders."""
import importlib
source = importlib.util.find_spec(
"src.server.services.folder_scan_service"
).origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
assert "validate_and_rename_series_folders" in content, (
"validate_and_rename_series_folders must be imported in folder_scan_service.py"
)
def test_validate_and_rename_called_in_run_folder_scan(self):
"""validate_and_rename_series_folders must be called inside run_folder_scan."""
import importlib
source = importlib.util.find_spec(
"src.server.services.folder_scan_service"
).origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
run_folder_scan_pos = content.find("def run_folder_scan")
rename_call_pos = content.find("validate_and_rename_series_folders()")
assert run_folder_scan_pos != -1, "run_folder_scan method not found"
assert rename_call_pos != -1, "validate_and_rename_series_folders call not found"
assert rename_call_pos > run_folder_scan_pos, (
"validate_and_rename_series_folders must be called INSIDE run_folder_scan"
)
class TestFolderRenameIntegration:
"""Integration test: folder rename is triggered during folder scan."""
@pytest.mark.asyncio
async def test_folder_rename_runs_during_scan(self, tmp_path):
"""When folder_scan_enabled is true, the scan renames mismatched folders."""
from src.server.services.folder_scan_service import FolderScanService
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(anime_dir)
with patch(
"src.config.settings.settings", mock_settings
), patch(
"src.server.services.folder_rename_service.settings", mock_settings
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.folder_rename_service._is_series_being_downloaded",
return_value=False,
), patch(
"src.server.services.folder_rename_service._update_database_paths",
new_callable=AsyncMock,
):
service = FolderScanService()
await service.run_folder_scan()
assert not series_dir.exists()
assert (anime_dir / "Attack on Titan (2013)").is_dir()
@pytest.mark.asyncio
async def test_folder_rename_skipped_when_prerequisites_not_met(self, tmp_path):
"""If anime directory is missing, rename logic is skipped gracefully."""
from src.server.services.folder_scan_service import FolderScanService
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(tmp_path / "nonexistent")
with patch(
"src.config.settings.settings", mock_settings
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.folder_rename_service.validate_and_rename_series_folders"
) as mock_rename:
service = FolderScanService()
await service.run_folder_scan()
mock_rename.assert_not_called()

View File

@@ -1,46 +1,63 @@
"""Integration tests verifying perform_nfo_repair_scan is wired into app startup. """Integration tests verifying perform_nfo_repair_scan is wired into folder scan
and NOT called during FastAPI lifespan startup.
These tests confirm that: These tests confirm that:
1. The lifespan calls perform_nfo_repair_scan after perform_media_scan_if_needed. 1. FolderScanService.run_folder_scan calls perform_nfo_repair_scan.
2. Series with incomplete NFO files are queued via the background_loader. 2. perform_nfo_repair_scan is NOT imported or called in fastapi_app.py lifespan.
3. Series with incomplete NFO files are queued via asyncio.create_task.
""" """
from unittest.mock import AsyncMock, MagicMock, call, patch from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest import pytest
class TestNfoRepairScanCalledOnStartup: class TestNfoRepairScanNotCalledOnStartup:
"""Verify perform_nfo_repair_scan is invoked during the FastAPI lifespan.""" """Verify perform_nfo_repair_scan is NOT invoked during FastAPI lifespan startup."""
def test_perform_nfo_repair_scan_imported_in_lifespan(self): def test_perform_nfo_repair_scan_not_imported_in_lifespan(self):
"""fastapi_app.py lifespan imports perform_nfo_repair_scan.""" """fastapi_app.py lifespan must not import or call perform_nfo_repair_scan."""
import importlib import importlib
import src.server.fastapi_app as app_module
source = importlib.util.find_spec("src.server.fastapi_app").origin source = importlib.util.find_spec("src.server.fastapi_app").origin
with open(source, "r", encoding="utf-8") as fh: with open(source, "r", encoding="utf-8") as fh:
content = fh.read() content = fh.read()
assert "perform_nfo_repair_scan" not in content, (
"perform_nfo_repair_scan must NOT be imported or called in fastapi_app.py"
)
class TestNfoRepairScanCalledInFolderScan:
"""Verify perform_nfo_repair_scan is invoked from FolderScanService."""
def test_perform_nfo_repair_scan_imported_in_folder_scan_service(self):
"""folder_scan_service.py imports perform_nfo_repair_scan."""
import importlib
source = importlib.util.find_spec("src.server.services.folder_scan_service").origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
assert "perform_nfo_repair_scan" in content, ( assert "perform_nfo_repair_scan" in content, (
"perform_nfo_repair_scan must be imported and called in fastapi_app.py" "perform_nfo_repair_scan must be imported in folder_scan_service.py"
) )
def test_perform_nfo_repair_scan_called_after_media_scan(self): def test_perform_nfo_repair_scan_called_in_run_folder_scan(self):
"""perform_nfo_repair_scan must appear after perform_media_scan_if_needed.""" """perform_nfo_repair_scan must be called inside run_folder_scan."""
import importlib import importlib
source = importlib.util.find_spec("src.server.fastapi_app").origin source = importlib.util.find_spec("src.server.services.folder_scan_service").origin
with open(source, "r", encoding="utf-8") as fh: with open(source, "r", encoding="utf-8") as fh:
content = fh.read() content = fh.read()
media_scan_pos = content.find("perform_media_scan_if_needed(background_loader)") run_folder_scan_pos = content.find("def run_folder_scan")
repair_scan_pos = content.find("perform_nfo_repair_scan(background_loader)") # Find the call inside the method body (after the import line)
repair_scan_call_pos = content.find("await perform_nfo_repair_scan(background_loader=None)")
assert media_scan_pos != -1, "perform_media_scan_if_needed call not found" assert run_folder_scan_pos != -1, "run_folder_scan method not found"
assert repair_scan_pos != -1, "perform_nfo_repair_scan call not found" assert repair_scan_call_pos != -1, "perform_nfo_repair_scan call not found"
assert repair_scan_pos > media_scan_pos, ( assert repair_scan_call_pos > run_folder_scan_pos, (
"perform_nfo_repair_scan must be called AFTER perform_media_scan_if_needed" "perform_nfo_repair_scan must be called INSIDE run_folder_scan"
) )
@@ -50,7 +67,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_incomplete_nfo_series_scheduled_for_repair(self, tmp_path): 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.""" """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 = tmp_path / "IncompleteAnime"
series_dir.mkdir() series_dir.mkdir()
@@ -66,7 +83,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
mock_repair_service.repair_series = AsyncMock(return_value=True) mock_repair_service.repair_series = AsyncMock(return_value=True)
with patch( with patch(
"src.server.services.initialization_service.settings", mock_settings "src.server.services.folder_scan_service._settings", mock_settings
), patch( ), patch(
"src.core.services.nfo_repair_service.nfo_needs_repair", "src.core.services.nfo_repair_service.nfo_needs_repair",
return_value=True, return_value=True,
@@ -86,7 +103,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_complete_nfo_series_not_scheduled(self, tmp_path): async def test_complete_nfo_series_not_scheduled(self, tmp_path):
"""Series whose tvshow.nfo has all required tags are not scheduled for repair.""" """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 = tmp_path / "CompleteAnime"
series_dir.mkdir() series_dir.mkdir()
@@ -99,7 +116,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
mock_settings.anime_directory = str(tmp_path) mock_settings.anime_directory = str(tmp_path)
with patch( with patch(
"src.server.services.initialization_service.settings", mock_settings "src.server.services.folder_scan_service._settings", mock_settings
), patch( ), patch(
"src.core.services.nfo_repair_service.nfo_needs_repair", "src.core.services.nfo_repair_service.nfo_needs_repair",
return_value=False, return_value=False,

View File

@@ -96,6 +96,8 @@ class TestCompleteNFOWorkflow:
mock_tmdb = Mock() mock_tmdb = Mock()
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb) mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
mock_tmdb.__aexit__ = AsyncMock(return_value=None) mock_tmdb.__aexit__ = AsyncMock(return_value=None)
mock_tmdb._ensure_session = AsyncMock()
mock_tmdb.close = AsyncMock()
mock_tmdb.search_tv_show = AsyncMock(return_value={"results": [mock_tmdb_show]}) mock_tmdb.search_tv_show = AsyncMock(return_value={"results": [mock_tmdb_show]})
mock_tmdb.get_tv_show = AsyncMock(return_value=mock_tmdb_show) mock_tmdb.get_tv_show = AsyncMock(return_value=mock_tmdb_show)
mock_tmdb.get_tv_show_details = AsyncMock(return_value=mock_tmdb_show) mock_tmdb.get_tv_show_details = AsyncMock(return_value=mock_tmdb_show)
@@ -158,6 +160,8 @@ class TestCompleteNFOWorkflow:
mock_tmdb = Mock() mock_tmdb = Mock()
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb) mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
mock_tmdb.__aexit__ = AsyncMock(return_value=None) mock_tmdb.__aexit__ = AsyncMock(return_value=None)
mock_tmdb._ensure_session = AsyncMock()
mock_tmdb.close = AsyncMock()
mock_tmdb.search_tv_show = AsyncMock( mock_tmdb.search_tv_show = AsyncMock(
return_value={"results": [{ return_value={"results": [{
"id": 999, "id": 999,
@@ -208,6 +212,8 @@ class TestCompleteNFOWorkflow:
mock_tmdb = Mock() mock_tmdb = Mock()
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb) mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
mock_tmdb.__aexit__ = AsyncMock(return_value=None) mock_tmdb.__aexit__ = AsyncMock(return_value=None)
mock_tmdb._ensure_session = AsyncMock()
mock_tmdb.close = AsyncMock()
mock_tmdb.search_tv_show = AsyncMock( mock_tmdb.search_tv_show = AsyncMock(
side_effect=TMDBAPIError("API error") side_effect=TMDBAPIError("API error")
) )
@@ -253,6 +259,8 @@ class TestCompleteNFOWorkflow:
mock_tmdb = Mock() mock_tmdb = Mock()
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb) mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
mock_tmdb.__aexit__ = AsyncMock(return_value=None) mock_tmdb.__aexit__ = AsyncMock(return_value=None)
mock_tmdb._ensure_session = AsyncMock()
mock_tmdb.close = AsyncMock()
mock_tmdb.search_tv_show = AsyncMock( mock_tmdb.search_tv_show = AsyncMock(
return_value={"results": [{ return_value={"results": [{
"id": 999, "id": 999,
@@ -307,16 +315,22 @@ class TestCompleteNFOWorkflow:
mock_tmdb = Mock() mock_tmdb = Mock()
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb) mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
mock_tmdb.__aexit__ = AsyncMock(return_value=None) mock_tmdb.__aexit__ = AsyncMock(return_value=None)
mock_tmdb._ensure_session = AsyncMock()
mock_tmdb.close = AsyncMock()
mock_tmdb.search_tv_show = AsyncMock( mock_tmdb.search_tv_show = AsyncMock(
side_effect=[ side_effect=[
{"results": [{"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"}]}, {"results": [{"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"}]},
{"results": [{"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"}]}, {"results": [{"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"}]},
{"results": [{"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"}]},
{"results": [{"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"}]},
] ]
) )
mock_tmdb.get_tv_show_details = AsyncMock( mock_tmdb.get_tv_show_details = AsyncMock(
side_effect=[ side_effect=[
{"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"}, {"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"},
{"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"}, {"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"},
{"id": 1, "name": "Anime 1", "first_air_date": "2020-01-01"},
{"id": 2, "name": "Anime 2", "first_air_date": "2021-01-01"},
] ]
) )
mock_tmdb.get_tv_show_content_ratings = AsyncMock(return_value={"results": []}) mock_tmdb.get_tv_show_content_ratings = AsyncMock(return_value={"results": []})
@@ -366,6 +380,8 @@ class TestNFOWorkflowWithDownloads:
mock_tmdb = Mock() mock_tmdb = Mock()
mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb) mock_tmdb.__aenter__ = AsyncMock(return_value=mock_tmdb)
mock_tmdb.__aexit__ = AsyncMock(return_value=None) mock_tmdb.__aexit__ = AsyncMock(return_value=None)
mock_tmdb._ensure_session = AsyncMock()
mock_tmdb.close = AsyncMock()
mock_tmdb.search_tv_show = AsyncMock( mock_tmdb.search_tv_show = AsyncMock(
return_value={"results": [{ return_value={"results": [{
"id": 999, "id": 999,

View File

@@ -0,0 +1,294 @@
"""Integration tests for poster check service wiring.
These tests verify that:
1. FolderScanService.run_folder_scan calls check_and_download_missing_posters.
2. The poster check logic is properly integrated into the scheduled folder scan.
3. Missing posters are downloaded, valid posters are skipped, and errors are handled.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
class TestPosterCheckScanCalledInFolderScan:
"""Verify check_and_download_missing_posters is invoked from FolderScanService."""
def test_check_and_download_missing_posters_imported_in_folder_scan_service(self):
"""folder_scan_service.py imports check_and_download_missing_posters."""
import importlib
source = importlib.util.find_spec(
"src.server.services.folder_scan_service"
).origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
assert "check_and_download_missing_posters" in content, (
"check_and_download_missing_posters must be imported in folder_scan_service.py"
)
def test_check_and_download_missing_posters_called_in_run_folder_scan(self):
"""check_and_download_missing_posters must be called inside run_folder_scan."""
import importlib
source = importlib.util.find_spec(
"src.server.services.folder_scan_service"
).origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
run_folder_scan_pos = content.find("def run_folder_scan")
poster_call_pos = content.find("check_and_download_missing_posters()")
assert run_folder_scan_pos != -1, "run_folder_scan method not found"
assert poster_call_pos != -1, "check_and_download_missing_posters call not found"
assert poster_call_pos > run_folder_scan_pos, (
"check_and_download_missing_posters must be called INSIDE run_folder_scan"
)
class TestPosterCheckIntegration:
"""Integration test: poster check is triggered during folder scan."""
@pytest.mark.asyncio
async def test_poster_check_downloads_missing_poster(self, tmp_path):
"""When poster.jpg is missing, the scan downloads it from the NFO thumb URL."""
from src.server.services.folder_scan_service import FolderScanService
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan (2013)"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<?xml version='1.0' encoding='UTF-8'?>\n"
"<tvshow>\n"
" <title>Attack on Titan</title>\n"
" <year>2013</year>\n"
' <thumb aspect="poster">https://example.com/poster.jpg</thumb>\n'
"</tvshow>\n"
)
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(anime_dir)
call_log = []
class MockDownloader:
"""Fake ImageDownloader that records calls."""
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def download_poster(self, url, folder, skip_existing=True):
call_log.append({"url": url, "folder": folder, "skip_existing": skip_existing})
return True
with patch(
"src.config.settings.settings", mock_settings
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
new_callable=AsyncMock,
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
), patch(
"src.server.services.folder_scan_service.ImageDownloader",
new=MockDownloader,
):
service = FolderScanService()
await service.run_folder_scan()
assert len(call_log) == 1, f"Expected 1 download call, got {len(call_log)}"
assert call_log[0]["url"] == "https://example.com/poster.jpg"
assert call_log[0]["folder"] == series_dir
assert call_log[0]["skip_existing"] is False
@pytest.mark.asyncio
async def test_poster_check_skips_valid_poster(self, tmp_path):
"""When poster.jpg exists and is large enough, the scan skips it."""
from src.server.services.folder_scan_service import FolderScanService
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan (2013)"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow>"
"<title>Attack on Titan</title>"
"<year>2013</year>"
"<thumb aspect='poster'>https://example.com/poster.jpg</thumb>"
"</tvshow>"
)
# Create a valid poster.jpg (larger than 1 KB)
poster_path = series_dir / "poster.jpg"
poster_path.write_bytes(b"x" * 2048)
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(anime_dir)
with patch(
"src.config.settings.settings", mock_settings
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
new_callable=AsyncMock,
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
), patch(
"src.server.services.folder_scan_service.ImageDownloader"
) as mock_downloader_cls:
service = FolderScanService()
await service.run_folder_scan()
mock_downloader_cls.assert_not_called()
@pytest.mark.asyncio
async def test_poster_check_skips_when_no_thumb_url(self, tmp_path):
"""When NFO has no thumb URL, the scan skips the folder."""
from src.server.services.folder_scan_service import FolderScanService
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan (2013)"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow>"
"<title>Attack on Titan</title>"
"<year>2013</year>"
"</tvshow>"
)
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(anime_dir)
with patch(
"src.config.settings.settings", mock_settings
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
new_callable=AsyncMock,
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
), patch(
"src.server.services.folder_scan_service.ImageDownloader"
) as mock_downloader_cls:
service = FolderScanService()
await service.run_folder_scan()
mock_downloader_cls.assert_not_called()
@pytest.mark.asyncio
async def test_poster_check_skipped_when_prerequisites_not_met(self, tmp_path):
"""If anime directory is missing, poster check logic is skipped gracefully."""
from src.server.services.folder_scan_service import FolderScanService
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(tmp_path / "nonexistent")
with patch(
"src.config.settings.settings", mock_settings
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.folder_rename_service.validate_and_rename_series_folders"
) as mock_rename, patch(
"src.server.services.folder_scan_service.ImageDownloader"
) as mock_downloader_cls:
service = FolderScanService()
await service.run_folder_scan()
mock_downloader_cls.assert_not_called()
class TestPosterCheckSemaphore:
"""Verify the poster download semaphore limits concurrency."""
def test_poster_download_semaphore_defined(self):
"""_POSTER_DOWNLOAD_SEMAPHORE must be defined in folder_scan_service.py."""
import importlib
source = importlib.util.find_spec(
"src.server.services.folder_scan_service"
).origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
assert "_POSTER_DOWNLOAD_SEMAPHORE" in content, (
"_POSTER_DOWNLOAD_SEMAPHORE must be defined in folder_scan_service.py"
)
@pytest.mark.asyncio
async def test_poster_download_uses_semaphore(self, tmp_path):
"""Poster downloads are gated by the semaphore."""
from src.server.services.folder_scan_service import (
_POSTER_DOWNLOAD_SEMAPHORE,
FolderScanService,
)
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
# Create multiple series folders
for i in range(5):
series_dir = anime_dir / f"Series {i}"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
f"<tvshow>"
f"<title>Series {i}</title>"
f"<year>202{i}</year>"
f"<thumb aspect='poster'>https://example.com/poster{i}.jpg</thumb>"
f"</tvshow>"
)
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
mock_settings.anime_directory = str(anime_dir)
active_count = 0
max_active = 0
async def tracked_download(*args, **kwargs):
nonlocal active_count, max_active
active_count += 1
max_active = max(max_active, active_count)
await asyncio.sleep(0.05)
active_count -= 1
return True
with patch(
"src.config.settings.settings", mock_settings
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
new_callable=AsyncMock,
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
), patch(
"src.server.services.folder_scan_service.ImageDownloader"
) as mock_downloader_cls:
mock_downloader = AsyncMock()
mock_downloader.download_poster = AsyncMock(side_effect=tracked_download)
mock_downloader_cls.return_value.__aenter__ = AsyncMock(
return_value=mock_downloader
)
mock_downloader_cls.return_value.__aexit__ = AsyncMock(return_value=False)
service = FolderScanService()
await service.run_folder_scan()
assert max_active <= 3, (
f"Expected max concurrent downloads <= 3, got {max_active}"
)

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 "(2013)" in sanitized
assert "Attack on Titan" in sanitized assert "Attack on Titan" in sanitized
def test_name_with_year_does_not_duplicate(self):
"""Test that name_with_year doesn't duplicate year."""
serie = Serie(
key="eighty-six",
name="86 Eighty Six (2021)",
site="aniworld.to",
folder="86 Eighty Six (2021)",
episodeDict={},
year=2021
)
assert serie.name_with_year == "86 Eighty Six (2021)"
assert serie.name_with_year.count("(2021)") == 1
class TestEnsureFolderWithYear: class TestEnsureFolderWithYear:
"""Test Serie.ensure_folder_with_year method.""" """Test Serie.ensure_folder_with_year method."""

View File

@@ -4,6 +4,7 @@ This module tests the performance characteristics of batch NFO creation
including concurrent operations, TMDB API request optimization, and memory usage. including concurrent operations, TMDB API request optimization, and memory usage.
""" """
import asyncio import asyncio
import logging
import time import time
from pathlib import Path from pathlib import Path
from typing import List from typing import List
@@ -15,6 +16,8 @@ from src.core.services.nfo_service import NFOService
from src.server.api.nfo import batch_create_nfo from src.server.api.nfo import batch_create_nfo
from src.server.models.nfo import NFOBatchCreateRequest from src.server.models.nfo import NFOBatchCreateRequest
logger = logging.getLogger(__name__)
class TestConcurrentNFOCreation: class TestConcurrentNFOCreation:
"""Test performance of concurrent NFO creation operations.""" """Test performance of concurrent NFO creation operations."""
@@ -83,8 +86,11 @@ class TestConcurrentNFOCreation:
# Concurrent should take roughly (num_series / 5) * 0.1 = 0.2s # Concurrent should take roughly (num_series / 5) * 0.1 = 0.2s
assert elapsed_time < 1.0, "Concurrency not providing speedup" assert elapsed_time < 1.0, "Concurrency not providing speedup"
print(f"\nPerformance: {num_series} series in {elapsed_time:.2f}s") logger.info("Batch NFO creation completed", extra={"num_series": num_series, "elapsed_s": elapsed_time})
print(f"Rate: {num_series / elapsed_time:.2f} series/second") logger.debug(
"Batch NFO creation rate",
extra={"series_per_second": num_series / elapsed_time},
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_concurrent_nfo_creation_50_series(self): async def test_concurrent_nfo_creation_50_series(self):

View File

@@ -4,6 +4,7 @@ This module tests the performance characteristics of WebSocket connections
including concurrent clients, message throughput, and progress update throttling. including concurrent clients, message throughput, and progress update throttling.
""" """
import asyncio import asyncio
import logging
import time import time
from typing import List from typing import List
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
@@ -12,6 +13,8 @@ import pytest
from src.server.services.websocket_service import WebSocketService from src.server.services.websocket_service import WebSocketService
logger = logging.getLogger(__name__)
class MockWebSocket: class MockWebSocket:
"""Mock WebSocket client for testing.""" """Mock WebSocket client for testing."""
@@ -82,8 +85,14 @@ class TestWebSocketConcurrentClients:
for i in range(num_clients): for i in range(num_clients):
await websocket_service.disconnect(f"client_{i:03d}") await websocket_service.disconnect(f"client_{i:03d}")
print(f"\n100 clients: Broadcast in {elapsed_time:.2f}s") logger.info("Broadcast completed for %d clients", num_clients, extra={"elapsed_s": elapsed_time})
print(f"Average per client: {elapsed_time / num_clients * 1000:.2f}ms") logger.debug(
"Broadcast performance per client",
extra={
"num_clients": num_clients,
"avg_ms_per_client": elapsed_time / num_clients * 1000,
},
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_200_concurrent_clients_scalability(self): async def test_200_concurrent_clients_scalability(self):
@@ -114,7 +123,7 @@ class TestWebSocketConcurrentClients:
for i in range(num_clients): for i in range(num_clients):
await websocket_service.disconnect(f"client_{i:03d}") await websocket_service.disconnect(f"client_{i:03d}")
print(f"\n200 clients: Broadcast in {elapsed_time:.2f}s") logger.info("Broadcast completed for %d clients", num_clients, extra={"elapsed_s": elapsed_time})
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_connection_pool_efficiency(self): async def test_connection_pool_efficiency(self):
@@ -144,8 +153,8 @@ class TestWebSocketConcurrentClients:
for i in range(num_clients): for i in range(num_clients):
await websocket_service.disconnect(f"client_{i:02d}") await websocket_service.disconnect(f"client_{i:02d}")
print(f"\nConnected {num_clients} clients in {connection_time:.3f}s") logger.info("Connected %d clients in %.3fs", num_clients, connection_time)
print(f"Average: {connection_time / num_clients * 1000:.2f}ms per connection") logger.info("Average: %.2fms per connection", connection_time / num_clients * 1000)
class TestMessageThroughput: class TestMessageThroughput:
@@ -192,8 +201,13 @@ class TestMessageThroughput:
for i in range(num_clients): for i in range(num_clients):
await websocket_service.disconnect(f"client_{i}") await websocket_service.disconnect(f"client_{i}")
print(f"\nThroughput: {messages_per_second:.2f} messages/second") logger.info("Throughput: %.2f messages/second", messages_per_second)
print(f"Total: {num_messages} messages to {num_clients} clients in {elapsed_time:.2f}s") logger.info(
"Total: %d messages to %d clients in %.2fs",
num_messages,
num_clients,
elapsed_time,
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_high_frequency_updates(self): async def test_high_frequency_updates(self):
@@ -234,7 +248,7 @@ class TestMessageThroughput:
for i in range(5): for i in range(5):
await websocket_service.disconnect(f"client_{i}") await websocket_service.disconnect(f"client_{i}")
print(f"\nHigh-frequency: {updates_per_second:.2f} updates/second") logger.info("High-frequency: %.2f updates/second", updates_per_second)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_burst_message_handling(self): async def test_burst_message_handling(self):
@@ -275,7 +289,7 @@ class TestMessageThroughput:
for i in range(num_clients): for i in range(num_clients):
await websocket_service.disconnect(f"client_{i:02d}") await websocket_service.disconnect(f"client_{i:02d}")
print(f"\nBurst: {num_messages} messages in {elapsed_time:.2f}s") logger.info("Burst: %d messages in %.2fs", num_messages, elapsed_time)
class TestProgressUpdateThrottling: class TestProgressUpdateThrottling:
@@ -313,7 +327,10 @@ class TestProgressUpdateThrottling:
await websocket_service.disconnect("test_client") await websocket_service.disconnect("test_client")
print(f"\nThrottling: {len(client.received_messages)} updates sent (100 possible)") logger.info(
"Throttling: %d updates sent (100 possible)",
len(client.received_messages),
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_throttling_reduces_network_load(self): async def test_throttling_reduces_network_load(self):
@@ -356,7 +373,11 @@ class TestProgressUpdateThrottling:
for i in range(10): for i in range(10):
await websocket_service.disconnect(f"client_{i}") await websocket_service.disconnect(f"client_{i}")
print(f"\nThrottling: {throttled_updates}/1000 updates sent ({reduction_percent:.1f}% reduction)") logger.info(
"Throttling: %d/1000 updates sent (%.1f%% reduction)",
throttled_updates,
reduction_percent,
)
class TestRoomIsolation: class TestRoomIsolation:
@@ -402,7 +423,7 @@ class TestRoomIsolation:
for i in range(clients_per_room): for i in range(clients_per_room):
await websocket_service.disconnect(f"{room}_client_{i:02d}") await websocket_service.disconnect(f"{room}_client_{i:02d}")
print(f"\nRoom isolation: 3 rooms × 30 clients in {elapsed_time:.2f}s") logger.info("Room isolation: 3 rooms × 30 clients in %.2fs", elapsed_time)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_selective_room_broadcast_performance(self): async def test_selective_room_broadcast_performance(self):
@@ -435,7 +456,7 @@ class TestRoomIsolation:
for i in range(clients_per_room): for i in range(clients_per_room):
await websocket_service.disconnect(f"{room}_{i:02d}") await websocket_service.disconnect(f"{room}_{i:02d}")
print(f"\nSelective broadcast: 25/100 clients in {elapsed_time:.3f}s") logger.info("Selective broadcast: 25/100 clients in %.3fs", elapsed_time)
class TestConnectionStability: class TestConnectionStability:
@@ -472,7 +493,7 @@ class TestConnectionStability:
# All connections should be cleaned up # All connections should be cleaned up
assert len(websocket_service.manager._active_connections) == 0 assert len(websocket_service.manager._active_connections) == 0
print(f"\nRapid cycles: {cycles_per_second:.2f} cycles/second") logger.info("Rapid cycles: %.2f cycles/second", cycles_per_second)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_concurrent_connect_disconnect(self): async def test_concurrent_connect_disconnect(self):
@@ -497,7 +518,7 @@ class TestConnectionStability:
# All should be cleaned up # All should be cleaned up
assert len(websocket_service.manager._active_connections) == 0 assert len(websocket_service.manager._active_connections) == 0
print(f"\nConcurrent ops: 30 clients in {elapsed_time:.2f}s") logger.info("Concurrent ops: 30 clients in %.2fs", elapsed_time)
class TestMemoryEfficiency: class TestMemoryEfficiency:
@@ -533,8 +554,8 @@ class TestMemoryEfficiency:
for i in range(100): for i in range(100):
await websocket_service.disconnect(f"mem_client_{i:03d}") await websocket_service.disconnect(f"mem_client_{i:03d}")
print(f"\nMemory: {memory_increase_mb:.2f}MB for 100 connections") logger.info("Memory: %.2fMB for 100 connections", memory_increase_mb)
print(f"Per connection: {per_connection_kb:.2f}KB") logger.info("Per connection: %.2fKB", per_connection_kb)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_message_queue_memory_efficiency(self): async def test_message_queue_memory_efficiency(self):
@@ -567,5 +588,5 @@ class TestMemoryEfficiency:
await websocket_service.disconnect("queue_test") await websocket_service.disconnect("queue_test")
print(f"\nMessage queue: {total_size} bytes for 100 messages") logger.info("Message queue: %d bytes for 100 messages", total_size)
print(f"Average: {total_size / 100:.2f} bytes/message") logger.info("Average: %.2f bytes/message", total_size / 100)

View File

@@ -528,7 +528,13 @@ class TestLoadNfoAndImages:
year=2020 year=2020
) )
with patch("src.server.database.service.AnimeSeriesService") as mock_service_class: mock_nfo_service = AsyncMock()
mock_nfo_service.create_tvshow_nfo = AsyncMock(return_value="/anime/test_folder/tvshow.nfo")
mock_factory = MagicMock()
mock_factory.create = MagicMock(return_value=mock_nfo_service)
with patch("src.server.database.service.AnimeSeriesService") as mock_service_class, \
patch("src.server.services.background_loader_service.get_nfo_factory", return_value=mock_factory):
mock_service_class.get_by_key = AsyncMock(return_value=mock_series) mock_service_class.get_by_key = AsyncMock(return_value=mock_series)
result = await background_loader_service._load_nfo_and_images(task, mock_db) result = await background_loader_service._load_nfo_and_images(task, mock_db)

View File

@@ -263,8 +263,9 @@ class TestWithErrorRecoveryDecorator:
fail_once() fail_once()
# Should have logged a warning with context # Should have logged a warning with context
mock_logger.warning.assert_called() mock_logger.warning.assert_called()
logged_msg = mock_logger.warning.call_args[0][0] # context is a %s arg, so check all positional call args
assert "my_context" in logged_msg logged_args = mock_logger.warning.call_args[0]
assert any("my_context" in str(arg) for arg in logged_args)
def test_retryable_error_is_retried(self): def test_retryable_error_is_retried(self):
"""RetryableError (standard Exception subclass) is retried.""" """RetryableError (standard Exception subclass) is retried."""

View File

@@ -472,7 +472,7 @@ async def test_validate_schema_with_inspection_error():
def test_schema_constants(): def test_schema_constants():
"""Test that schema constants are properly defined.""" """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 len(EXPECTED_TABLES) == 5
assert "anime_series" in EXPECTED_TABLES assert "anime_series" in EXPECTED_TABLES
assert "episodes" in EXPECTED_TABLES assert "episodes" in EXPECTED_TABLES

View File

@@ -0,0 +1,461 @@
"""Unit tests for folder_rename_service.py.
These tests verify the core logic of the folder rename service in
isolation, using temporary directories and mocked dependencies.
"""
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.server.services.folder_rename_service import (
_compute_expected_folder_name,
_is_series_being_downloaded,
_parse_nfo_title_and_year,
_update_database_paths,
validate_and_rename_series_folders,
)
class TestParseNfoTitleAndYear:
"""Tests for _parse_nfo_title_and_year."""
def test_parses_title_and_year(self, tmp_path: Path) -> None:
nfo = tmp_path / "tvshow.nfo"
nfo.write_text(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
title, year = _parse_nfo_title_and_year(nfo)
assert title == "Attack on Titan"
assert year == "2013"
def test_missing_title_returns_none(self, tmp_path: Path) -> None:
nfo = tmp_path / "tvshow.nfo"
nfo.write_text("<tvshow><year>2013</year></tvshow>")
title, year = _parse_nfo_title_and_year(nfo)
assert title is None
assert year == "2013"
def test_missing_year_returns_none(self, tmp_path: Path) -> None:
nfo = tmp_path / "tvshow.nfo"
nfo.write_text("<tvshow><title>Attack on Titan</title></tvshow>")
title, year = _parse_nfo_title_and_year(nfo)
assert title == "Attack on Titan"
assert year is None
def test_empty_title_returns_none(self, tmp_path: Path) -> None:
nfo = tmp_path / "tvshow.nfo"
nfo.write_text(
"<tvshow><title> </title><year>2013</year></tvshow>"
)
title, year = _parse_nfo_title_and_year(nfo)
assert title is None
assert year == "2013"
def test_malformed_xml_returns_none(self, tmp_path: Path) -> None:
nfo = tmp_path / "tvshow.nfo"
nfo.write_text("not xml at all")
title, year = _parse_nfo_title_and_year(nfo)
assert title is None
assert year is None
class TestComputeExpectedFolderName:
"""Tests for _compute_expected_folder_name."""
def test_simple_title_and_year(self) -> None:
result = _compute_expected_folder_name("Attack on Titan", "2013")
assert result == "Attack on Titan (2013)"
def test_sanitizes_invalid_chars(self) -> None:
result = _compute_expected_folder_name("Show: Subtitle", "2020")
assert result == "Show Subtitle (2020)"
def test_sanitizes_slashes(self) -> None:
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."""
def test_no_active_download(self) -> None:
mock_service = MagicMock()
mock_service._active_download = None
mock_service._pending_queue = []
with patch(
"src.server.services.folder_rename_service.get_download_service",
return_value=mock_service,
):
assert _is_series_being_downloaded("Some Show") is False
def test_active_download_matches(self) -> None:
mock_item = MagicMock()
mock_item.serie_folder = "Some Show"
mock_service = MagicMock()
mock_service._active_download = mock_item
mock_service._pending_queue = []
with patch(
"src.server.services.folder_rename_service.get_download_service",
return_value=mock_service,
):
assert _is_series_being_downloaded("Some Show") is True
def test_pending_download_matches(self) -> None:
mock_item = MagicMock()
mock_item.serie_folder = "Some Show"
mock_service = MagicMock()
mock_service._active_download = None
mock_service._pending_queue = [mock_item]
with patch(
"src.server.services.folder_rename_service.get_download_service",
return_value=mock_service,
):
assert _is_series_being_downloaded("Some Show") is True
def test_exception_returns_true_for_safety(self) -> None:
with patch(
"src.server.services.folder_rename_service.get_download_service",
side_effect=RuntimeError("boom"),
):
assert _is_series_being_downloaded("Some Show") is True
class TestUpdateDatabasePaths:
"""Tests for _update_database_paths."""
@pytest.mark.asyncio
async def test_updates_series_folder(self, tmp_path: Path) -> None:
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
mock_series = MagicMock()
mock_series.id = 1
mock_series.folder = "Old Name"
with patch(
"src.server.services.folder_rename_service.get_db_session"
) as mock_get_db, patch(
"src.server.services.folder_rename_service.AnimeSeriesService"
) as mock_series_svc, patch(
"src.server.services.folder_rename_service.EpisodeService"
) as mock_episode_svc, patch(
"src.server.services.folder_rename_service.DownloadQueueService"
) as mock_queue_svc:
mock_db = AsyncMock()
mock_get_db.return_value.__aenter__ = AsyncMock(return_value=mock_db)
mock_get_db.return_value.__aexit__ = AsyncMock(return_value=False)
mock_series_svc.get_by_key = AsyncMock(return_value=mock_series)
mock_series_svc.get_all = AsyncMock(return_value=[])
mock_series_svc.update = AsyncMock(return_value=mock_series)
mock_episode_svc.get_by_series = AsyncMock(return_value=[])
mock_queue_svc.get_all = AsyncMock(return_value=[])
await _update_database_paths("Old Name", "New Name", anime_dir)
mock_series_svc.update.assert_awaited_once_with(
mock_db, 1, folder="New Name"
)
@pytest.mark.asyncio
async def test_updates_episode_file_paths(self, tmp_path: Path) -> None:
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
old_path = anime_dir / "Old Name" / "S01E01.mkv"
new_path = anime_dir / "New Name" / "S01E01.mkv"
mock_series = MagicMock()
mock_series.id = 1
mock_series.folder = "Old Name"
mock_episode = MagicMock()
mock_episode.file_path = str(old_path)
with patch(
"src.server.services.folder_rename_service.get_db_session"
) as mock_get_db, patch(
"src.server.services.folder_rename_service.AnimeSeriesService"
) as mock_series_svc, patch(
"src.server.services.folder_rename_service.EpisodeService"
) as mock_episode_svc, patch(
"src.server.services.folder_rename_service.DownloadQueueService"
) as mock_queue_svc:
mock_db = AsyncMock()
mock_get_db.return_value.__aenter__ = AsyncMock(return_value=mock_db)
mock_get_db.return_value.__aexit__ = AsyncMock(return_value=False)
mock_series_svc.get_by_key = AsyncMock(return_value=mock_series)
mock_series_svc.get_all = AsyncMock(return_value=[])
mock_series_svc.update = AsyncMock(return_value=mock_series)
mock_episode_svc.get_by_series = AsyncMock(return_value=[mock_episode])
mock_queue_svc.get_all = AsyncMock(return_value=[])
await _update_database_paths("Old Name", "New Name", anime_dir)
assert mock_episode.file_path == str(new_path)
class TestValidateAndRenameSeriesFolders:
"""Integration-style tests for validate_and_rename_series_folders."""
@pytest.mark.asyncio
async def test_no_anime_directory(self) -> None:
with patch(
"src.server.services.folder_rename_service.settings.anime_directory",
"",
):
stats = await validate_and_rename_series_folders()
assert stats == {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}
@pytest.mark.asyncio
async def test_renames_folder_when_name_differs(self, tmp_path: Path) -> None:
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
with patch(
"src.server.services.folder_rename_service.settings.anime_directory",
str(anime_dir),
), patch(
"src.server.services.folder_rename_service._is_series_being_downloaded",
return_value=False,
), patch(
"src.server.services.folder_rename_service._update_database_paths",
new_callable=AsyncMock,
) as mock_update_db:
stats = await validate_and_rename_series_folders()
assert stats["scanned"] == 1
assert stats["renamed"] == 1
assert stats["skipped"] == 0
assert stats["errors"] == 0
assert not series_dir.exists()
assert (anime_dir / "Attack on Titan (2013)").is_dir()
mock_update_db.assert_awaited_once()
@pytest.mark.asyncio
async def test_skips_when_name_already_correct(self, tmp_path: Path) -> None:
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan (2013)"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
with patch(
"src.server.services.folder_rename_service.settings.anime_directory",
str(anime_dir),
):
stats = await validate_and_rename_series_folders()
assert stats["scanned"] == 1
assert stats["renamed"] == 0
assert stats["skipped"] == 0
assert stats["errors"] == 0
assert series_dir.is_dir()
@pytest.mark.asyncio
async def test_skips_missing_title_or_year(self, tmp_path: Path) -> None:
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Incomplete"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow><title>Incomplete</title></tvshow>"
)
with patch(
"src.server.services.folder_rename_service.settings.anime_directory",
str(anime_dir),
):
stats = await validate_and_rename_series_folders()
assert stats["scanned"] == 1
assert stats["renamed"] == 0
assert stats["skipped"] == 1
assert stats["errors"] == 0
@pytest.mark.asyncio
async def test_skips_when_download_active(self, tmp_path: Path) -> None:
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
with patch(
"src.server.services.folder_rename_service.settings.anime_directory",
str(anime_dir),
), patch(
"src.server.services.folder_rename_service._is_series_being_downloaded",
return_value=True,
):
stats = await validate_and_rename_series_folders()
assert stats["scanned"] == 1
assert stats["renamed"] == 0
assert stats["skipped"] == 1
assert stats["errors"] == 0
assert series_dir.is_dir()
@pytest.mark.asyncio
async def test_errors_when_target_exists(self, tmp_path: Path) -> None:
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
series_dir = anime_dir / "Attack on Titan"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
# Pre-create the target folder to simulate a duplicate
(anime_dir / "Attack on Titan (2013)").mkdir()
with patch(
"src.server.services.folder_rename_service.settings.anime_directory",
str(anime_dir),
), patch(
"src.server.services.folder_rename_service._is_series_being_downloaded",
return_value=False,
):
stats = await validate_and_rename_series_folders()
assert stats["scanned"] == 1
assert stats["renamed"] == 0
assert stats["skipped"] == 0
assert stats["errors"] == 1
assert series_dir.is_dir()
@pytest.mark.asyncio
async def test_counts_multiple_folders(self, tmp_path: Path) -> None:
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
# Folder 1: needs rename
d1 = anime_dir / "Show A"
d1.mkdir()
(d1 / "tvshow.nfo").write_text(
"<tvshow><title>Show A</title><year>2020</year></tvshow>"
)
# Folder 2: already correct
d2 = anime_dir / "Show B (2021)"
d2.mkdir()
(d2 / "tvshow.nfo").write_text(
"<tvshow><title>Show B</title><year>2021</year></tvshow>"
)
# Folder 3: missing year
d3 = anime_dir / "Show C"
d3.mkdir()
(d3 / "tvshow.nfo").write_text("<tvshow><title>Show C</title></tvshow>")
with patch(
"src.server.services.folder_rename_service.settings.anime_directory",
str(anime_dir),
), patch(
"src.server.services.folder_rename_service._is_series_being_downloaded",
return_value=False,
), patch(
"src.server.services.folder_rename_service._update_database_paths",
new_callable=AsyncMock,
):
stats = await validate_and_rename_series_folders()
assert stats["scanned"] == 3
assert stats["renamed"] == 1
assert stats["skipped"] == 1
assert stats["errors"] == 0
assert not d1.exists()
assert (anime_dir / "Show A (2020)").is_dir()
assert d2.is_dir()
assert d3.is_dir()

View File

@@ -0,0 +1,608 @@
"""Unit tests for FolderScanService (Tasks 1.21.5).
Covers:
- Prerequisites checking (TMDB key, anime directory)
- NFO repair integration (Task 1.3)
- Folder rename validation (Task 1.4)
- Poster check and download (Task 1.5)
- Exception handling and semaphore usage
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from src.server.services.folder_scan_service import (
_POSTER_DOWNLOAD_SEMAPHORE,
_TMDB_SEMAPHORE,
FolderScanService,
FolderScanServiceError,
perform_nfo_repair_scan,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def folder_scan_service() -> FolderScanService:
"""Return a fresh FolderScanService instance."""
return FolderScanService()
@pytest.fixture
def mock_settings(tmp_path: Path):
"""Return a mock settings object with valid prerequisites."""
mock = MagicMock()
mock.tmdb_api_key = "test-api-key"
mock.anime_directory = str(tmp_path)
mock.nfo_download_poster = True
return mock
# ---------------------------------------------------------------------------
# 1.2 Skeleton / prerequisites
# ---------------------------------------------------------------------------
class TestPrerequisites:
"""Test _prerequisites_met checks."""
def test_prerequisites_met(self, folder_scan_service, tmp_path):
"""All prerequisites present → True."""
with patch(
"src.config.settings.settings"
) as mock_settings:
mock_settings.tmdb_api_key = "key"
mock_settings.anime_directory = str(tmp_path)
assert folder_scan_service._prerequisites_met() is True
def test_missing_tmdb_key(self, folder_scan_service, tmp_path):
"""Missing TMDB API key → False."""
with patch(
"src.config.settings.settings"
) as mock_settings:
mock_settings.tmdb_api_key = None
mock_settings.anime_directory = str(tmp_path)
assert folder_scan_service._prerequisites_met() is False
def test_missing_anime_directory(self, folder_scan_service):
"""Missing anime_directory → False."""
with patch(
"src.config.settings.settings"
) as mock_settings:
mock_settings.tmdb_api_key = "key"
mock_settings.anime_directory = None
assert folder_scan_service._prerequisites_met() is False
def test_anime_directory_not_found(self, folder_scan_service, tmp_path):
"""anime_directory points to non-existent path → False."""
non_existent = tmp_path / "does_not_exist"
with patch(
"src.config.settings.settings"
) as mock_settings:
mock_settings.tmdb_api_key = "key"
mock_settings.anime_directory = str(non_existent)
assert folder_scan_service._prerequisites_met() is False
class TestRunFolderScanPrerequisites:
"""Test run_folder_scan skips when prerequisites not met."""
@pytest.mark.asyncio
async def test_skips_when_prerequisites_missing(self, folder_scan_service):
"""If _prerequisites_met returns False, scan exits early."""
with patch.object(
folder_scan_service, "_prerequisites_met", return_value=False
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan"
) as mock_repair:
await folder_scan_service.run_folder_scan()
mock_repair.assert_not_called()
@pytest.mark.asyncio
async def test_logs_start_and_completion(self, folder_scan_service, tmp_path):
"""Scan logs start and completion when prerequisites are met."""
with patch.object(
folder_scan_service, "_prerequisites_met", return_value=True
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
new_callable=AsyncMock,
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
), patch.object(
folder_scan_service,
"check_and_download_missing_posters",
new_callable=AsyncMock,
return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
):
# Should not raise
await folder_scan_service.run_folder_scan()
@pytest.mark.asyncio
async def test_catches_unhandled_exceptions(self, folder_scan_service):
"""Unhandled exceptions are caught and logged, not re-raised."""
with patch.object(
folder_scan_service,
"_prerequisites_met",
side_effect=RuntimeError("boom"),
):
# Must NOT raise
await folder_scan_service.run_folder_scan()
# ---------------------------------------------------------------------------
# 1.3 NFO repair integration
# ---------------------------------------------------------------------------
class TestNfoRepairIntegration:
"""Test perform_nfo_repair_scan is called inside run_folder_scan."""
@pytest.mark.asyncio
async def test_calls_perform_nfo_repair_scan(self, folder_scan_service, tmp_path):
"""run_folder_scan must call perform_nfo_repair_scan."""
with patch.object(
folder_scan_service, "_prerequisites_met", return_value=True
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
) as mock_repair, patch(
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
new_callable=AsyncMock,
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
), patch.object(
folder_scan_service,
"check_and_download_missing_posters",
new_callable=AsyncMock,
return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
):
await folder_scan_service.run_folder_scan()
mock_repair.assert_awaited_once_with(background_loader=None)
@pytest.mark.asyncio
async def test_nfo_repair_failure_does_not_crash_scan(
self, folder_scan_service, tmp_path
):
"""If perform_nfo_repair_scan raises, the broad except catches it
and the scan stops — remaining steps are NOT invoked."""
with patch.object(
folder_scan_service, "_prerequisites_met", return_value=True
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
side_effect=RuntimeError("repair failed"),
) as mock_repair, patch(
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
new_callable=AsyncMock,
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
) as mock_rename, patch.object(
folder_scan_service,
"check_and_download_missing_posters",
new_callable=AsyncMock,
return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
):
await folder_scan_service.run_folder_scan()
mock_repair.assert_awaited_once()
# Broad except stops the scan; rename/poster are skipped
mock_rename.assert_not_called()
# ---------------------------------------------------------------------------
# 1.4 Folder rename integration
# ---------------------------------------------------------------------------
class TestFolderRenameIntegration:
"""Test validate_and_rename_series_folders is called and stats logged."""
@pytest.mark.asyncio
async def test_calls_folder_rename_service(self, folder_scan_service, tmp_path):
"""run_folder_scan must call validate_and_rename_series_folders."""
with patch.object(
folder_scan_service, "_prerequisites_met", return_value=True
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
new_callable=AsyncMock,
return_value={"scanned": 5, "renamed": 2, "skipped": 2, "errors": 1},
) as mock_rename, patch.object(
folder_scan_service,
"check_and_download_missing_posters",
new_callable=AsyncMock,
return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
):
await folder_scan_service.run_folder_scan()
mock_rename.assert_awaited_once()
@pytest.mark.asyncio
async def test_folder_rename_failure_does_not_crash_scan(
self, folder_scan_service, tmp_path
):
"""If validate_and_rename_series_folders raises, the broad except
catches it and the scan stops — poster check is NOT invoked."""
with patch.object(
folder_scan_service, "_prerequisites_met", return_value=True
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
new_callable=AsyncMock,
side_effect=RuntimeError("rename failed"),
), patch.object(
folder_scan_service,
"check_and_download_missing_posters",
new_callable=AsyncMock,
return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
) as mock_poster:
await folder_scan_service.run_folder_scan()
# Broad except stops the scan; poster check is skipped
mock_poster.assert_not_called()
# ---------------------------------------------------------------------------
# 1.5 Poster check and download
# ---------------------------------------------------------------------------
class TestPosterCheck:
"""Test check_and_download_missing_posters logic."""
@pytest.mark.asyncio
async def test_no_anime_directory_returns_empty_stats(self, folder_scan_service):
"""Missing anime_directory → empty stats."""
with patch(
"src.config.settings.settings"
) as mock_settings:
mock_settings.anime_directory = None
stats = await folder_scan_service.check_and_download_missing_posters()
assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
@pytest.mark.asyncio
async def test_nonexistent_directory_returns_empty_stats(
self, folder_scan_service, tmp_path
):
"""Non-existent anime_directory → empty stats."""
non_existent = tmp_path / "missing"
with patch(
"src.config.settings.settings"
) as mock_settings:
mock_settings.anime_directory = str(non_existent)
stats = await folder_scan_service.check_and_download_missing_posters()
assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
@pytest.mark.asyncio
async def test_no_series_folders_returns_empty_stats(
self, folder_scan_service, tmp_path
):
"""Empty anime_directory → empty stats."""
with patch(
"src.config.settings.settings"
) as mock_settings:
mock_settings.anime_directory = str(tmp_path)
stats = await folder_scan_service.check_and_download_missing_posters()
assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
@pytest.mark.asyncio
async def test_skips_folders_without_nfo(self, folder_scan_service, tmp_path):
"""Folders without tvshow.nfo are ignored."""
(tmp_path / "SomeShow").mkdir()
with patch(
"src.config.settings.settings"
) as mock_settings:
mock_settings.anime_directory = str(tmp_path)
stats = await folder_scan_service.check_and_download_missing_posters()
assert stats == {"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0}
@pytest.mark.asyncio
async def test_valid_poster_skipped(self, folder_scan_service, tmp_path):
"""Existing poster.jpg ≥ 1 KB is skipped."""
series_dir = tmp_path / "Attack on Titan (2013)"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
# Write a 2 KB poster
(series_dir / "poster.jpg").write_bytes(b"x" * 2048)
with patch(
"src.config.settings.settings"
) as mock_settings:
mock_settings.anime_directory = str(tmp_path)
stats = await folder_scan_service.check_and_download_missing_posters()
assert stats["scanned"] == 1
assert stats["skipped"] == 1
assert stats["downloaded"] == 0
assert stats["errors"] == 0
@pytest.mark.asyncio
async def test_missing_poster_downloaded(self, folder_scan_service, tmp_path):
"""Missing poster triggers download when thumb URL exists."""
series_dir = tmp_path / "Attack on Titan (2013)"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow>"
"<title>Attack on Titan</title><year>2013</year>"
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
"</tvshow>"
)
mock_downloader = AsyncMock()
mock_downloader.download_poster = AsyncMock(return_value=True)
mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader)
mock_downloader.__aexit__ = AsyncMock(return_value=False)
with patch(
"src.config.settings.settings"
) as mock_settings:
mock_settings.anime_directory = str(tmp_path)
mock_settings.nfo_download_poster = True
with patch(
"src.server.services.folder_scan_service.ImageDownloader",
return_value=mock_downloader,
):
stats = await folder_scan_service.check_and_download_missing_posters()
assert stats["scanned"] == 1
assert stats["downloaded"] == 1
assert stats["skipped"] == 0
assert stats["errors"] == 0
@pytest.mark.asyncio
async def test_no_thumb_url_skipped(self, folder_scan_service, tmp_path):
"""NFO without thumb URL → skipped."""
series_dir = tmp_path / "Attack on Titan (2013)"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
)
with patch(
"src.config.settings.settings"
) as mock_settings:
mock_settings.anime_directory = str(tmp_path)
mock_settings.nfo_download_poster = True
stats = await folder_scan_service.check_and_download_missing_posters()
assert stats["scanned"] == 1
assert stats["skipped"] == 1
assert stats["downloaded"] == 0
@pytest.mark.asyncio
async def test_poster_download_disabled_by_setting(
self, folder_scan_service, tmp_path
):
"""nfo_download_poster=False → skipped even with valid thumb URL."""
series_dir = tmp_path / "Attack on Titan (2013)"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow>"
"<title>Attack on Titan</title><year>2013</year>"
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
"</tvshow>"
)
with patch(
"src.config.settings.settings"
) as mock_settings:
mock_settings.anime_directory = str(tmp_path)
mock_settings.nfo_download_poster = False
stats = await folder_scan_service.check_and_download_missing_posters()
assert stats["scanned"] == 1
assert stats["skipped"] == 1
assert stats["downloaded"] == 0
@pytest.mark.asyncio
async def test_download_failure_counts_as_error(self, folder_scan_service, tmp_path):
"""Failed download increments errors."""
series_dir = tmp_path / "Attack on Titan (2013)"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow>"
"<title>Attack on Titan</title><year>2013</year>"
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
"</tvshow>"
)
mock_downloader = AsyncMock()
mock_downloader.download_poster = AsyncMock(return_value=False)
mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader)
mock_downloader.__aexit__ = AsyncMock(return_value=False)
with patch(
"src.config.settings.settings"
) as mock_settings:
mock_settings.anime_directory = str(tmp_path)
mock_settings.nfo_download_poster = True
with patch(
"src.server.services.folder_scan_service.ImageDownloader",
return_value=mock_downloader,
):
stats = await folder_scan_service.check_and_download_missing_posters()
assert stats["scanned"] == 1
assert stats["errors"] == 1
assert stats["downloaded"] == 0
@pytest.mark.asyncio
async def test_download_exception_counts_as_error(self, folder_scan_service, tmp_path):
"""Exception during download increments errors."""
series_dir = tmp_path / "Attack on Titan (2013)"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow>"
"<title>Attack on Titan</title><year>2013</year>"
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
"</tvshow>"
)
mock_downloader = AsyncMock()
mock_downloader.download_poster = AsyncMock(side_effect=RuntimeError("net"))
mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader)
mock_downloader.__aexit__ = AsyncMock(return_value=False)
with patch(
"src.config.settings.settings"
) as mock_settings:
mock_settings.anime_directory = str(tmp_path)
mock_settings.nfo_download_poster = True
with patch(
"src.server.services.folder_scan_service.ImageDownloader",
return_value=mock_downloader,
):
stats = await folder_scan_service.check_and_download_missing_posters()
assert stats["scanned"] == 1
assert stats["errors"] == 1
assert stats["downloaded"] == 0
@pytest.mark.asyncio
async def test_too_small_poster_re_downloaded(self, folder_scan_service, tmp_path):
"""Poster < 1 KB is treated as missing and re-downloaded."""
series_dir = tmp_path / "Attack on Titan (2013)"
series_dir.mkdir()
(series_dir / "tvshow.nfo").write_text(
"<tvshow>"
"<title>Attack on Titan</title><year>2013</year>"
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
"</tvshow>"
)
# Write a tiny 100-byte poster
(series_dir / "poster.jpg").write_bytes(b"x" * 100)
mock_downloader = AsyncMock()
mock_downloader.download_poster = AsyncMock(return_value=True)
mock_downloader.__aenter__ = AsyncMock(return_value=mock_downloader)
mock_downloader.__aexit__ = AsyncMock(return_value=False)
with patch(
"src.config.settings.settings"
) as mock_settings:
mock_settings.anime_directory = str(tmp_path)
mock_settings.nfo_download_poster = True
with patch(
"src.server.services.folder_scan_service.ImageDownloader",
return_value=mock_downloader,
):
stats = await folder_scan_service.check_and_download_missing_posters()
assert stats["scanned"] == 1
assert stats["downloaded"] == 1
assert stats["skipped"] == 0
class TestExtractPosterUrl:
"""Test _extract_poster_url_from_nfo static method."""
def test_extract_poster_url_with_aspect(self, tmp_path):
nfo = tmp_path / "tvshow.nfo"
nfo.write_text(
"<tvshow>"
'<thumb aspect=\"poster\">https://example.com/poster.jpg</thumb>'
"</tvshow>"
)
url = FolderScanService._extract_poster_url_from_nfo(nfo)
assert url == "https://example.com/poster.jpg"
def test_extract_first_thumb_fallback(self, tmp_path):
nfo = tmp_path / "tvshow.nfo"
nfo.write_text(
"<tvshow>"
'<thumb>https://example.com/fallback.jpg</thumb>'
"</tvshow>"
)
url = FolderScanService._extract_poster_url_from_nfo(nfo)
assert url == "https://example.com/fallback.jpg"
def test_no_thumb_returns_none(self, tmp_path):
nfo = tmp_path / "tvshow.nfo"
nfo.write_text("<tvshow><title>Test</title></tvshow>")
url = FolderScanService._extract_poster_url_from_nfo(nfo)
assert url is None
def test_missing_file_returns_none(self, tmp_path):
nfo = tmp_path / "tvshow.nfo"
url = FolderScanService._extract_poster_url_from_nfo(nfo)
assert url is None
def test_malformed_xml_returns_none(self, tmp_path):
nfo = tmp_path / "tvshow.nfo"
nfo.write_text("not xml")
url = FolderScanService._extract_poster_url_from_nfo(nfo)
assert url is None
# ---------------------------------------------------------------------------
# Semaphores
# ---------------------------------------------------------------------------
class TestSemaphores:
"""Verify module-level semaphores exist and have correct initial value."""
def test_tmdb_semaphore_value(self):
assert _TMDB_SEMAPHORE._value == 3
def test_poster_download_semaphore_value(self):
assert _POSTER_DOWNLOAD_SEMAPHORE._value == 3
# ---------------------------------------------------------------------------
# Full run_folder_scan integration
# ---------------------------------------------------------------------------
class TestRunFolderScanFull:
"""End-to-end tests for run_folder_scan with mocked sub-tasks."""
@pytest.mark.asyncio
async def test_full_scan_happy_path(self, folder_scan_service, tmp_path):
"""All sub-tasks succeed."""
with patch.object(
folder_scan_service, "_prerequisites_met", return_value=True
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
) as mock_repair, patch(
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
new_callable=AsyncMock,
return_value={"scanned": 3, "renamed": 1, "skipped": 1, "errors": 1},
) as mock_rename, patch.object(
folder_scan_service,
"check_and_download_missing_posters",
new_callable=AsyncMock,
return_value={"scanned": 3, "downloaded": 2, "skipped": 1, "errors": 0},
) as mock_poster:
await folder_scan_service.run_folder_scan()
mock_repair.assert_awaited_once_with(background_loader=None)
mock_rename.assert_awaited_once()
mock_poster.assert_awaited_once()
@pytest.mark.asyncio
async def test_full_scan_all_stats_zero(self, folder_scan_service, tmp_path):
"""Empty library → all stats zero."""
with patch.object(
folder_scan_service, "_prerequisites_met", return_value=True
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
new_callable=AsyncMock,
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
), patch.object(
folder_scan_service,
"check_and_download_missing_posters",
new_callable=AsyncMock,
return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
):
await folder_scan_service.run_folder_scan()

View File

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

View File

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

View File

@@ -424,7 +424,14 @@ class TestCreateTVShowNFO:
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock): patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock):
mock_search.return_value = {"results": [{"id": 1429, "name": "Attack on Titan", "first_air_date": "2013-04-07"}]} mock_search.return_value = {
"results": [{
"id": 1429,
"name": "Attack on Titan",
"first_air_date": "2013-04-07",
"overview": "Several hundred years ago, humans were nearly...",
}]
}
mock_details.return_value = mock_tmdb_data mock_details.return_value = mock_tmdb_data
mock_ratings.return_value = mock_content_ratings_de mock_ratings.return_value = mock_content_ratings_de
@@ -463,7 +470,14 @@ class TestCreateTVShowNFO:
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \ patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock): patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock):
mock_search.return_value = {"results": [{"id": 1429, "name": "Attack on Titan", "first_air_date": "2013-04-07"}]} mock_search.return_value = {
"results": [{
"id": 1429,
"name": "Attack on Titan",
"first_air_date": "2013-04-07",
"overview": "Several hundred years ago, humans were nearly...",
}]
}
mock_details.return_value = mock_tmdb_data mock_details.return_value = mock_tmdb_data
mock_ratings.return_value = mock_content_ratings_no_de mock_ratings.return_value = mock_content_ratings_no_de
@@ -749,7 +763,14 @@ class TestNFOServiceEdgeCases:
"poster_path": None, "backdrop_path": None "poster_path": None, "backdrop_path": None
} }
mock_search.return_value = {"results": [{"id": 1, "name": "Series", "first_air_date": "2020-01-01"}]} mock_search.return_value = {
"results": [{
"id": 1,
"name": "Series",
"first_air_date": "2020-01-01",
"overview": "Test overview.",
}]
}
mock_details.return_value = tmdb_data mock_details.return_value = tmdb_data
mock_ratings.return_value = {"results": []} mock_ratings.return_value = {"results": []}
@@ -1486,6 +1507,67 @@ class TestEnrichFallbackLanguages:
content = nfo_path.read_text(encoding="utf-8") content = nfo_path.read_text(encoding="utf-8")
assert "<plot>Search result overview text.</plot>" in content assert "<plot>Search result overview text.</plot>" in content
@pytest.mark.asyncio
async def test_en_us_search_fallback_when_german_search_overview_empty(
self, nfo_service, tmp_path
):
"""When the German search overview is empty, fallback to en-US search overview."""
series_folder = tmp_path / "Rare Anime"
series_folder.mkdir()
empty_data = {
"id": 77777, "name": "Rare Anime",
"original_name": "新しいアニメ", "first_air_date": "2025-01-01",
"overview": "",
"vote_average": 0, "vote_count": 0,
"status": "Continuing", "episode_run_time": [],
"genres": [], "networks": [], "production_countries": [],
"poster_path": None, "backdrop_path": None,
"external_ids": {}, "credits": {"cast": []},
"images": {"logos": []},
}
async def search_side_effect(query, language="de-DE", page=1):
if language == "en-US":
return {
"results": [{
"id": 77777,
"name": "Rare Anime",
"first_air_date": "2025-01-01",
"overview": "English search overview text.",
}],
}
return {
"results": [{
"id": 77777,
"name": "Rare Anime",
"first_air_date": "2025-01-01",
"overview": "",
}],
}
async def details_side_effect(tv_id, **kwargs):
return empty_data
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, '_download_media_files', new_callable=AsyncMock):
mock_search.side_effect = search_side_effect
mock_details.side_effect = details_side_effect
mock_ratings.return_value = {"results": []}
nfo_path = await nfo_service.create_tvshow_nfo(
"Rare Anime", "Rare Anime",
download_poster=False, download_logo=False, download_fanart=False,
)
content = nfo_path.read_text(encoding="utf-8")
assert "<plot>English search overview text.</plot>" in content
assert mock_search.call_count == 2
assert mock_search.call_args_list[1].kwargs['language'] == 'en-US'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_japanese_fallback_when_english_succeeds( async def test_no_japanese_fallback_when_english_succeeds(
self, nfo_service, tmp_path, self, nfo_service, tmp_path,

View File

@@ -1,6 +1,7 @@
"""Unit test for NFOService.update_tvshow_nfo() - tests XML parsing logic.""" """Unit test for NFOService.update_tvshow_nfo() - tests XML parsing logic."""
import asyncio import asyncio
import logging
import shutil import shutil
import tempfile import tempfile
from pathlib import Path from pathlib import Path
@@ -8,6 +9,8 @@ from pathlib import Path
import pytest import pytest
from lxml import etree from lxml import etree
logger = logging.getLogger(__name__)
from src.core.services.nfo_service import NFOService from src.core.services.nfo_service import NFOService
from src.core.services.tmdb_client import TMDBAPIError from src.core.services.tmdb_client import TMDBAPIError
@@ -51,7 +54,7 @@ def test_parse_nfo_with_uniqueid():
break break
assert tmdb_id == 1429, f"Expected TMDB ID 1429, got {tmdb_id}" assert tmdb_id == 1429, f"Expected TMDB ID 1429, got {tmdb_id}"
print(f"Successfully parsed TMDB ID from uniqueid: {tmdb_id}") logger.info("Successfully parsed TMDB ID from uniqueid: %s", tmdb_id)
finally: finally:
shutil.rmtree(temp_dir) shutil.rmtree(temp_dir)
@@ -92,7 +95,7 @@ def test_parse_nfo_with_tmdbid_element():
tmdb_id = int(tmdbid_elem.text) tmdb_id = int(tmdbid_elem.text)
assert tmdb_id == 12345, f"Expected TMDB ID 12345, got {tmdb_id}" assert tmdb_id == 12345, f"Expected TMDB ID 12345, got {tmdb_id}"
print(f"Successfully parsed TMDB ID from tmdbid element: {tmdb_id}") logger.info("Successfully parsed TMDB ID from tmdbid element: %s", tmdb_id)
finally: finally:
shutil.rmtree(temp_dir) shutil.rmtree(temp_dir)
@@ -131,7 +134,7 @@ def test_parse_nfo_without_tmdb_id():
tmdb_id = int(tmdbid_elem.text) tmdb_id = int(tmdbid_elem.text)
assert tmdb_id is None, "Should not have found TMDB ID" assert tmdb_id is None, "Should not have found TMDB ID"
print("Correctly identified NFO without TMDB ID") logger.info("Correctly identified NFO without TMDB ID")
finally: finally:
shutil.rmtree(temp_dir) shutil.rmtree(temp_dir)
@@ -157,22 +160,23 @@ def test_parse_invalid_xml():
tree = etree.parse(str(nfo_path)) tree = etree.parse(str(nfo_path))
assert False, "Should have raised XMLSyntaxError" assert False, "Should have raised XMLSyntaxError"
except etree.XMLSyntaxError: except etree.XMLSyntaxError:
print("Correctly raised XMLSyntaxError for invalid XML") logger.info("Correctly raised XMLSyntaxError for invalid XML")
finally: finally:
shutil.rmtree(temp_dir) shutil.rmtree(temp_dir)
if __name__ == "__main__": if __name__ == "__main__":
print("Testing NFO XML parsing logic...") logging.basicConfig(level=logging.INFO, format="%(message)s")
print() logger.info("Testing NFO XML parsing logic...")
logger.info("")
test_parse_nfo_with_uniqueid() test_parse_nfo_with_uniqueid()
test_parse_nfo_with_tmdbid_element() test_parse_nfo_with_tmdbid_element()
test_parse_nfo_without_tmdb_id() test_parse_nfo_without_tmdb_id()
test_parse_invalid_xml() test_parse_invalid_xml()
print() logger.info("")
print("=" * 60) logger.info("%s", "=" * 60)
print("ALL TESTS PASSED") logger.info("ALL TESTS PASSED")
print("=" * 60) logger.info("%s", "=" * 60)

View File

@@ -187,7 +187,7 @@ class TestTemplateHelpers:
assert context["request"] == mock_request assert context["request"] == mock_request
assert context["title"] == "Test Title" assert context["title"] == "Test Title"
assert context["app_name"] == "Aniworld Download Manager" 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): def test_get_base_context_default_title(self):
"""Test getting base context with default title.""" """Test getting base context with default title."""

View File

@@ -5,11 +5,14 @@ each other. The background loader should process multiple series simultaneously
rather than sequentially. rather than sequentially.
""" """
import asyncio import asyncio
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
logger = logging.getLogger(__name__)
from src.server.services.background_loader_service import ( from src.server.services.background_loader_service import (
BackgroundLoaderService, BackgroundLoaderService,
LoadingStatus, LoadingStatus,
@@ -162,9 +165,9 @@ async def test_parallel_anime_additions(
f"(indicating sequential processing)" f"(indicating sequential processing)"
) )
print(f"Parallel execution verified:") logger.info("Parallel execution verified")
print(f" - Start time difference: {start_diff:.3f}s") logger.info("Start time difference: %.3fs", start_diff)
print(f" - Total duration: {total_duration:.3f}s") logger.info("Total duration: %.3fs", total_duration)
@pytest.mark.asyncio @pytest.mark.asyncio

Some files were not shown because too many files have changed in this diff Show More