Compare commits
102 Commits
079f1f99e3
...
v1.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
| ed8f5cae10 | |||
| a54c285994 | |||
| c58b42dfa5 | |||
| 6dfb24de7e | |||
| 6021cdef28 | |||
| 5517ccbab0 | |||
| 94ed013172 | |||
| 76b849fc91 | |||
| 00b26c8cbc | |||
| a6f2399aca | |||
| cf001563b3 | |||
| 38c12638a4 | |||
| 765e43c684 | |||
| 5190d32665 | |||
| 4e6afa31b5 | |||
| 1ef59c5283 | |||
| 239341629c | |||
| 51b7f349f8 | |||
| 14b8ef7f06 | |||
| 7abba0dae2 | |||
| 30858f441c | |||
| 33f63ca304 | |||
| fe9284b80e | |||
| 12e5526991 | |||
| bc87bee416 | |||
| 7ded5a6e4d | |||
| d596902ca3 | |||
| d358a07290 | |||
| b9c55f9e7a | |||
| fc4e52f1a2 | |||
| 6d30747f25 | |||
| ceb6a2aeb4 | |||
| 53d6da5dac | |||
| 102d83e947 | |||
| 841368bf85 | |||
| cbd53ef2a0 | |||
| 50a77976d5 | |||
| dfc28b8e66 | |||
| 6c9605e896 | |||
| 3947f6d266 | |||
| a3176f5ac1 | |||
| 9a81b04b65 | |||
| a336733ea9 | |||
| ca93bb740a | |||
| d5e955a731 | |||
| e2a373816a | |||
| a115215416 | |||
| c579235af0 | |||
| 0ba2587bc8 | |||
| bda1fe4445 | |||
| 810346bc8b | |||
| daa937bcb7 | |||
| 1c505bd722 | |||
| 3551838887 | |||
| 9a20541598 | |||
| 3f7651404d | |||
| bee24406e6 | |||
| 31eb0026cf | |||
| 24ea12bbaf | |||
| e74b602f60 | |||
| db65e28854 | |||
| 11e231a4ab | |||
| a11f8c4fa0 | |||
| cf5a06af11 | |||
| e07f75432e | |||
| 1696d5c65b | |||
| c8b386f47a | |||
| 3888da352a | |||
| 06e104db42 | |||
| d4594bd1d9 | |||
| d866e836f6 | |||
| 195dae13cb | |||
| 51be777e7d | |||
| 7930e49701 | |||
| 75c22fe296 | |||
| 7bcd0600d5 | |||
| a333329ae2 | |||
| 363f7899f8 | |||
| a08a8f7408 | |||
| 54ac5e9ab7 | |||
| c93ac3e7b8 | |||
| 68c4335348 | |||
| be87f2e230 | |||
| c56e0f507d | |||
| cb0a36ccc2 | |||
| 3644b16447 | |||
| d5116e378e | |||
| 50a7083ce5 | |||
| 52c0ff2337 | |||
| a5fd88e224 | |||
| 98d4edad14 | |||
| bc8059b453 | |||
| 815a4f1520 | |||
| e3509f5c8f | |||
| 69c2fd01f9 | |||
| 0f36afd88c | |||
| ceac22fc34 | |||
| 9c0f7ce08d | |||
| 756731cd5d | |||
| eb0e6e8ccb | |||
| eb2fc3c5ab | |||
| c39ae9d0fc |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies for compiled Python packages
|
# Install system dependencies for compiled Python packages and ffmpeg for HLS support
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
gcc \
|
gcc \
|
||||||
g++ \
|
g++ \
|
||||||
libffi-dev \
|
libffi-dev \
|
||||||
|
ffmpeg \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install Python dependencies (cached layer)
|
# Install Python dependencies (cached layer)
|
||||||
|
|||||||
1
Docker/VERSION
Normal file
1
Docker/VERSION
Normal file
@@ -0,0 +1 @@
|
|||||||
|
v1.3.1
|
||||||
@@ -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,37 @@ 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
|
||||||
|
# Add explicit route to DNS server through wg0 so it's found in main table
|
||||||
|
# (suppress_prefixlength 0 ignores default routes but allows host routes)
|
||||||
|
ip -4 route add "$dns" dev "$INTERFACE" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
echo "[vpn] DNS set to: ${VPN_DNS}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Add explicit host route for the health-check target so it is picked up by
|
||||||
|
# the 'lookup main suppress_prefixlength 0' rule (same as DNS servers above).
|
||||||
|
# Without this, CHECK_HOST falls through to the VPN table default route whose
|
||||||
|
# source-address selection can be defeated by the priority-100 'from ETH0_IP'
|
||||||
|
# policy rule, causing pings to bypass wg0 and be dropped by the kill switch.
|
||||||
|
ip -4 route add "${CHECK_HOST}" dev "$INTERFACE" 2>/dev/null || true
|
||||||
|
echo "[vpn] Health-check route: ${CHECK_HOST} → ${INTERFACE}"
|
||||||
|
|
||||||
echo "[vpn] WireGuard interface ${INTERFACE} is up."
|
echo "[vpn] WireGuard interface ${INTERFACE} is up."
|
||||||
|
echo "[vpn] Main routes:"
|
||||||
|
ip route show | sed 's/^/[vpn] /'
|
||||||
|
echo "[vpn] VPN table ($FW_TABLE):"
|
||||||
|
ip route show table "$FW_TABLE" 2>/dev/null | sed 's/^/[vpn] /'
|
||||||
|
echo "[vpn] Policy rules:"
|
||||||
|
ip rule show | sed 's/^/[vpn] /'
|
||||||
|
echo "[vpn] WireGuard status:"
|
||||||
|
wg show "$INTERFACE" 2>/dev/null | sed 's/^/[vpn] /'
|
||||||
}
|
}
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
@@ -170,6 +222,21 @@ start_vpn() {
|
|||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
stop_vpn() {
|
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 +252,31 @@ 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
|
||||||
|
# Secondary DNS check
|
||||||
|
if ping -c 1 -W 5 "google.com" > /dev/null 2>&1; then
|
||||||
|
: # DNS OK — silent
|
||||||
|
else
|
||||||
|
echo "[health] WARN google.com unreachable — possible DNS issue"
|
||||||
|
fi
|
||||||
else
|
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"
|
||||||
|
# Secondary check: distinguish IP failure from DNS failure
|
||||||
|
if ping -c 1 -W 5 "google.com" > /dev/null 2>&1; then
|
||||||
|
echo "[health] INFO google.com reachable — DNS works, ${CHECK_HOST} may be filtered"
|
||||||
|
else
|
||||||
|
echo "[health] INFO google.com also unreachable — DNS or general routing failure"
|
||||||
|
fi
|
||||||
|
# Dump WireGuard stats to show if handshake is stale and how much data flows
|
||||||
|
echo "[health] wg stats:"
|
||||||
|
wg show "$INTERFACE" 2>/dev/null | grep -E 'latest handshake|transfer|endpoint' | sed 's/^/[health] /' || echo "[health] wg0 not found"
|
||||||
|
echo "[health] routes:"
|
||||||
|
ip route show | grep -E 'wg0|default' | sed 's/^/[health] /'
|
||||||
|
|
||||||
if [ "$failures" -ge "$max_failures" ]; then
|
if [ "$failures" -ge "$max_failures" ]; then
|
||||||
echo "[health] VPN appears down. Restarting WireGuard..."
|
echo "[health] VPN appears down. Restarting WireGuard..."
|
||||||
@@ -221,8 +305,83 @@ 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"
|
||||||
|
echo "[check] ── End of checks ──"
|
||||||
|
exit 1
|
||||||
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,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
|
||||||
@@ -22,7 +23,7 @@ services:
|
|||||||
- /server/server_aniworld/wg0.conf:/etc/wireguard/wg0.conf:ro
|
- /server/server_aniworld/wg0.conf:/etc/wireguard/wg0.conf:ro
|
||||||
- /lib/modules:/lib/modules:ro
|
- /lib/modules:/lib/modules:ro
|
||||||
ports:
|
ports:
|
||||||
- "2000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
- HEALTH_CHECK_INTERVAL=10
|
- HEALTH_CHECK_INTERVAL=10
|
||||||
- HEALTH_CHECK_HOST=1.1.1.1
|
- HEALTH_CHECK_HOST=1.1.1.1
|
||||||
@@ -51,4 +52,5 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /server/server_aniworld/data:/app/data
|
- /server/server_aniworld/data:/app/data
|
||||||
- /server/server_aniworld/logs:/app/logs
|
- /server/server_aniworld/logs:/app/logs
|
||||||
|
- /media/serien/Serien:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 "\n❌ ERROR: $*" >&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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
push_app() {
|
||||||
log "Pushing ${APP_IMAGE}:${TAG}"
|
log "Pushing ${APP_IMAGE}:${TAG}"
|
||||||
podman push "${APP_IMAGE}:${TAG}"
|
"${ENGINE}" push "${APP_IMAGE}:${TAG}"
|
||||||
|
}
|
||||||
|
|
||||||
|
push_vpn() {
|
||||||
log "Pushing ${VPN_IMAGE}:${TAG}"
|
log "Pushing ${VPN_IMAGE}:${TAG}"
|
||||||
podman push "${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
129
Docker/release.sh
Normal 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."
|
||||||
@@ -6,23 +6,29 @@ 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 logging
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
import os
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -35,6 +41,11 @@ STARTUP_TIMEOUT = 30 # seconds to wait for VPN to come up
|
|||||||
HEALTH_POLL_INTERVAL = 2 # seconds between health checks
|
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)
|
||||||
@@ -55,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):
|
||||||
@@ -84,6 +96,12 @@ class TestVPNImage(unittest.TestCase):
|
|||||||
assert result.returncode == 0, f"Build failed:\n{result.stderr}"
|
assert result.returncode == 0, f"Build failed:\n{result.stderr}"
|
||||||
logger.info("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 ──
|
||||||
logger.info("Starting container '%s'...", CONTAINER_NAME)
|
logger.info("Starting container '%s'...", CONTAINER_NAME)
|
||||||
result = run(
|
result = run(
|
||||||
@@ -120,6 +138,8 @@ class TestVPNImage(unittest.TestCase):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def tearDownClass(cls):
|
def tearDownClass(cls):
|
||||||
"""Stop and remove the container."""
|
"""Stop and remove the container."""
|
||||||
|
if not is_root():
|
||||||
|
return
|
||||||
logger.info("Cleaning up test container...")
|
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)
|
||||||
logger.info("Cleanup complete.")
|
logger.info("Cleanup complete.")
|
||||||
@@ -144,10 +164,22 @@ 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()
|
||||||
logger.info("VPN public IP: %s", vpn_ip)
|
logger.info("VPN public IP: %s", vpn_ip)
|
||||||
logger.info("Host public IP: %s", self.host_ip)
|
logger.info("Host public IP: %s", self.host_ip)
|
||||||
@@ -161,12 +193,42 @@ class TestVPNImage(unittest.TestCase):
|
|||||||
|
|
||||||
def test_02_wireguard_interface_exists(self):
|
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}")
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
[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
|
||||||
|
DNS = 8.8.8.8
|
||||||
|
|
||||||
|
# Route zum VPN-Server direkt über dein lokales Netz
|
||||||
|
PostUp = ip route add 91.148.236.64 via 192.168.178.1 dev wlp4s0f0
|
||||||
|
PostUp = ip route add 192.168.178.0/24 via 192.168.178.1 dev wlp4s0f0
|
||||||
|
PostDown = ip route del 91.148.236.64 via 192.168.178.1 dev wlp4s0f0
|
||||||
|
PostDown = ip route del 192.168.178.0/24 via 192.168.178.1 dev wlp4s0f0
|
||||||
|
|
||||||
[Peer]
|
[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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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, key resolution, renaming, poster checks) during scheduled runs
|
||||||
```
|
```
|
||||||
|
|
||||||
### 12.2 Temp Folder Guarantee
|
### 12.2 Temp Folder Guarantee
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Encoding detection for HTML parsing** (`src/core/providers/aniworld_provider.py`):
|
||||||
|
Added `_decode_html_content()` function that uses `chardet` to detect the actual
|
||||||
|
encoding of HTML content before parsing. Falls back to UTF-8 with `errors='replace'`
|
||||||
|
to handle pages with mismatched encoding declarations. Applied to all BeautifulSoup
|
||||||
|
parsing calls to prevent "Some characters could not be decoded" warnings.
|
||||||
|
- **chardet dependency**: Added `chardet>=5.2.0` to `requirements.txt` for encoding detection.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
- **Temp file cleanup after every download** (`src/core/providers/aniworld_provider.py`,
|
- **Temp file cleanup after every download** (`src/core/providers/aniworld_provider.py`,
|
||||||
`src/core/providers/enhanced_provider.py`): Module-level helper
|
`src/core/providers/enhanced_provider.py`): Module-level helper
|
||||||
`_cleanup_temp_file()` removes the working temp file and any yt-dlp `.part`
|
`_cleanup_temp_file()` removes the working temp file and any yt-dlp `.part`
|
||||||
@@ -73,17 +82,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
|
||||||
|
|
||||||
@@ -131,6 +139,10 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
|
|||||||
- Modified `src/server/api/anime.py` to save scanned episodes to database
|
- Modified `src/server/api/anime.py` to save scanned episodes to database
|
||||||
- Episodes table properly tracks missing episodes with automatic cleanup
|
- Episodes table properly tracks missing episodes with automatic cleanup
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- **Legacy Series Files (key/data)**: File-based series storage is deprecated. `key` and `data` files in anime folders will be removed in v3.0.0. Database storage is now the primary method. See [docs/MIGRATION_GUIDE.md](docs/MIGRATION_GUIDE.md) for details.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sections for Each Release
|
## Sections for Each Release
|
||||||
|
|||||||
@@ -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,6 +218,7 @@ 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`
|
- `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
|
||||||
|
|||||||
200
docs/DATABASE.md
200
docs/DATABASE.md
@@ -83,15 +83,21 @@ Source: [src/server/database/models.py](../src/server/database/models.py), [src/
|
|||||||
|
|
||||||
### 3.2 anime_series
|
### 3.2 anime_series
|
||||||
|
|
||||||
Stores anime series metadata.
|
Stores anime series metadata. Corresponds to the core `Serie` class.
|
||||||
|
|
||||||
| Column | Type | Constraints | Description |
|
| Column | Type | Constraints | Description |
|
||||||
| ------------ | ------------- | -------------------------- | ------------------------------------------------------- |
|
| ---------------- | ------------- | -------------------------- | ------------------------------------------------------- |
|
||||||
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Internal database ID |
|
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Internal database ID |
|
||||||
| `key` | VARCHAR(255) | UNIQUE, NOT NULL, INDEX | **Primary identifier** - provider-assigned URL-safe key |
|
| `key` | VARCHAR(255) | UNIQUE, NOT NULL, INDEX | **Primary identifier** - provider-assigned URL-safe key |
|
||||||
| `name` | VARCHAR(500) | NOT NULL, INDEX | Display name of the series |
|
| `name` | VARCHAR(500) | NOT NULL, INDEX | Display name of the series |
|
||||||
| `site` | VARCHAR(500) | NOT NULL | Provider site URL |
|
| `site` | VARCHAR(500) | NOT NULL | Provider site URL |
|
||||||
| `folder` | VARCHAR(1000) | NOT NULL | Filesystem folder name (metadata only) |
|
| `folder` | VARCHAR(1000) | NOT NULL | Filesystem folder name (metadata only) |
|
||||||
|
| `year` | INTEGER | NULLABLE | Release year of the series |
|
||||||
|
| `nfo_path` | VARCHAR(1000) | NULLABLE | Path to tvshow.nfo metadata file |
|
||||||
|
| `tmdb_id` | INTEGER | NULLABLE, INDEX | TMDB (The Movie Database) ID for metadata |
|
||||||
|
| `tvdb_id` | INTEGER | NULLABLE, INDEX | TVDB (TheTVDB) ID for metadata |
|
||||||
|
| `has_nfo` | BOOLEAN | NOT NULL, DEFAULT FALSE | Whether tvshow.nfo exists |
|
||||||
|
| `loading_status` | VARCHAR(50) | NOT NULL, DEFAULT 'completed' | Status: pending, loading_episodes, loading_nfo, completed, failed |
|
||||||
| `created_at` | DATETIME | NOT NULL, DEFAULT NOW | Record creation timestamp |
|
| `created_at` | DATETIME | NOT NULL, DEFAULT NOW | Record creation timestamp |
|
||||||
| `updated_at` | DATETIME | NOT NULL, ON UPDATE NOW | Last update timestamp |
|
| `updated_at` | DATETIME | NOT NULL, ON UPDATE NOW | Last update timestamp |
|
||||||
|
|
||||||
@@ -101,7 +107,13 @@ Stores anime series metadata.
|
|||||||
- `folder` is **metadata only** for filesystem operations (e.g., `"Attack on Titan (2013)"`)
|
- `folder` is **metadata only** for filesystem operations (e.g., `"Attack on Titan (2013)"`)
|
||||||
- `id` is used only for database relationships
|
- `id` is used only for database relationships
|
||||||
|
|
||||||
Source: [src/server/database/models.py](../src/server/database/models.py#L23-L87)
|
**EpisodeDict Mapping:**
|
||||||
|
|
||||||
|
The `episodeDict` (season → episode numbers mapping) is stored as individual `Episode` records:
|
||||||
|
- Each `Episode` has `season` and `episode_number` columns
|
||||||
|
- Relationship: `AnimeSeries.episodes` returns all Episode records for that series
|
||||||
|
|
||||||
|
Source: [src/server/database/models.py](../src/server/database/models.py#L23-L150)
|
||||||
|
|
||||||
### 3.3 episodes
|
### 3.3 episodes
|
||||||
|
|
||||||
@@ -441,7 +453,187 @@ items = await db.execute(
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Database Location
|
## 12. Series Storage: Database vs Files (Deprecated)
|
||||||
|
|
||||||
|
### File-Based Storage (Removed in v2.0)
|
||||||
|
|
||||||
|
Prior to v2.0, series metadata was stored in two files per anime folder:
|
||||||
|
|
||||||
|
| File | Contents |
|
||||||
|
| -------- | ------------------------------------------------------- |
|
||||||
|
| `key` | Series provider key (e.g., `"attack-on-titan"`) |
|
||||||
|
| `data` | JSON serialization of `Serie` object |
|
||||||
|
|
||||||
|
File structure example:
|
||||||
|
```
|
||||||
|
/anime/Attack on Titan (2013)/
|
||||||
|
├── key # Contains: attack-on-titan
|
||||||
|
├── data # Contains: {"key": "...", "name": "...", "episodeDict": {...}}
|
||||||
|
├── Season 1/
|
||||||
|
│ └── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Storage (Current)
|
||||||
|
|
||||||
|
Since v2.0, all series metadata is stored in the `anime_series` table with `Episode` records for episode tracking. This provides:
|
||||||
|
|
||||||
|
- **ACID transactions** for data consistency
|
||||||
|
- **Foreign key constraints** (cascade delete)
|
||||||
|
- **Indexed queries** for fast lookups
|
||||||
|
- **No filesystem dependency** for metadata
|
||||||
|
|
||||||
|
### Migration from Files to Database
|
||||||
|
|
||||||
|
The `Serie.save_to_file()` and `Serie.load_from_file()` methods are deprecated but still functional for backward compatibility during migration:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.core.entities.series import Serie
|
||||||
|
|
||||||
|
# Old file-based loading (deprecated)
|
||||||
|
serie = Serie.load_from_file("/anime/Attack on Titan (2013)/data")
|
||||||
|
|
||||||
|
# New database-based loading
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
serie = await AnimeSeriesService.get_by_key(db, "attack-on-titan")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Removing File Dependencies
|
||||||
|
|
||||||
|
After verifying database schema supports all fields, file-based storage can be removed:
|
||||||
|
|
||||||
|
1. ✅ Schema verified: All `Serie` fields have corresponding DB columns
|
||||||
|
2. ✅ Migration complete: All existing series migrated to database
|
||||||
|
3. ❌ File cleanup: Remove `key` and `data` files (pending)
|
||||||
|
|
||||||
|
**Note:** The `save_to_file()` and `load_from_file()` methods will be removed in v3.0.0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Series Persistence Flow
|
||||||
|
|
||||||
|
When a directory scan discovers or updates series, the scanner persists data to the database instead of writing to disk files.
|
||||||
|
|
||||||
|
### Scan Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Scan Directory
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Find MP4 Files → Extract Serie Key
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Check DB for Existing Series (by key)
|
||||||
|
│
|
||||||
|
├─── EXISTS ──────────────────────► Update Series Metadata
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ Sync Episodes to DB
|
||||||
|
│ │
|
||||||
|
│◄──────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
└─── NEW ───────────────────────────► Create New Series Record
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Create Episode Records
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Return to Scan Loop
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Methods
|
||||||
|
|
||||||
|
**SerieScanner._persist_serie_to_db()**
|
||||||
|
- Called after `get_missing_episodes_and_season()` computes episodeDict
|
||||||
|
- Uses `AnimeSeriesService.get_by_key()` to check if series exists
|
||||||
|
- If exists: calls `AnimeSeriesService.update()` + `_sync_episodes_to_db()`
|
||||||
|
- If new: calls `AnimeSeriesService.create()` + creates episodes
|
||||||
|
|
||||||
|
**SerieScanner._sync_episodes_to_db()**
|
||||||
|
- Gets existing episodes from DB via `EpisodeService.get_by_series()`
|
||||||
|
- Compares with new episodeDict
|
||||||
|
- Removes episodes no longer missing (unless `is_downloaded=True`)
|
||||||
|
- Adds new missing episodes
|
||||||
|
- Preserves `is_downloaded=True` episodes when removing missing ones
|
||||||
|
|
||||||
|
**SerieList.add_to_db()**
|
||||||
|
- Used when adding a new discovered series via API
|
||||||
|
- Creates filesystem folder + database record + episode records
|
||||||
|
|
||||||
|
### Episode Sync Logic
|
||||||
|
|
||||||
|
```python
|
||||||
|
# For each episode in DB but not in new episodeDict:
|
||||||
|
if episode.is_downloaded:
|
||||||
|
# Keep - file exists, don't remove
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Remove - no longer missing
|
||||||
|
EpisodeService.delete()
|
||||||
|
|
||||||
|
# For each episode in new episodeDict but not in DB:
|
||||||
|
# Add as new missing episode
|
||||||
|
EpisodeService.create(is_downloaded=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transaction Handling
|
||||||
|
|
||||||
|
- DB operations use their own session with commit/rollback
|
||||||
|
- If DB write fails, error is logged and scan continues
|
||||||
|
- File-based `save_to_file()` no longer called during scan
|
||||||
|
|
||||||
|
### Migration Path
|
||||||
|
|
||||||
|
1. v2.x: Scanner writes to both DB (primary) and files (fallback)
|
||||||
|
2. v3.0: Scanner writes only to DB, file methods removed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Series Persistence
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
|
||||||
|
**AnimeSeries Table**: Stores series metadata (key, name, site, folder, year)
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|-----------|--------------|---------------------------|----------------------|
|
||||||
|
| `id` | INTEGER | PRIMARY KEY | Auto-increment |
|
||||||
|
| `key` | VARCHAR(255) | UNIQUE, NOT NULL | Series provider key |
|
||||||
|
| `name` | VARCHAR(500) | NOT NULL | Display name |
|
||||||
|
| `site` | VARCHAR(500) | | Provider site URL |
|
||||||
|
| `folder` | VARCHAR(1000)| | Filesystem folder |
|
||||||
|
|
||||||
|
**Episode Table**: Stores per-episode metadata (season, episode_number, is_downloaded)
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
|-----------------|--------------|---------------------------|----------------------|
|
||||||
|
| `id` | INTEGER | PRIMARY KEY | Auto-increment |
|
||||||
|
| `series_id` | INTEGER | FOREIGN KEY → anime_series| Parent series |
|
||||||
|
| `season` | INTEGER | NOT NULL | Season number |
|
||||||
|
| `episode_number`| INTEGER | NOT NULL | Episode number |
|
||||||
|
| `is_downloaded` | BOOLEAN | DEFAULT FALSE | Download status |
|
||||||
|
|
||||||
|
### Relationships
|
||||||
|
|
||||||
|
- `AnimeSeries.episodes` → List of Episode objects (one-to-many)
|
||||||
|
- `Episode.series` → Parent AnimeSeries (many-to-one)
|
||||||
|
- Cascade delete: Deleting a series removes all its episodes
|
||||||
|
|
||||||
|
### Queries
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Get all series with episodes
|
||||||
|
AnimeSeriesService.get_all(db, with_episodes=True)
|
||||||
|
|
||||||
|
# Get by provider key
|
||||||
|
AnimeSeriesService.get_by_key(db, key)
|
||||||
|
|
||||||
|
# Get by folder path
|
||||||
|
AnimeSeriesService.get_by_folder(db, folder)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Database Location
|
||||||
|
|
||||||
| Environment | Default Location |
|
| Environment | Default Location |
|
||||||
| ----------- | ------------------------------------------------- |
|
| ----------- | ------------------------------------------------- |
|
||||||
|
|||||||
@@ -61,4 +61,376 @@ This document provides guidance for developers working on the Aniworld project.
|
|||||||
- Commit message format
|
- Commit message format
|
||||||
- Pull request process
|
- Pull request process
|
||||||
8. Common Development Tasks
|
8. Common Development Tasks
|
||||||
|
|
||||||
|
### Adding Queue Deduplication
|
||||||
|
|
||||||
|
The download queue prevents duplicate entries at two levels:
|
||||||
|
|
||||||
|
**In-Memory Deduplication** (`src/server/services/download_service.py`):
|
||||||
|
- `_pending_by_episode` dict tracks pending episodes: key = `(serie_id, season, episode)`
|
||||||
|
- `_add_to_pending_queue()` updates the dict when adding items
|
||||||
|
- `add_to_queue()` checks this dict before adding episodes (includes batch-local dedup)
|
||||||
|
- `_remove_from_pending_queue()` cleans up the dict when items are removed
|
||||||
|
|
||||||
|
**Database Constraint** (`src/server/models.py`):
|
||||||
|
- `DownloadQueueItem` has a unique index on `episode_id` via `__table_args__`
|
||||||
|
- Prevents duplicate queue entries at the database level
|
||||||
|
- Unique constraint: `Index("ix_download_queue_episode_pending", "episode_id", unique=True)`
|
||||||
|
|
||||||
|
**Scheduler Cooldown** (`src/server/services/scheduler_service.py`):
|
||||||
|
- `_last_auto_download_time` tracks when auto-download last ran
|
||||||
|
- 5-minute cooldown prevents rapid re-triggers
|
||||||
|
- Checked at start of `_auto_download_missing()`
|
||||||
|
|
||||||
|
### Episode Lifecycle
|
||||||
|
|
||||||
|
Episodes transition through states stored in the `episodes` table:
|
||||||
|
|
||||||
|
| State | `is_downloaded` | `file_path` | Description |
|
||||||
|
|-------|----------------|-------------|-------------|
|
||||||
|
| Missing | `False` | `NULL` | Episode not yet downloaded |
|
||||||
|
| Downloaded | `True` | Set | Episode exists on disk |
|
||||||
|
|
||||||
|
**State Transitions:**
|
||||||
|
1. **Missing → Downloaded**: When download completes, `_remove_episode_from_missing_list()` calls `EpisodeService.mark_downloaded()` to set `is_downloaded=True` and populate `file_path`. The episode record is NOT deleted.
|
||||||
|
|
||||||
|
**Query Implications:**
|
||||||
|
- `get_series_with_missing_episodes()`: Filters for `is_downloaded=False` to find series with undownloaded episodes
|
||||||
|
- `get_series_with_no_episodes()`: Finds series with `is_downloaded=False` episodes but NO `is_downloaded=True` episodes (completely unwatched series)
|
||||||
|
|
||||||
|
### Mocking the Download Queue
|
||||||
|
|
||||||
|
When testing components that use the download queue:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Mock repository for unit tests
|
||||||
|
class MockQueueRepository:
|
||||||
|
def __init__(self):
|
||||||
|
self._items: Dict[str, DownloadItem] = {}
|
||||||
|
|
||||||
|
async def save_item(self, item: DownloadItem) -> DownloadItem:
|
||||||
|
self._items[item.id] = item
|
||||||
|
return item
|
||||||
|
|
||||||
|
async def get_all_items(self) -> List[DownloadItem]:
|
||||||
|
return list(self._items.values())
|
||||||
|
|
||||||
|
# Use in fixture
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_queue_repository():
|
||||||
|
return MockQueueRepository()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def download_service(mock_anime_service, mock_queue_repository):
|
||||||
|
return DownloadService(
|
||||||
|
anime_service=mock_anime_service,
|
||||||
|
queue_repository=mock_queue_repository,
|
||||||
|
max_retries=3,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
9. Troubleshooting Development Issues
|
9. Troubleshooting Development Issues
|
||||||
|
|
||||||
|
### Async Context Managers for aiohttp
|
||||||
|
|
||||||
|
All `aiohttp.ClientSession` usages must be wrapped in `async with`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Correct — session properly closed on exit
|
||||||
|
async with TMDBClient(api_key="key") as client:
|
||||||
|
result = await client.search_tv_show("Show")
|
||||||
|
|
||||||
|
# Wrong — session may leak if exception occurs
|
||||||
|
client = TMDBClient(api_key="key")
|
||||||
|
result = await client.search_tv_show("Show")
|
||||||
|
await client.close() # May not be called if exception raised earlier
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
- `aiohttp.ClientSession` holds TCP connections that must be explicitly closed
|
||||||
|
- If exception occurs before `close()`, session leaks
|
||||||
|
- Context manager guarantees `__aexit__` runs even on exceptions
|
||||||
|
|
||||||
|
**Services that use aiohttp:**
|
||||||
|
- `TMDBClient` — has `__aenter__`/`__aexit__`, use `async with`
|
||||||
|
- `ImageDownloader` — has `__aenter__`/`__aexit__`, use `async with`
|
||||||
|
- `NFOService` — wraps both above, use `async with`
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- Missing context manager usage triggers `__del__` warning on garbage collection
|
||||||
|
- Integration tests verify no "Unclosed client session" errors in logs
|
||||||
|
|
||||||
|
### Scheduler Persistence and Recovery
|
||||||
|
|
||||||
|
The scheduler uses APScheduler's in-memory job store. Jobs are reconstructed from `config.json` on every startup — no separate database is needed.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Jobs are built from config on startup — no persistence DB required
|
||||||
|
scheduler = AsyncIOScheduler() # default MemoryJobStore
|
||||||
|
scheduler.add_job(..., replace_existing=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Startup misfire recovery:** On `start()`, the scheduler checks `system_settings.last_scan_timestamp` in `aniworld.db`. If the last scan is overdue (>23h but <25h ago), an immediate rescan is triggered. This replaces APScheduler's built-in misfire handling which required a separate SQLite database.
|
||||||
|
|
||||||
|
**Grace period:** If the server was down for more than 25 hours, no automatic recovery occurs to avoid surprise rescans after long downtime.
|
||||||
|
|
||||||
|
**Health endpoint:** `GET /health` returns `scheduler_next_run` and `scheduler_last_run` for external monitors (Uptime Kuma, Prometheus, etc.).
|
||||||
|
|
||||||
|
**If server is down too long:** Manual trigger via `POST /api/scheduler/trigger-rescan` or wait for next scheduled run.
|
||||||
|
|
||||||
|
### Database Session Management
|
||||||
|
|
||||||
|
`get_async_session_factory()` returns a **new AsyncSession instance** directly (not a factory). The function name is historical — callers receive the session immediately:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Correct usage:
|
||||||
|
db = get_async_session_factory() # db IS the session
|
||||||
|
await db.execute(...)
|
||||||
|
await db.commit()
|
||||||
|
await db.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
Do NOT call the result again with `()` — that tries to call an `AsyncSession` object, causing `'AsyncSession' object is not callable`.
|
||||||
|
|
||||||
|
For context manager usage, prefer `get_db_session()` (auto-commits) or `get_transactional_session()` (manual commit).
|
||||||
|
|
||||||
|
### Health Check Endpoints
|
||||||
|
|
||||||
|
The application provides health check endpoints for monitoring and container orchestration:
|
||||||
|
|
||||||
|
#### `GET /health`
|
||||||
|
Basic health check returning service status and startup health check results.
|
||||||
|
|
||||||
|
**Response fields:**
|
||||||
|
- `status`: "healthy", "degraded", or "unhealthy" based on startup checks
|
||||||
|
- `timestamp`: ISO timestamp of the check
|
||||||
|
- `series_app_initialized`: Whether the series app is loaded
|
||||||
|
- `anime_directory_configured`: Whether anime_directory is set
|
||||||
|
- `scheduler_next_run` / `scheduler_last_run`: Scheduler times
|
||||||
|
- `checks`: Detailed startup check results (ffmpeg, DNS, anime_directory)
|
||||||
|
|
||||||
|
#### `GET /health/ready`
|
||||||
|
Readiness check for container orchestrators (Kubernetes, Docker Swarm).
|
||||||
|
|
||||||
|
**Response when ready:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ready",
|
||||||
|
"ready": true,
|
||||||
|
"timestamp": "2024-01-01T00:00:00",
|
||||||
|
"checks": {...}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response when not ready (503):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "not_ready",
|
||||||
|
"ready": false,
|
||||||
|
"timestamp": "2024-01-01T00:00:00",
|
||||||
|
"critical_failures": ["anime_directory: not configured"],
|
||||||
|
"checks": {...}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `GET /health/detailed`
|
||||||
|
Comprehensive health check including database, filesystem, and system metrics.
|
||||||
|
|
||||||
|
#### Startup Health Checks
|
||||||
|
|
||||||
|
On application startup, the following checks are performed:
|
||||||
|
|
||||||
|
| Check | Failure Status | Impact |
|
||||||
|
|-------|---------------|--------|
|
||||||
|
| `ffmpeg` | warning | HLS downloads may fail |
|
||||||
|
| `dns_aniworld` | warning | Provider requests may fail |
|
||||||
|
| `dns_tmdb` | warning | TMDB API calls may fail |
|
||||||
|
| `anime_directory` | error | Download service disabled |
|
||||||
|
|
||||||
|
DNS checks are warnings because failures can be transient. anime_directory errors disable the download service to prevent failures.
|
||||||
|
|
||||||
|
### Troubleshooting Development Issues
|
||||||
|
|
||||||
|
#### Scheduler missed a run
|
||||||
|
|
||||||
|
1. Server was down at scheduled time (03:00 UTC by default).
|
||||||
|
2. On restart, the scheduler checks `last_scan_timestamp` — if overdue by 23-25h, it triggers immediately.
|
||||||
|
3. If server was down >25 hours, missed job is skipped to avoid surprise rescans.
|
||||||
|
4. Trigger manually: `POST /api/scheduler/trigger-rescan`
|
||||||
|
5. Monitor next run: `GET /health` → `scheduler_next_run`
|
||||||
|
|
||||||
|
#### Scheduler not firing (no events at scheduled time)
|
||||||
|
|
||||||
|
If the scheduler appears configured but never triggers:
|
||||||
|
|
||||||
|
1. **Check application logs for scheduler startup:**
|
||||||
|
```
|
||||||
|
grep "Scheduler service started" fastapi_app.log
|
||||||
|
```
|
||||||
|
- If missing, the scheduler failed to start — check for errors above this line
|
||||||
|
- If present, scheduler started successfully
|
||||||
|
|
||||||
|
2. **Verify the job is registered:**
|
||||||
|
```
|
||||||
|
grep "Scheduler started with cron trigger" fastapi_app.log
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Verify APScheduler events in logs:**
|
||||||
|
```
|
||||||
|
grep "apscheduler.executors.default" fastapi_app.log
|
||||||
|
```
|
||||||
|
- `Running job` = job triggered
|
||||||
|
- `executed successfully` = job completed
|
||||||
|
- No output = job never fired
|
||||||
|
|
||||||
|
4. **Test manual trigger:**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/scheduler/trigger-rescan -H "Authorization: Bearer <token>"
|
||||||
|
```
|
||||||
|
- If manual trigger works but cron doesn't, the issue is APScheduler configuration
|
||||||
|
|
||||||
|
5. **Check next_run_time via health endpoint:**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/health | jq .scheduler_next_run
|
||||||
|
```
|
||||||
|
- If `null`, the job is not scheduled
|
||||||
|
- If set, the scheduler knows when to run next
|
||||||
|
|
||||||
|
6. **Check timezone handling:**
|
||||||
|
- APScheduler uses UTC internally
|
||||||
|
- The schedule_time config (e.g., "03:00") is interpreted as UTC
|
||||||
|
- If you expect local time, adjust the schedule_time accordingly
|
||||||
|
|
||||||
|
#### Startup health check failures
|
||||||
|
|
||||||
|
If `/health` returns `unhealthy` status:
|
||||||
|
|
||||||
|
1. **anime_directory error**: Directory not configured or not writable
|
||||||
|
- Check `ANIME_DIRECTORY` environment variable
|
||||||
|
- Verify directory exists and permissions allow write access
|
||||||
|
- Download service will not initialize until resolved
|
||||||
|
|
||||||
|
2. **ffmpeg warning**: ffmpeg not found in PATH
|
||||||
|
- HLS stream downloads will fail
|
||||||
|
- Install ffmpeg: `apt install ffmpeg` or `brew install ffmpeg`
|
||||||
|
|
||||||
|
3. **DNS warnings**: Domain resolution failed
|
||||||
|
- Check network connectivity
|
||||||
|
- DNS failures are transient — warnings don't block startup
|
||||||
|
- Retry later to verify: `GET /health`
|
||||||
|
|
||||||
|
### Provider Failure Handling
|
||||||
|
|
||||||
|
Download providers (VOE, Doodstream, Vidmoly, Vidoza, SpeedFiles, Streamtape,
|
||||||
|
Luluvdo) regularly break: URLs expire, sites change their player markup, geo
|
||||||
|
blocks appear, and `yt-dlp` extractors lag behind upstream changes. The
|
||||||
|
`AniworldLoader.download()` flow is designed to fail fast and rotate.
|
||||||
|
|
||||||
|
**Rotation order**
|
||||||
|
|
||||||
|
1. The episode page is scraped for the providers AniWorld actually advertises.
|
||||||
|
2. Results are ordered by the preference in `DEFAULT_PROVIDERS`
|
||||||
|
(`provider_config.py`); providers not listed run last.
|
||||||
|
3. For each candidate the loader:
|
||||||
|
1. Calls `_check_url_alive()` — HEAD probe with GET fallback. Any 4xx
|
||||||
|
response or connection error skips the provider immediately.
|
||||||
|
2. Resolves the redirect via `_resolve_direct_link()` to obtain a direct
|
||||||
|
stream URL plus headers. Provider-specific extractors (e.g. `VOE`) are
|
||||||
|
preferred; unknown providers fall back to the embed URL so `yt-dlp` can
|
||||||
|
attempt extraction.
|
||||||
|
3. Tries `_try_direct_stream()` — straight `requests.get(stream=True)` when
|
||||||
|
`Content-Type` is `video/*` or `application/octet-stream`. This avoids
|
||||||
|
`yt-dlp` entirely for direct MP4 links.
|
||||||
|
4. Falls back to `yt-dlp` with the ffmpeg downloader for HLS streams.
|
||||||
|
4. On any failure, temp files are cleaned and the loop moves to the next
|
||||||
|
provider. When the chain is exhausted, the loader logs
|
||||||
|
`All download providers failed for S{season}E{episode} ...; tried=[...]`
|
||||||
|
to both the application log and `logs/download_errors.log`.
|
||||||
|
|
||||||
|
**Do not hardcode provider URLs.** Provider domains shift constantly (e.g.
|
||||||
|
Doodstream alternates between `dood.li`, `dood.so`, `dood.la`). Only the
|
||||||
|
referer hints in `PROVIDER_HEADERS` are persisted — discovery still happens
|
||||||
|
at runtime through AniWorld's redirect endpoint.
|
||||||
|
|
||||||
|
### HLS Stream Handling
|
||||||
|
|
||||||
|
HLS (HTTP Live Streaming) manifests (`.m3u8`) require yt-dlp to use the
|
||||||
|
`ffmpeg` downloader with `--hls-use-mpegts`. Both providers configure this
|
||||||
|
automatically:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ydl_opts = {
|
||||||
|
"downloader": "ffmpeg", # Use ffmpeg instead of native
|
||||||
|
"hls_use_mpegts": True, # Write transport stream (.ts) segments
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this matters**: Without ffmpeg, yt-dlp logs:
|
||||||
|
`"Live HLS streams are not supported by the native downloader"`
|
||||||
|
|
||||||
|
**Requirements**:
|
||||||
|
- ffmpeg must be installed and in PATH (`which ffmpeg`)
|
||||||
|
- Install: `apt install ffmpeg` (Debian/Ubuntu) or `brew install ffmpeg` (macOS)
|
||||||
|
- Startup health check (see Health Check Endpoints) verifies ffmpeg presence
|
||||||
|
|
||||||
|
**Trade-offs**:
|
||||||
|
- HLS downloads are slower than direct MP4 (reassembly of .ts segments)
|
||||||
|
- Requires more disk space during download
|
||||||
|
- May need post-processing if .ts format is not desired
|
||||||
|
|
||||||
|
**Detection**: VOE provider extracts HLS URLs via `HLS_PATTERN` regex. Other
|
||||||
|
providers let yt-dlp auto-detect from URL/content-type.
|
||||||
|
|
||||||
|
### Updating yt-dlp
|
||||||
|
|
||||||
|
When extractors break (typical symptoms: every provider HEAD probe succeeds
|
||||||
|
but `yt-dlp` raises `Unable to extract` or `HTTP Error 404`):
|
||||||
|
|
||||||
|
1. Check the upstream tracker first: https://github.com/yt-dlp/yt-dlp/issues
|
||||||
|
2. Upgrade in the conda environment:
|
||||||
|
```bash
|
||||||
|
conda run -n AniWorld pip install --upgrade yt-dlp
|
||||||
|
```
|
||||||
|
3. Smoke-test against a known-good episode before pinning a new floor in
|
||||||
|
`requirements.txt` (`yt-dlp>=YYYY.MM.DD`).
|
||||||
|
4. Re-run the provider test suite:
|
||||||
|
```bash
|
||||||
|
conda run -n AniWorld python -m pytest tests/unit/test_aniworld_provider.py -v
|
||||||
|
```
|
||||||
|
5. If a specific extractor is removed upstream, drop the provider from
|
||||||
|
`DEFAULT_PROVIDERS` rather than patching `yt-dlp` in tree.
|
||||||
|
|
||||||
|
### User Notification on Total Failure
|
||||||
|
|
||||||
|
`SeriesApp.download_episode()` already emits a `download_status="failed"`
|
||||||
|
WebSocket event when `loader.download()` returns `False`. Operators should
|
||||||
|
forward this to `notification_service.notify_download_failed()` so users see
|
||||||
|
a HIGH-priority alert. The loader keeps the failure detail in
|
||||||
|
`logs/download_errors.log` for post-mortem.
|
||||||
|
|
||||||
|
## Series Storage
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
Series metadata now stored in the database (SQLAlchemy ORM).
|
||||||
|
Legacy files (`key` and `data` per folder) are deprecated but preserved
|
||||||
|
for backward compatibility.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
- **Database**: Single source of truth for all series metadata
|
||||||
|
- **In-Memory Cache**: SeriesApp maintains a cache for performance
|
||||||
|
- **Filesystem**: Only used for episode files themselves, not metadata
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
First startup after upgrade automatically imports any legacy
|
||||||
|
series files into the database.
|
||||||
|
|
||||||
|
### Legacy Files
|
||||||
|
|
||||||
|
- `key` file: Contains series provider key (deprecated)
|
||||||
|
- `data` file: Contains Serie JSON object (deprecated)
|
||||||
|
|
||||||
|
Both are safe to delete after migration; not needed for normal operation.
|
||||||
|
|
||||||
|
|||||||
111
docs/MIGRATION_GUIDE.md
Normal file
111
docs/MIGRATION_GUIDE.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# Migration Guide: File-Based to Database Storage
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This guide covers the transition from file-based series metadata storage to the new database-backed system introduced in v2.0.
|
||||||
|
|
||||||
|
## What Changed
|
||||||
|
|
||||||
|
**Before v2.0**: Series metadata stored in `key` and `data` files alongside anime folders.
|
||||||
|
|
||||||
|
**After v2.0**: All metadata stored in SQLite database (`aniworld.db`). Files are deprecated but still supported for backward compatibility during migration.
|
||||||
|
|
||||||
|
## Automated Migration
|
||||||
|
|
||||||
|
The application automatically migrates on first startup:
|
||||||
|
|
||||||
|
1. Scans anime directory for `key` and `data` files
|
||||||
|
2. Parses legacy files into `AnimeSeries` and `Episode` records
|
||||||
|
3. Loads series into in-memory cache
|
||||||
|
4. Logs migration results
|
||||||
|
|
||||||
|
**No manual action required.**
|
||||||
|
|
||||||
|
## Manual Verification
|
||||||
|
|
||||||
|
After first startup with the new version:
|
||||||
|
|
||||||
|
1. **Check logs** for: `"Migrated X series from files to DB"`
|
||||||
|
2. **Verify series count**: UI shows same number of series as before
|
||||||
|
3. **Confirm episodes**: Episode counts match expected totals
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check migration log
|
||||||
|
grep "Migrated" logs/app.log
|
||||||
|
|
||||||
|
# Verify series via API
|
||||||
|
curl http://localhost:8000/api/anime | jq '.total'
|
||||||
|
```
|
||||||
|
|
||||||
|
## After Migration
|
||||||
|
|
||||||
|
### Safe to Delete
|
||||||
|
|
||||||
|
Once verified, these files can be removed:
|
||||||
|
|
||||||
|
```
|
||||||
|
<anime_folder>/
|
||||||
|
├── Attack on Titan (2013)/
|
||||||
|
│ ├── key # ❌ Can delete
|
||||||
|
│ ├── data # ❌ Can delete
|
||||||
|
│ └── Season 1/
|
||||||
|
│ └── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deleting these files does not affect the database.** The metadata now lives in `aniworld.db`.
|
||||||
|
|
||||||
|
### Backup (Recommended)
|
||||||
|
|
||||||
|
Before deleting, backup the files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create backup directory
|
||||||
|
mkdir -p backup/legacy_series_files
|
||||||
|
|
||||||
|
# Copy all key and data files
|
||||||
|
find /path/to/anime -name "key" -o -name "data" | while read f; do
|
||||||
|
cp "$f" "backup/legacy_series_files/"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reverting (Not Recommended)
|
||||||
|
|
||||||
|
If you must revert to file-based storage:
|
||||||
|
|
||||||
|
1. **Restore from database backup** (if available)
|
||||||
|
2. **Export manually** (no export script exists)
|
||||||
|
|
||||||
|
**Warning**: File-based storage is deprecated and will be removed in v3.0.0.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Series Not Appearing After Migration
|
||||||
|
|
||||||
|
1. Check logs for migration errors: `grep -i error logs/app.log`
|
||||||
|
2. Verify `key` and `data` files exist and are readable
|
||||||
|
3. Manually trigger rescan: `POST /api/scheduler/trigger-rescan`
|
||||||
|
|
||||||
|
### Duplicate Series
|
||||||
|
|
||||||
|
1. Check for duplicate `key` files (same series in multiple folders)
|
||||||
|
2. Verify series key uniqueness in database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 aniworld.db "SELECT key, COUNT(*) FROM anime_series GROUP BY key HAVING COUNT(*) > 1;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Missing Episodes
|
||||||
|
|
||||||
|
1. Trigger targeted scan for affected series
|
||||||
|
2. Check episode sync logs
|
||||||
|
3. Verify file permissions on anime directory
|
||||||
|
|
||||||
|
## Deprecation Timeline
|
||||||
|
|
||||||
|
| Version | Status |
|
||||||
|
|---------|--------|
|
||||||
|
| v2.0.x | Legacy files supported, migration automated |
|
||||||
|
| v2.1.x | Legacy files still supported, warnings in logs |
|
||||||
|
| v3.0.0 | **Legacy files removed** - database only |
|
||||||
|
|
||||||
|
Upgrade to v3.0.0 before legacy file support ends.
|
||||||
@@ -171,6 +171,35 @@ Response:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 3.6 Fallback Behavior When TMDB is Unavailable
|
||||||
|
|
||||||
|
When TMDB lookup fails (network issues, API errors, or no match found), the system creates a **minimal NFO** to ensure the series is still tracked. This behavior applies to:
|
||||||
|
|
||||||
|
- Manual NFO creation via API
|
||||||
|
- Batch NFO creation operations
|
||||||
|
- Automatic NFO creation during downloads
|
||||||
|
|
||||||
|
**What a minimal NFO contains:**
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<tvshow>
|
||||||
|
<title>Series Name</title>
|
||||||
|
<year>2024</year>
|
||||||
|
<plot>No metadata available for Series Name. TMDB lookup failed.</plot>
|
||||||
|
</tvshow>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Limitations of minimal NFOs:**
|
||||||
|
- No poster, logo, or fanart images
|
||||||
|
- No rating, genre, or studio information
|
||||||
|
- No TMDB or other provider IDs
|
||||||
|
- May not display correctly in some media servers
|
||||||
|
|
||||||
|
**To upgrade a minimal NFO:**
|
||||||
|
1. Use the Update endpoint (`PUT /api/nfo/{serie_id}/update`) when TMDB is available
|
||||||
|
2. Or delete the NFO and recreate it with full metadata
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. File Structure
|
## 4. File Structure
|
||||||
@@ -217,6 +246,7 @@ NFO files are created in the anime directory:
|
|||||||
<genre>Action</genre>
|
<genre>Action</genre>
|
||||||
<genre>Sci-Fi & Fantasy</genre>
|
<genre>Sci-Fi & Fantasy</genre>
|
||||||
<uniqueid type="tmdb">1429</uniqueid>
|
<uniqueid type="tmdb">1429</uniqueid>
|
||||||
|
<tmdbid>1429</tmdbid>
|
||||||
<thumb aspect="poster">https://image.tmdb.org/t/p/w500/...</thumb>
|
<thumb aspect="poster">https://image.tmdb.org/t/p/w500/...</thumb>
|
||||||
<fanart>
|
<fanart>
|
||||||
<thumb>https://image.tmdb.org/t/p/original/...</thumb>
|
<thumb>https://image.tmdb.org/t/p/original/...</thumb>
|
||||||
@@ -224,6 +254,13 @@ NFO files are created in the anime directory:
|
|||||||
</tvshow>
|
</tvshow>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Manual TMDB ID Override**: To skip TMDB search and use a specific ID directly, include `<tmdbid>YOUR_ID</tmdbid>` in the NFO. This is useful when:
|
||||||
|
- TMDB search fails for your series (e.g., new or obscure anime)
|
||||||
|
- You already know the correct TMDB ID
|
||||||
|
- You want to avoid rate limiting from repeated searches
|
||||||
|
|
||||||
|
Aniworld reads `<tmdbid>` element and `<uniqueid type="tmdb">` first. If found, it uses the ID directly instead of searching.
|
||||||
|
|
||||||
### 4.3 Episode NFO Format
|
### 4.3 Episode NFO Format
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
@@ -246,7 +283,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
|
||||||
|
|
||||||
@@ -523,6 +637,36 @@ NFO files are created in the anime directory:
|
|||||||
4. Check network speed to TMDB servers
|
4. Check network speed to TMDB servers
|
||||||
5. Verify disk I/O performance
|
5. Verify disk I/O performance
|
||||||
|
|
||||||
|
### 6.7 TMDB Lookup Fails for My Series
|
||||||
|
|
||||||
|
**Problem**: TMDB search fails with "No results found" for a valid series.
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
|
||||||
|
1. **Check if series exists on TMDB**: Visit https://www.themoviedb.org and search for your series
|
||||||
|
2. **Use manual ID override**: Add TMDB ID directly to `tvshow.nfo`:
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<tvshow>
|
||||||
|
<title>Your Series Name</title>
|
||||||
|
<tmdbid>12345</tmdbid>
|
||||||
|
<uniqueid type="tmdb">12345</uniqueid>
|
||||||
|
</tvshow>
|
||||||
|
```
|
||||||
|
Aniworld will use this ID directly instead of searching.
|
||||||
|
|
||||||
|
3. **Try alternative titles**: Some anime have different titles (Japanese, romaji, English). If you have access to the folder, rename it to match the TMDB title.
|
||||||
|
|
||||||
|
4. **Add to existing NFO**: If `tvshow.nfo` exists but has no TMDB ID, edit it to add:
|
||||||
|
```xml
|
||||||
|
<tmdbid>YOUR_TMDB_ID</tmdbid>
|
||||||
|
```
|
||||||
|
Then use the Update endpoint to refresh metadata.
|
||||||
|
|
||||||
|
5. **Check for rate limiting**: If many lookups fail at once, you may be hitting TMDB rate limits. Wait and retry later.
|
||||||
|
|
||||||
|
6. **Verify API key**: Ensure your TMDB API key is valid and has not exceeded usage limits.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Best Practices
|
## 7. Best Practices
|
||||||
@@ -675,21 +819,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 +882,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 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,90 @@ This document describes the testing strategy, guidelines, and practices for the
|
|||||||
- What to mock
|
- What to mock
|
||||||
- Mock patterns
|
- Mock patterns
|
||||||
- External service mocks
|
- External service mocks
|
||||||
|
|
||||||
|
### Mocking the Download Queue
|
||||||
|
|
||||||
|
Use `MockQueueRepository` for testing download queue functionality:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.server.models.download import DownloadItem, EpisodeIdentifier
|
||||||
|
|
||||||
|
class MockQueueRepository:
|
||||||
|
def __init__(self):
|
||||||
|
self._items: Dict[str, DownloadItem] = {}
|
||||||
|
|
||||||
|
async def save_item(self, item: DownloadItem) -> DownloadItem:
|
||||||
|
self._items[item.id] = item
|
||||||
|
return item
|
||||||
|
|
||||||
|
async def get_item(self, item_id: str) -> Optional[DownloadItem]:
|
||||||
|
return self._items.get(item_id)
|
||||||
|
|
||||||
|
async def get_all_items(self) -> List[DownloadItem]:
|
||||||
|
return list(self._items.values())
|
||||||
|
|
||||||
|
async def set_error(self, item_id: str, error: str) -> bool:
|
||||||
|
if item_id in self._items:
|
||||||
|
self._items[item_id].error = error
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def delete_item(self, item_id: str) -> bool:
|
||||||
|
if item_id in self._items:
|
||||||
|
del self._items[item_id]
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def clear_all(self) -> int:
|
||||||
|
count = len(self._items)
|
||||||
|
self._items.clear()
|
||||||
|
return count
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key points:**
|
||||||
|
- The mock uses in-memory storage, no database required
|
||||||
|
- All async methods are implemented (even if just pass-through)
|
||||||
|
- `save_item` uses `item.id` as key (must be set before calling)
|
||||||
|
- Suitable for unit tests only (no persistence)
|
||||||
|
|
||||||
|
### Mocking aiohttp Sessions
|
||||||
|
|
||||||
|
When testing code that uses `aiohttp.ClientSession`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
|
||||||
|
# Mock aiohttp session for testing
|
||||||
|
class MockAiohttpSession:
|
||||||
|
def __init__(self):
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
def get(self, url, **kwargs):
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.json = AsyncMock(return_value={"data": "test"})
|
||||||
|
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
|
||||||
|
mock_response.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
return mock_response
|
||||||
|
|
||||||
|
# Use in fixture
|
||||||
|
@pytest.fixture
|
||||||
|
async def mock_tmdb_session():
|
||||||
|
session = MockAiohttpSession()
|
||||||
|
yield session
|
||||||
|
# Cleanup verification
|
||||||
|
assert session.closed, "Session was not closed"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key points:**
|
||||||
|
- Always verify `session.closed` is `True` after context manager exits
|
||||||
|
- Mock `__aenter__` and `__aexit__` for response context managers
|
||||||
|
- Set `closed = False` on mock session for unclosed warning tests
|
||||||
|
|
||||||
7. Coverage Requirements
|
7. Coverage Requirements
|
||||||
8. CI/CD Integration
|
8. CI/CD Integration
|
||||||
9. Writing Good Tests
|
9. Writing Good Tests
|
||||||
|
|||||||
10
docs/bla
10
docs/bla
@@ -1,10 +0,0 @@
|
|||||||
review frontend code and check for architektre issues
|
|
||||||
|
|
||||||
write the tasks in Task.md
|
|
||||||
for each task add the following informations
|
|
||||||
|
|
||||||
where is that found
|
|
||||||
goal. how it should be
|
|
||||||
possibale traps and issues
|
|
||||||
docs changes needed
|
|
||||||
why this is needed
|
|
||||||
@@ -31,14 +31,16 @@ flowchart TB
|
|||||||
|
|
||||||
subgraph Core["Core Layer"]
|
subgraph Core["Core Layer"]
|
||||||
SeriesApp["SeriesApp"]
|
SeriesApp["SeriesApp"]
|
||||||
|
SeriesCache["SeriesCache<br/>(In-Memory)"]
|
||||||
SerieScanner["SerieScanner"]
|
SerieScanner["SerieScanner"]
|
||||||
SerieList["SerieList"]
|
SerieList["SerieList"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Data["Data Layer"]
|
subgraph Data["Data Layer"]
|
||||||
SQLite[(SQLite<br/>aniworld.db)]
|
SQLite[("SQLite<br/>aniworld.db")]
|
||||||
ConfigJSON[(config.json)]
|
ConfigJSON[(config.json)]
|
||||||
FileSystem[(File System<br/>Anime Directory)]
|
FileSystem[(File System<br/>Anime Episodes)]
|
||||||
|
LegacyFiles[("Legacy Files<br/>key/data<br/>(Deprecated)")]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph External["External"]
|
subgraph External["External"]
|
||||||
@@ -71,9 +73,13 @@ flowchart TB
|
|||||||
AnimeService --> SQLite
|
AnimeService --> SQLite
|
||||||
|
|
||||||
%% Core to Data
|
%% Core to Data
|
||||||
|
SeriesApp --> SeriesCache
|
||||||
|
SeriesCache -.->|Cached Series| SQLite
|
||||||
SeriesApp --> SerieScanner
|
SeriesApp --> SerieScanner
|
||||||
SeriesApp --> SerieList
|
SeriesApp --> SerieList
|
||||||
SerieScanner --> FileSystem
|
SerieScanner -->|Scan Episodes| FileSystem
|
||||||
|
SerieScanner -->|Detect Series| SQLite
|
||||||
|
SerieScanner -->|Migrate Legacy| LegacyFiles
|
||||||
SerieScanner --> Provider
|
SerieScanner --> Provider
|
||||||
|
|
||||||
%% Event flow
|
%% Event flow
|
||||||
|
|||||||
0
docs/helper
Normal file
0
docs/helper
Normal file
47
docs/key
47
docs/key
@@ -2,3 +2,50 @@ API key : 299ae8f630a31bda814263c551361448
|
|||||||
|
|
||||||
/mnt/server/serien/Serien/
|
/mnt/server/serien/Serien/
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "Aniworld",
|
||||||
|
"data_dir": "data",
|
||||||
|
"scheduler": {
|
||||||
|
"enabled": true,
|
||||||
|
"interval_minutes": 60,
|
||||||
|
"schedule_time": "03:00",
|
||||||
|
"schedule_days": [
|
||||||
|
"mon",
|
||||||
|
"tue",
|
||||||
|
"wed",
|
||||||
|
"thu",
|
||||||
|
"fri",
|
||||||
|
"sat",
|
||||||
|
"sun"
|
||||||
|
],
|
||||||
|
"auto_download_after_rescan": true,
|
||||||
|
"folder_scan_enabled": true
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"level": "INFO",
|
||||||
|
"file": null,
|
||||||
|
"max_bytes": null,
|
||||||
|
"backup_count": 3
|
||||||
|
},
|
||||||
|
"backup": {
|
||||||
|
"enabled": false,
|
||||||
|
"path": "data/backups",
|
||||||
|
"keep_days": 30
|
||||||
|
},
|
||||||
|
"nfo": {
|
||||||
|
"tmdb_api_key": "9bc3e547caff878615cbdba2cc421d37",
|
||||||
|
"auto_create": true,
|
||||||
|
"update_on_scan": true,
|
||||||
|
"download_poster": true,
|
||||||
|
"download_logo": true,
|
||||||
|
"download_fanart": true,
|
||||||
|
"image_size": "original"
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"master_password_hash": "$pbkdf2-sha256$29000$HQNASKk1xpgTAgAgJGRMaQ$73TOCCM0UEZONyNXQEPa3SmIoXeG6C1l5mMFDNgYfMQ",
|
||||||
|
"anime_directory": "/data"
|
||||||
|
},
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
154
docs/runner.csx
Normal file
154
docs/runner.csx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env dotnet-script
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
// ── Ctrl+C: kill active process and exit cleanly ──────────────────────────────
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
Process? activeProcess = null;
|
||||||
|
|
||||||
|
Console.CancelKeyPress += (_, e) =>
|
||||||
|
{
|
||||||
|
e.Cancel = true;
|
||||||
|
Console.WriteLine("\n[runner] Interrupted — shutting down...");
|
||||||
|
cts.Cancel();
|
||||||
|
try { activeProcess?.Kill(entireProcessTree: true); } catch { }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Paths ─────────────────────────────────────────────────────────────────────
|
||||||
|
var repoRoot = Directory.GetCurrentDirectory();
|
||||||
|
var tasksFile = Path.Combine(repoRoot, "Docs", "Tasks.md");
|
||||||
|
|
||||||
|
if (!File.Exists(tasksFile))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[runner] ERROR: Tasks.md not found at {tasksFile}");
|
||||||
|
Console.Error.WriteLine("[runner] Run this script from the repository root.");
|
||||||
|
Environment.Exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read & split by "---" separator lines ────────────────────────────────────
|
||||||
|
var content = File.ReadAllText(tasksFile);
|
||||||
|
var items = Regex
|
||||||
|
.Split(content, @"\r?\n---\r?\n")
|
||||||
|
.Select(s => s.Trim())
|
||||||
|
.Where(s => s.Length > 0)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Console.WriteLine($"[runner] Found {items.Count} section(s) in Tasks.md");
|
||||||
|
|
||||||
|
// ── Helper: run copilot and stream output, return full output ─────────────────
|
||||||
|
async Task<string> RunCopilot(IEnumerable<string> extraArgs, string prompt)
|
||||||
|
{
|
||||||
|
var output = new StringBuilder();
|
||||||
|
|
||||||
|
var argList = new List<string> { "launch", "copilot", "--model", "minimax-m2.7:cloud", "--yes", "--", "--allow-all-tools" };
|
||||||
|
argList.AddRange(extraArgs);
|
||||||
|
argList.Add("-p");
|
||||||
|
argList.Add(prompt);
|
||||||
|
|
||||||
|
var psi = new ProcessStartInfo("ollama")
|
||||||
|
{
|
||||||
|
WorkingDirectory = repoRoot,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
};
|
||||||
|
foreach (var a in argList)
|
||||||
|
psi.ArgumentList.Add(a);
|
||||||
|
|
||||||
|
activeProcess = new Process { StartInfo = psi };
|
||||||
|
|
||||||
|
activeProcess.OutputDataReceived += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.Data is null) return;
|
||||||
|
Console.WriteLine(e.Data);
|
||||||
|
output.AppendLine(e.Data);
|
||||||
|
};
|
||||||
|
activeProcess.ErrorDataReceived += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.Data is null) return;
|
||||||
|
Console.Error.WriteLine(e.Data);
|
||||||
|
output.AppendLine(e.Data);
|
||||||
|
};
|
||||||
|
|
||||||
|
activeProcess.Start();
|
||||||
|
activeProcess.BeginOutputReadLine();
|
||||||
|
activeProcess.BeginErrorReadLine();
|
||||||
|
|
||||||
|
await activeProcess.WaitForExitAsync(cts.Token);
|
||||||
|
activeProcess = null;
|
||||||
|
|
||||||
|
return output.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main loop ─────────────────────────────────────────────────────────────────
|
||||||
|
for (int i = 0; i < items.Count; i++)
|
||||||
|
{
|
||||||
|
var item = items[i];
|
||||||
|
if (cts.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("[runner] ══════════════════════════════════════════════");
|
||||||
|
Console.WriteLine($"[runner] Task:\n{item}");
|
||||||
|
Console.WriteLine("[runner] ══════════════════════════════════════════════");
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
// Step 1 — run the task prompt
|
||||||
|
await RunCopilot(Enumerable.Empty<string>(), $"/caveman full");
|
||||||
|
await RunCopilot(new[] { "--continue" }, $"read ./Docs/instructions.md. {item}");
|
||||||
|
if (cts.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
// Step 2 — confirm completion in the same chat session
|
||||||
|
Console.WriteLine("\n[runner] Asking for task confirmation...\n");
|
||||||
|
var confirmation = await RunCopilot(
|
||||||
|
new[] { "--continue" },
|
||||||
|
"are you sure tasks is done. reply with yes"
|
||||||
|
);
|
||||||
|
if (cts.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
// Step 3 — check for "yes" in the reply, with retry logic for issue resolution
|
||||||
|
int maxRetries = 3;
|
||||||
|
int retryCount = 0;
|
||||||
|
bool taskConfirmed = confirmation.Contains("yes", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
while (!taskConfirmed && retryCount < maxRetries)
|
||||||
|
{
|
||||||
|
retryCount++;
|
||||||
|
Console.WriteLine($"\n[runner] Attempt {retryCount}/{maxRetries}: Resolving remaining issues and running tests...\n");
|
||||||
|
|
||||||
|
confirmation = await RunCopilot(
|
||||||
|
new[] { "--continue" },
|
||||||
|
"resolve any remaining issues, make sure all tests are running and pass. then confirm with yes if done"
|
||||||
|
);
|
||||||
|
if (cts.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
taskConfirmed = confirmation.Contains("yes", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!taskConfirmed)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"\n[runner] Task not confirmed as done after {maxRetries} attempts. Stopping.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4 — commit the work
|
||||||
|
Console.WriteLine("\n[runner] Task confirmed. Making git commit...\n");
|
||||||
|
|
||||||
|
await RunCopilot(Enumerable.Empty<string>(), $"/caveman full");
|
||||||
|
await RunCopilot(new[] { "--continue" }, "make git commit");
|
||||||
|
if (cts.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
// Step 5 — remove completed task from Tasks.md
|
||||||
|
var remaining = items.Skip(i + 1).ToList();
|
||||||
|
File.WriteAllText(tasksFile, string.Join("\n\n---\n\n", remaining));
|
||||||
|
Console.WriteLine("[runner] Removed completed task from Tasks.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("\n[runner] Finished.");
|
||||||
278
docs/tasks.md
278
docs/tasks.md
@@ -1,174 +1,178 @@
|
|||||||
# Tasks — NFO Plot Missing Bug
|
# Tasks
|
||||||
|
|
||||||
These tasks fix the root causes of `<plot>` being empty in `tvshow.nfo` after adding a series via the web UI.
|
## 1. Scheduled Folder Scan
|
||||||
The bug does **not** appear after a server restart because the repair scan uses a different, correctly isolated code path.
|
|
||||||
|
### 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 — Replace shared NFOService in BackgroundLoaderService with per-task instances
|
### Task 1.2: Create FolderScanService skeleton
|
||||||
|
|
||||||
- [x] Completed
|
**Where is that found**
|
||||||
|
- New file: `src/server/services/folder_scan_service.py`
|
||||||
|
- `src/server/services/scheduler_service.py` (to call it)
|
||||||
|
|
||||||
### Where
|
**Goal. How it should be**
|
||||||
`src/server/services/background_loader_service.py` — method `_load_nfo_and_images` (~line 555)
|
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.
|
||||||
|
|
||||||
```python
|
Keep the implementation empty for the sub-tasks (1.3–1.5) to fill in. Just add the skeleton and the semaphore.
|
||||||
nfo_path = await self.series_app.nfo_service.create_tvshow_nfo(
|
|
||||||
serie_name=task.name,
|
|
||||||
serie_folder=task.folder,
|
|
||||||
year=task.year,
|
|
||||||
...
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Goal
|
**Possible traps and issues**
|
||||||
Create a fresh, isolated `NFOService` (with its own `TMDBClient` and `aiohttp` session) for every background loading task, exactly the same way `_repair_one_series` in `initialization_service.py` already does it.
|
- 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.
|
||||||
Each task must own its client so that closing the session at the end of one task never kills an in-flight request inside another task.
|
- 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.
|
||||||
|
|
||||||
### How it should look
|
**Docs changes needed**
|
||||||
```python
|
- `docs/ARCHITECTURE.md`: Add `folder_scan_service.py` to the services list.
|
||||||
from src.core.services.nfo_factory import NFOServiceFactory
|
|
||||||
|
|
||||||
factory = NFOServiceFactory()
|
**Why this is needed**
|
||||||
nfo_service = factory.create()
|
Encapsulates the new daily maintenance logic in its own module, keeping `scheduler_service.py` clean and allowing the folder scan to be tested independently.
|
||||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
|
||||||
serie_name=task.name,
|
|
||||||
serie_folder=task.folder,
|
|
||||||
year=task.year,
|
|
||||||
...
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Possible traps and issues
|
|
||||||
- `NFOServiceFactory.create()` raises `ValueError` if no TMDB API key is available. Wrap in try/except and fall back gracefully (same behaviour as now when `nfo_service` is `None`).
|
|
||||||
- The factory reads the API key from `settings` first, then from `config.json`. Do not pass the key explicitly so the fallback chain stays intact.
|
|
||||||
- Each new `NFOService` opens its own `aiohttp` connector. Make sure to call `await nfo_service.close()` in a `finally` block to avoid connector leaks.
|
|
||||||
|
|
||||||
### Docs changes needed
|
|
||||||
None — this is an internal implementation detail.
|
|
||||||
|
|
||||||
### Why this is needed
|
|
||||||
Up to 5 background workers share one `NFOService`/`TMDBClient` instance. The `async with self.tmdb_client:` context manager inside `create_tvshow_nfo` calls `close()` on `__aexit__`, setting `session = None`. When Worker B exits its context while Worker A is still inside `_enrich_details_with_fallback` trying the `en-US` fallback request, that request throws "Connector is closed". The exception is silently swallowed, both `en-US` and `ja-JP` fallbacks fail, `details["overview"]` stays empty, and `plot` is written as an empty element.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task 2 — Guard NFOService init in SeriesApp on factory fallback, not just env var
|
### Task 1.3: Integrate NFO repair into folder scan
|
||||||
|
|
||||||
- [x] Completed
|
**Where is that found**
|
||||||
|
- `src/server/services/folder_scan_service.py`
|
||||||
|
- `src/server/services/initialization_service.py` (`perform_nfo_repair_scan`)
|
||||||
|
|
||||||
### Where
|
**Goal. How it should be**
|
||||||
`src/core/SeriesApp.py` — `__init__` method (~line 175)
|
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.
|
||||||
|
|
||||||
```python
|
**Possible traps and issues**
|
||||||
self.nfo_service: Optional[NFOService] = None
|
- `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.
|
||||||
if settings.tmdb_api_key: # ← checks env var ONLY
|
- 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.
|
||||||
factory = get_nfo_factory()
|
- `perform_nfo_repair_scan` imports `nfo_needs_repair` and `NfoRepairService` inside the function, so no heavy import-time dependencies.
|
||||||
self.nfo_service = factory.create()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Goal
|
**Docs changes needed**
|
||||||
The guard condition should be equivalent to what `NFOServiceFactory.create()` itself checks: whether the key is available from *any* source (env var or `config.json`). Replace the guard with a try/create pattern so that `nfo_service` is initialised whenever the factory would succeed.
|
- `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.
|
||||||
|
|
||||||
### How it should look
|
**Why this is needed**
|
||||||
```python
|
Reuses the existing, tested NFO repair logic. Moves NFO repair from startup blocking to scheduled background maintenance.
|
||||||
self.nfo_service: Optional[NFOService] = None
|
|
||||||
try:
|
|
||||||
from src.core.services.nfo_factory import get_nfo_factory
|
|
||||||
factory = get_nfo_factory()
|
|
||||||
self.nfo_service = factory.create()
|
|
||||||
logger.info("NFO service initialized successfully")
|
|
||||||
except ValueError:
|
|
||||||
logger.info("NFO service not available — TMDB API key not configured")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to initialize NFO service: %s", e)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Possible traps and issues
|
|
||||||
- This changes the condition from "env var set" to "factory can produce a service". The factory already has a safe fallback and raises `ValueError` when no key exists — so the `except ValueError` path is the normal "not configured" case, not an error.
|
|
||||||
- `SeriesApp` is used in tests with `settings.tmdb_api_key = None`. Those tests must not be affected; the `except ValueError` branch keeps behaviour identical.
|
|
||||||
- `series_app.nfo_service` is still `None` when not configured — downstream code that checks `if self.series_app.nfo_service:` remains correct.
|
|
||||||
|
|
||||||
### Docs changes needed
|
|
||||||
`docs/CONFIGURATION.md` — note that `TMDB_API_KEY` env var is not required if `nfo.tmdb_api_key` is set in `config.json`.
|
|
||||||
|
|
||||||
### Why this is needed
|
|
||||||
If the TMDB API key is configured only via `config.json` (not the `TMDB_API_KEY` env var), `settings.tmdb_api_key` is `None` and the guard prevents `nfo_service` from ever being created. The background loader then skips NFO creation completely (`nfo_service` is `None`). The repair scan at startup uses `NFOServiceFactory` directly (reads config.json) so it does create the NFO — which is exactly why restart works but add does not.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task 3 — Remove non-reentrant `async with self.tmdb_client:` from NFOService public methods
|
### Task 1.4: Validate and rename series folders
|
||||||
|
|
||||||
- [x] Completed
|
**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)
|
||||||
|
|
||||||
### Where
|
**Goal. How it should be**
|
||||||
`src/core/services/nfo_service.py` — `create_tvshow_nfo` (~line 151) and `update_tvshow_nfo` (~line 265)
|
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.
|
||||||
|
|
||||||
```python
|
Skip folders where title or year is missing/empty. Log every rename action.
|
||||||
async with self.tmdb_client:
|
|
||||||
details = await self.tmdb_client.get_tv_show_details(...)
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Goal
|
**Possible traps and issues**
|
||||||
The `TMDBClient.__aenter__` / `__aexit__` open and **close** the session, making any concurrent call to the same client instance fail. Because Task 1 creates a fresh instance per call, this context manager becomes redundant. Change both methods to use `_ensure_session()` at the start and `close()` in a `finally` block, or simply call `await self.tmdb_client._ensure_session()` once and close after all requests. This makes the lifetime explicit and prevents double-close if the caller already manages it.
|
- **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.
|
||||||
|
|
||||||
### How it should look
|
**Docs changes needed**
|
||||||
```python
|
- `docs/NFO_GUIDE.md`: Add a section "Folder Naming Convention" explaining the `<title> (<year>)` format.
|
||||||
async def create_tvshow_nfo(self, ...) -> Path:
|
- `docs/CONFIGURATION.md`: Mention that enabling folder scan will rename folders.
|
||||||
try:
|
|
||||||
await self.tmdb_client._ensure_session()
|
|
||||||
search_results = await self.tmdb_client.search_tv_show(search_name)
|
|
||||||
...
|
|
||||||
finally:
|
|
||||||
await self.tmdb_client.close()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Possible traps and issues
|
**Why this is needed**
|
||||||
- `TMDBClient.close()` is idempotent (checks `session.closed` before closing), so calling it in `finally` is safe even if the try block never opened a session.
|
Enforces a consistent, predictable folder naming scheme across the library, making it easier for media center apps (Kodi, Jellyfin, Plex) to match metadata.
|
||||||
- After Task 1 every `NFOService` is short-lived (one call), so `finally: close()` effectively replaces the context manager with no behaviour change.
|
|
||||||
- Do not remove the `__aenter__`/`__aexit__` from `TMDBClient` itself — other callers (e.g. tests, CLI) may still use it as a context manager.
|
|
||||||
- `update_tvshow_nfo` has the same pattern; fix both methods.
|
|
||||||
|
|
||||||
### Docs changes needed
|
|
||||||
None — internal implementation detail.
|
|
||||||
|
|
||||||
### Why this is needed
|
|
||||||
Even after Task 1 fixes the shared-instance problem, the `async with self.tmdb_client:` pattern is fragile by design: `__aexit__` calls `close()`, which would break any hypothetical future reuse. Removing the implicit close makes the session lifetime explicit and eliminates the root mechanism that caused the original bug.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task 4 — Add `en-US` search fallback so `search_overview` is never empty
|
### Task 1.5: Check and download missing poster.jpg
|
||||||
|
|
||||||
### Where
|
**Where is that found**
|
||||||
`src/core/services/nfo_service.py` — `create_tvshow_nfo` (~line 178) and `_enrich_details_with_fallback` (~line 395)
|
- `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)
|
||||||
|
|
||||||
```python
|
**Goal. How it should be**
|
||||||
search_overview = tv_show.get("overview") or None # always None for anime — de-DE search returns ""
|
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.
|
||||||
|
|
||||||
### Goal
|
**Possible traps and issues**
|
||||||
When the German `search_tv_show` result has an empty `overview`, perform a second search in `en-US` to obtain a non-empty overview as the last-resort fallback text. Store this as `search_overview` so `_enrich_details_with_fallback` can use it even if all language-specific detail requests fail.
|
- **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.
|
||||||
|
|
||||||
### How it should look
|
**Docs changes needed**
|
||||||
```python
|
- `docs/NFO_GUIDE.md`: Add "Poster Check" subsection under folder scan.
|
||||||
search_overview = tv_show.get("overview") or None
|
- `docs/CONFIGURATION.md`: Mention that `nfo.download_poster` setting also affects scheduled poster checks.
|
||||||
if not search_overview:
|
|
||||||
try:
|
|
||||||
en_results = await self.tmdb_client.search_tv_show(search_name, language="en-US")
|
|
||||||
en_match = self._find_best_match(en_results.get("results", []), search_name, year)
|
|
||||||
search_overview = en_match.get("overview") or None
|
|
||||||
except Exception:
|
|
||||||
pass # best-effort only
|
|
||||||
```
|
|
||||||
|
|
||||||
### Possible traps and issues
|
**Why this is needed**
|
||||||
- This adds one extra TMDB request per series when the German overview is empty. It is best-effort and must be wrapped in a broad `except` so it never blocks NFO creation.
|
Ensures every series has artwork, which is required by most media center front-ends for a polished library view.
|
||||||
- The TMDB search endpoint rate-limit is generous; one extra request per add is negligible.
|
|
||||||
- `_find_best_match` can raise `TMDBAPIError` if the result list is empty — catch both `TMDBAPIError` and `Exception`.
|
|
||||||
- `update_tvshow_nfo` calls `_enrich_details_with_fallback` without `search_overview`. This is acceptable because the detail request with `en-US` fallback covers it; the search overview is only a last resort for the create path.
|
|
||||||
|
|
||||||
### Docs changes needed
|
---
|
||||||
None — transparent improvement.
|
|
||||||
|
|
||||||
### Why this is needed
|
## 2. Remove startup NFO repair
|
||||||
Most anime have no German translation on TMDB. The `de-DE` search result returns `overview: ""`. The current code stores this as `search_overview = None` so the last-resort fallback in `_enrich_details_with_fallback` never fires. Combined with session contention (Task 1), the detail-level `en-US` fallback also fails, leaving `plot` empty. This task ensures that at least the search-level `en-US` overview is available as a safety net.
|
|
||||||
|
### 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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aniworld-web",
|
"name": "aniworld-web",
|
||||||
"version": "1.0.0",
|
"version": "1.3.1",
|
||||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ APScheduler>=3.10.4
|
|||||||
Events>=0.5
|
Events>=0.5
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
beautifulsoup4>=4.12.0
|
beautifulsoup4>=4.12.0
|
||||||
|
chardet>=5.2.0
|
||||||
fake-useragent>=1.4.0
|
fake-useragent>=1.4.0
|
||||||
yt-dlp>=2024.1.0
|
yt-dlp>=2024.1.0
|
||||||
urllib3>=2.0.0
|
urllib3>=2.0.0
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -114,6 +115,40 @@ class Settings(BaseSettings):
|
|||||||
validation_alias="NFO_PREFER_FSK_RATING",
|
validation_alias="NFO_PREFER_FSK_RATING",
|
||||||
description="Prefer German FSK rating over MPAA rating in NFO files"
|
description="Prefer German FSK rating over MPAA rating in NFO files"
|
||||||
)
|
)
|
||||||
|
nfo_folder_ignore_patterns: str = Field(
|
||||||
|
default="The Last of Us|Loki|Chernobyl|Star Trek Discovery|Marvel|Matrix|Fast & Furious|Jurassic|James Bond|Mission: Impossible|Bourne|Hunger Games|Die Hard|John Wick|Pacific Rim|Guardians of the Galaxy|Avengers|Batman|Superman|Wonder Woman|Spider-Man|X-Men|Fantastic Four|Terminator|Predator|Rambo|Rocky|Expendables|Tomb Raider|Jumanji|Jurassic Park|Pirates of the Caribbean|Harry Potter|Lord of the Rings|Hobbit|Game of Thrones|Westworld|Stranger Things|Breaking Bad|Better Call Saul|Sherlock|Downton Abbey|The Crown|Bridgerton|Sex Education|Normal People|Emily in Paris|The Witcher|Servant|Lucifer|Dark|Shadow and Bone|Grimm|Fairytale",
|
||||||
|
validation_alias="NFO_FOLDER_IGNORE_PATTERNS",
|
||||||
|
description="Regex patterns for folder names to skip during scan (pipe-separated)"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def folder_ignore_patterns(self) -> list[str]:
|
||||||
|
"""Parse ignore patterns from comma-separated string into list.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of regex patterns to skip during folder scanning.
|
||||||
|
"""
|
||||||
|
if not self.nfo_folder_ignore_patterns:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
pattern.strip()
|
||||||
|
for pattern in self.nfo_folder_ignore_patterns.split("|")
|
||||||
|
if pattern.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
def should_ignore_folder(self, folder_name: str) -> bool:
|
||||||
|
"""Check if folder should be ignored based on ignore patterns.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_name: Name of folder to check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if folder matches any ignore pattern, False otherwise.
|
||||||
|
"""
|
||||||
|
for pattern in self.folder_ignore_patterns:
|
||||||
|
if re.search(pattern, folder_name, re.IGNORECASE):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def allowed_origins(self) -> list[str]:
|
def allowed_origins(self) -> list[str]:
|
||||||
@@ -134,5 +169,23 @@ class Settings(BaseSettings):
|
|||||||
]
|
]
|
||||||
return [origin.strip() for origin in raw.split(",") if origin.strip()]
|
return [origin.strip() for origin in raw.split(",") if origin.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scan_key_overrides(self) -> dict[str, str]:
|
||||||
|
"""Return scan key overrides from config.json.
|
||||||
|
|
||||||
|
Maps folder names to provider keys for cases where auto-generated
|
||||||
|
keys from folder names are incorrect.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping folder names to provider keys.
|
||||||
|
"""
|
||||||
|
from src.server.services.config_service import ConfigService
|
||||||
|
try:
|
||||||
|
config_service = ConfigService()
|
||||||
|
config = config_service.load_config()
|
||||||
|
return config.scan_key_overrides or {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -10,18 +10,23 @@ Note:
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
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
|
||||||
|
|
||||||
|
from src.config.settings import settings
|
||||||
from src.core.entities.series import Serie
|
from src.core.entities.series import Serie
|
||||||
from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException
|
from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException
|
||||||
from src.core.providers.base_provider import Loader
|
from src.core.providers.base_provider import Loader
|
||||||
|
from src.core.utils.key_utils import generate_key_from_folder
|
||||||
|
from src.server.database.connection import get_sync_session
|
||||||
|
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
error_logger = logging.getLogger("error")
|
error_logger = logging.getLogger("error")
|
||||||
@@ -40,15 +45,31 @@ class SerieScanner:
|
|||||||
in keyDict and can be retrieved after scanning.
|
in keyDict and can be retrieved after scanning.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
# Synchronous context (CLI):
|
||||||
scanner = SerieScanner("/path/to/anime", loader)
|
scanner = SerieScanner("/path/to/anime", loader)
|
||||||
scanner.scan()
|
scanner.scan() # asyncio.run() used internally when no event loop
|
||||||
|
|
||||||
|
# Asynchronous context (server/scheduler):
|
||||||
|
# scan() detects running event loop and uses create_task()
|
||||||
|
# internally, so no special handling needed by caller.
|
||||||
# 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))
|
||||||
|
|
||||||
|
# With scan key overrides:
|
||||||
|
overrides = {"Folder Name": "correct-provider-key"}
|
||||||
|
scanner = SerieScanner("/path/to/anime", loader,
|
||||||
|
scan_key_overrides=overrides)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
basePath: str,
|
basePath: str,
|
||||||
loader: Loader,
|
loader: Loader,
|
||||||
|
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
|
||||||
|
scan_key_overrides: Optional[dict[str, str]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Initialize the SerieScanner.
|
Initialize the SerieScanner.
|
||||||
@@ -56,7 +77,15 @@ 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.
|
||||||
|
scan_key_overrides: Optional dict mapping folder names to provider
|
||||||
|
keys. When a folder name is found in this dict, the override
|
||||||
|
key is used instead of auto-generating from folder name.
|
||||||
|
Format: {"Folder Name": "actual-provider-key"}
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If basePath is invalid or doesn't exist
|
ValueError: If basePath is invalid or doesn't exist
|
||||||
@@ -75,11 +104,14 @@ 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._scan_key_overrides: Optional[dict[str, str]] = scan_key_overrides
|
||||||
self._current_operation_id: Optional[str] = None
|
self._current_operation_id: Optional[str] = None
|
||||||
self.events = Events()
|
self.events = Events()
|
||||||
|
|
||||||
self.events.on_progress = []
|
self.events.on_progress = []
|
||||||
self.events.on_error = []
|
self.events.on_error = []
|
||||||
|
self.events.on_warning = []
|
||||||
self.events.on_completion = []
|
self.events.on_completion = []
|
||||||
|
|
||||||
logger.info("Initialized SerieScanner with base path: %s", abs_path)
|
logger.info("Initialized SerieScanner with base path: %s", abs_path)
|
||||||
@@ -173,6 +205,24 @@ class SerieScanner:
|
|||||||
if handler in self.events.on_error:
|
if handler in self.events.on_error:
|
||||||
self.events.on_error.remove(handler)
|
self.events.on_error.remove(handler)
|
||||||
|
|
||||||
|
def subscribe_on_warning(self, handler):
|
||||||
|
"""
|
||||||
|
Subscribe a handler to an event.
|
||||||
|
Args:
|
||||||
|
handler: Callable to handle the event
|
||||||
|
"""
|
||||||
|
if handler not in self.events.on_warning:
|
||||||
|
self.events.on_warning.append(handler)
|
||||||
|
|
||||||
|
def unsubscribe_on_warning(self, handler):
|
||||||
|
"""
|
||||||
|
Unsubscribe a handler from an event.
|
||||||
|
Args:
|
||||||
|
handler: Callable to remove
|
||||||
|
"""
|
||||||
|
if handler in self.events.on_warning:
|
||||||
|
self.events.on_warning.remove(handler)
|
||||||
|
|
||||||
def subscribe_on_completion(self, handler):
|
def subscribe_on_completion(self, handler):
|
||||||
"""
|
"""
|
||||||
Subscribe a handler to an event.
|
Subscribe a handler to an event.
|
||||||
@@ -195,6 +245,105 @@ class SerieScanner:
|
|||||||
"""Reinitialize the series dictionary (keyed by serie.key)."""
|
"""Reinitialize the series dictionary (keyed by serie.key)."""
|
||||||
self.keyDict: dict[str, Serie] = {}
|
self.keyDict: dict[str, Serie] = {}
|
||||||
|
|
||||||
|
async def _persist_serie_to_db(self, serie: Serie) -> None:
|
||||||
|
"""Persist serie to database (create or update).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
serie: Serie domain object to persist
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.server.database.connection import get_async_session_factory
|
||||||
|
|
||||||
|
db = get_async_session_factory()
|
||||||
|
try:
|
||||||
|
existing = await AnimeSeriesService.get_by_key(db, serie.key)
|
||||||
|
if existing:
|
||||||
|
await AnimeSeriesService.update(
|
||||||
|
db, existing.id,
|
||||||
|
name=serie.name,
|
||||||
|
folder=serie.folder,
|
||||||
|
year=serie.year
|
||||||
|
)
|
||||||
|
await self._sync_episodes_to_db(db, existing.id, serie.episodeDict)
|
||||||
|
else:
|
||||||
|
anime_series = await AnimeSeriesService.create(
|
||||||
|
db=db,
|
||||||
|
key=serie.key,
|
||||||
|
name=serie.name,
|
||||||
|
site=serie.site,
|
||||||
|
folder=serie.folder,
|
||||||
|
year=serie.year
|
||||||
|
)
|
||||||
|
for season, eps in serie.episodeDict.items():
|
||||||
|
for ep in eps:
|
||||||
|
await EpisodeService.create(
|
||||||
|
db=db,
|
||||||
|
series_id=anime_series.id,
|
||||||
|
season=season,
|
||||||
|
episode_number=ep
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
logger.debug(
|
||||||
|
"Persisted serie '%s' (key=%s) to database",
|
||||||
|
serie.name, serie.key
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(
|
||||||
|
"Failed to persist serie '%s' to DB: %s",
|
||||||
|
serie.key, e, exc_info=True
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Could not persist serie '%s' to DB (DB unavailable?): %s",
|
||||||
|
serie.key, e
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _sync_episodes_to_db(
|
||||||
|
self, db, series_id: int, episode_dict: dict[int, list[int]]
|
||||||
|
) -> None:
|
||||||
|
"""Sync episodes to database, preserving downloaded flags.
|
||||||
|
|
||||||
|
Adds missing episodes, removes episodes no longer missing,
|
||||||
|
and preserves is_downloaded=True episodes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session
|
||||||
|
series_id: Database ID of the series
|
||||||
|
episode_dict: Dict mapping season -> list of episode numbers
|
||||||
|
"""
|
||||||
|
existing_episodes = await EpisodeService.get_by_series(db, series_id)
|
||||||
|
existing_map = {
|
||||||
|
(ep.season, ep.episode_number): ep for ep in existing_episodes
|
||||||
|
}
|
||||||
|
new_keys = set()
|
||||||
|
for season, eps in episode_dict.items():
|
||||||
|
for ep_num in eps:
|
||||||
|
new_keys.add((season, ep_num))
|
||||||
|
for (season, ep_num), ep in existing_map.items():
|
||||||
|
if (season, ep_num) not in new_keys:
|
||||||
|
if ep.is_downloaded:
|
||||||
|
logger.debug(
|
||||||
|
"Preserving downloaded episode S%02dE%02d for series_id=%d",
|
||||||
|
season, ep_num, series_id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await EpisodeService.delete_by_series(
|
||||||
|
db, series_id, season, ep_num
|
||||||
|
)
|
||||||
|
for season, eps in episode_dict.items():
|
||||||
|
for ep_num in eps:
|
||||||
|
if (season, ep_num) not in existing_map:
|
||||||
|
await EpisodeService.create(
|
||||||
|
db=db,
|
||||||
|
series_id=series_id,
|
||||||
|
season=season,
|
||||||
|
episode_number=ep_num
|
||||||
|
)
|
||||||
|
|
||||||
def get_total_to_scan(self) -> int:
|
def get_total_to_scan(self) -> int:
|
||||||
"""Get the total number of folders to scan.
|
"""Get the total number of folders to scan.
|
||||||
|
|
||||||
@@ -268,6 +417,11 @@ 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():
|
||||||
|
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
|
||||||
@@ -311,18 +465,47 @@ class SerieScanner:
|
|||||||
)
|
)
|
||||||
serie.episodeDict = missing_episodes
|
serie.episodeDict = missing_episodes
|
||||||
serie.folder = folder
|
serie.folder = folder
|
||||||
data_path = os.path.join(
|
|
||||||
self.directory, folder, 'data'
|
# Persist to database (async)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
# No running loop — safe to use asyncio.run()
|
||||||
|
asyncio.run(self._persist_serie_to_db(serie))
|
||||||
|
else:
|
||||||
|
# Already in async context — schedule as task
|
||||||
|
asyncio.create_task(self._persist_serie_to_db(serie))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"DB persistence failed for '%s', "
|
||||||
|
"continuing without DB: %s",
|
||||||
|
serie.key, e
|
||||||
)
|
)
|
||||||
serie.save_to_file(data_path)
|
|
||||||
|
|
||||||
# Store by key (primary identifier), not folder
|
# Store by key (primary identifier), not folder
|
||||||
if serie.key in self.keyDict:
|
if serie.key in self.keyDict:
|
||||||
logger.error(
|
existing = self.keyDict[serie.key]
|
||||||
"Duplicate series found with key '%s' "
|
logger.warning(
|
||||||
"(folder: '%s')",
|
"Duplicate series found with key '%s': "
|
||||||
|
"folder '%s' maps to same key as existing folder '%s'. "
|
||||||
|
"Skipping duplicate folder.",
|
||||||
serie.key,
|
serie.key,
|
||||||
folder
|
folder,
|
||||||
|
existing.folder
|
||||||
|
)
|
||||||
|
self._safe_call_event(
|
||||||
|
self.events.on_warning,
|
||||||
|
{
|
||||||
|
"operation_id": self._current_operation_id,
|
||||||
|
"warning": "duplicate_key",
|
||||||
|
"message": f"Duplicate series skipped: '{folder}' maps to key '{serie.key}' already used by '{existing.folder}'",
|
||||||
|
"metadata": {
|
||||||
|
"key": serie.key,
|
||||||
|
"duplicate_folder": folder,
|
||||||
|
"existing_folder": existing.folder,
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.keyDict[serie.key] = serie
|
self.keyDict[serie.key] = serie
|
||||||
@@ -426,6 +609,9 @@ class SerieScanner:
|
|||||||
for anime_name in os.listdir(self.directory):
|
for anime_name in os.listdir(self.directory):
|
||||||
anime_path = os.path.join(self.directory, anime_name)
|
anime_path = os.path.join(self.directory, anime_name)
|
||||||
if os.path.isdir(anime_path):
|
if os.path.isdir(anime_path):
|
||||||
|
if settings.should_ignore_folder(anime_name):
|
||||||
|
logger.debug("Skipping ignored folder: %s", anime_name)
|
||||||
|
continue
|
||||||
mp4_files: list[str] = []
|
mp4_files: list[str] = []
|
||||||
has_files = False
|
has_files = False
|
||||||
for root, _, files in os.walk(anime_path):
|
for root, _, files in os.walk(anime_path):
|
||||||
@@ -436,34 +622,78 @@ class SerieScanner:
|
|||||||
yield anime_name, mp4_files if has_files else []
|
yield anime_name, mp4_files if has_files else []
|
||||||
|
|
||||||
def __read_data_from_file(self, folder_name: str) -> Optional[Serie]:
|
def __read_data_from_file(self, folder_name: str) -> Optional[Serie]:
|
||||||
"""Read serie data from file or key file.
|
"""Load or discover a Serie for the given folder.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1. Query DB by folder name
|
||||||
|
2. If found, return cached Serie object
|
||||||
|
3. If not in DB, fall back to provider search via _db_lookup callback
|
||||||
|
4. If still not found, try reading 'data' file for legacy deployments
|
||||||
|
5. Check user-provided key overrides in scan_key_overrides
|
||||||
|
6. Generate key from folder name as last resort
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
folder_name: Filesystem folder name
|
folder_name: Filesystem folder name
|
||||||
(used only to locate data files)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Serie object with valid key if found, None otherwise
|
Serie object with valid key if found, None otherwise
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
The returned Serie will have its 'key' as the primary identifier.
|
DB is the source of truth. File-based lookups (data files)
|
||||||
The 'folder' field is metadata only.
|
are temporary backward compatibility for CLI-only deployments.
|
||||||
"""
|
"""
|
||||||
folder_path = os.path.join(self.directory, folder_name)
|
# Step 1: Try DB lookup by folder name
|
||||||
key = None
|
try:
|
||||||
key_file = os.path.join(folder_path, 'key')
|
session = get_sync_session()
|
||||||
serie_file = os.path.join(folder_path, 'data')
|
try:
|
||||||
|
anime_series = AnimeSeriesService.get_by_folder_sync(session, folder_name)
|
||||||
if os.path.exists(key_file):
|
if anime_series:
|
||||||
with open(key_file, 'r', encoding='utf-8') as file:
|
# Reconstruct Serie from DB record
|
||||||
key = file.read().strip()
|
episode_dict: dict[int, list[int]] = {}
|
||||||
logger.info(
|
if anime_series.episodes:
|
||||||
"Key found for folder '%s': %s",
|
for ep in anime_series.episodes:
|
||||||
folder_name,
|
season = ep.season or 1
|
||||||
key
|
if season not in episode_dict:
|
||||||
|
episode_dict[season] = []
|
||||||
|
episode_dict[season].append(ep.episode_number or ep.number or 0)
|
||||||
|
return Serie(
|
||||||
|
key=anime_series.key,
|
||||||
|
name=anime_series.name,
|
||||||
|
site=anime_series.site,
|
||||||
|
folder=anime_series.folder,
|
||||||
|
episodeDict=episode_dict,
|
||||||
|
year=anime_series.year
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"DB lookup failed for folder '%s': %s",
|
||||||
|
folder_name,
|
||||||
|
exc
|
||||||
)
|
)
|
||||||
return Serie(key, "", "aniworld.to", folder_name, dict())
|
|
||||||
|
|
||||||
|
# Step 2: Fall back to provider search callback
|
||||||
|
if self._db_lookup is not None:
|
||||||
|
try:
|
||||||
|
serie = self._db_lookup(folder_name)
|
||||||
|
if serie and serie.key and serie.key.strip():
|
||||||
|
logger.info(
|
||||||
|
"Provider lookup resolved folder '%s' -> key='%s'",
|
||||||
|
folder_name,
|
||||||
|
serie.key
|
||||||
|
)
|
||||||
|
return serie
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Provider lookup failed for folder '%s': %s",
|
||||||
|
folder_name,
|
||||||
|
exc
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Legacy data file fallback (CLI-only deployments)
|
||||||
|
folder_path = os.path.join(self.directory, folder_name)
|
||||||
|
serie_file = os.path.join(folder_path, 'data')
|
||||||
if os.path.exists(serie_file):
|
if os.path.exists(serie_file):
|
||||||
with open(serie_file, "rb") as file:
|
with open(serie_file, "rb") as file:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -473,6 +703,49 @@ class SerieScanner:
|
|||||||
)
|
)
|
||||||
return Serie.load_from_file(serie_file)
|
return Serie.load_from_file(serie_file)
|
||||||
|
|
||||||
|
# Step 4: Check for user-provided key overrides before generating
|
||||||
|
if self._scan_key_overrides and folder_name in self._scan_key_overrides:
|
||||||
|
override_key = self._scan_key_overrides[folder_name]
|
||||||
|
year_from_folder = self._extract_year_from_folder_name(folder_name)
|
||||||
|
logger.info(
|
||||||
|
"Using scan key override for folder '%s' -> key='%s'",
|
||||||
|
folder_name,
|
||||||
|
override_key
|
||||||
|
)
|
||||||
|
return Serie(
|
||||||
|
key=override_key,
|
||||||
|
name="", # Name will be fetched from provider if needed
|
||||||
|
site="aniworld.to",
|
||||||
|
folder=folder_name,
|
||||||
|
episodeDict=dict(),
|
||||||
|
year=year_from_folder
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 5: Generate key from folder name as last resort
|
||||||
|
# This handles edge cases like non-Latin characters or special symbols
|
||||||
|
try:
|
||||||
|
generated_key = generate_key_from_folder(folder_name)
|
||||||
|
year_from_folder = self._extract_year_from_folder_name(folder_name)
|
||||||
|
logger.info(
|
||||||
|
"Generated key for folder '%s' -> key='%s'",
|
||||||
|
folder_name,
|
||||||
|
generated_key
|
||||||
|
)
|
||||||
|
return Serie(
|
||||||
|
key=generated_key,
|
||||||
|
name="", # Name will be fetched from provider if needed
|
||||||
|
site="aniworld.to",
|
||||||
|
folder=folder_name,
|
||||||
|
episodeDict=dict(),
|
||||||
|
year=year_from_folder
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to generate key for folder '%s': %s",
|
||||||
|
folder_name,
|
||||||
|
exc
|
||||||
|
)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def __get_episode_and_season(self, filename: str) -> tuple[int, int]:
|
def __get_episode_and_season(self, filename: str) -> tuple[int, int]:
|
||||||
|
|||||||
@@ -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,10 @@ 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,
|
||||||
|
scan_key_overrides=settings.scan_key_overrides,
|
||||||
)
|
)
|
||||||
# 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
|
||||||
@@ -441,6 +448,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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"""Utilities for loading and managing stored anime series metadata.
|
"""Utilities for loading and managing stored anime series metadata.
|
||||||
|
|
||||||
This module provides the SerieList class for managing collections of anime
|
This module provides the SerieList class for managing collections of anime
|
||||||
series metadata. It uses file-based storage only.
|
series metadata. It supports loading from both filesystem (legacy) and
|
||||||
|
database (primary).
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
This module is part of the core domain layer and has no database
|
This module is part of the core domain layer. Database operations
|
||||||
dependencies. All database operations are handled by the service layer.
|
are handled by the service layer via add_to_db().
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -16,6 +17,7 @@ import warnings
|
|||||||
from json import JSONDecodeError
|
from json import JSONDecodeError
|
||||||
from typing import Dict, Iterable, List, Optional
|
from typing import Dict, Iterable, List, Optional
|
||||||
|
|
||||||
|
from src.config.settings import settings
|
||||||
from src.core.entities.series import Serie
|
from src.core.entities.series import Serie
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -106,6 +108,76 @@ class SerieList:
|
|||||||
|
|
||||||
return anime_path
|
return anime_path
|
||||||
|
|
||||||
|
async def add_to_db(self, serie: Serie) -> bool:
|
||||||
|
"""Persist a new series to the database.
|
||||||
|
|
||||||
|
Creates the filesystem folder using serie.folder, then persists
|
||||||
|
the series metadata to the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
serie: The Serie instance to add
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.server.database.connection import get_async_session_factory
|
||||||
|
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||||
|
|
||||||
|
folder_name = serie.folder
|
||||||
|
anime_path = os.path.join(self.directory, folder_name)
|
||||||
|
os.makedirs(anime_path, exist_ok=True)
|
||||||
|
|
||||||
|
session_factory = get_async_session_factory()
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
existing = await AnimeSeriesService.get_by_key(db, serie.key)
|
||||||
|
if existing:
|
||||||
|
logger.debug(
|
||||||
|
"Series '%s' (key=%s) already exists in DB, skipping",
|
||||||
|
serie.name, serie.key
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
anime_series = await AnimeSeriesService.create(
|
||||||
|
db=db,
|
||||||
|
key=serie.key,
|
||||||
|
name=serie.name,
|
||||||
|
site=serie.site,
|
||||||
|
folder=folder_name,
|
||||||
|
year=serie.year
|
||||||
|
)
|
||||||
|
for season, eps in serie.episodeDict.items():
|
||||||
|
for ep in eps:
|
||||||
|
await EpisodeService.create(
|
||||||
|
db=db,
|
||||||
|
series_id=anime_series.id,
|
||||||
|
season=season,
|
||||||
|
episode_number=ep
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
self.keyDict[serie.key] = serie
|
||||||
|
logger.info(
|
||||||
|
"Persisted series '%s' to database",
|
||||||
|
serie.name
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(
|
||||||
|
"Failed to persist series '%s' to DB: %s",
|
||||||
|
serie.key, e, exc_info=True
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Could not add series '%s' to DB (DB unavailable?): %s",
|
||||||
|
serie.key, e
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
def contains(self, key: str) -> bool:
|
def contains(self, key: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Return True when a series identified by ``key`` already exists.
|
Return True when a series identified by ``key`` already exists.
|
||||||
@@ -143,6 +215,9 @@ class SerieList:
|
|||||||
}
|
}
|
||||||
|
|
||||||
for anime_folder in entries:
|
for anime_folder in entries:
|
||||||
|
if settings.should_ignore_folder(anime_folder):
|
||||||
|
logger.debug("Skipping ignored folder: %s", anime_folder)
|
||||||
|
continue
|
||||||
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):
|
||||||
logger.debug("Found data file for folder %s", anime_folder)
|
logger.debug("Found data file for folder %s", anime_folder)
|
||||||
@@ -318,3 +393,139 @@ class SerieList:
|
|||||||
if serie.folder == folder:
|
if serie.folder == folder:
|
||||||
return serie
|
return serie
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def load_all_from_db(self) -> int:
|
||||||
|
"""Load all series from database into in-memory cache.
|
||||||
|
|
||||||
|
Retrieves all anime series from the database with their episodes
|
||||||
|
and populates the in-memory keyDict for fast access.
|
||||||
|
|
||||||
|
This method replaces file-based loading. Use after initialization
|
||||||
|
when database is ready.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Number of series loaded into cache
|
||||||
|
"""
|
||||||
|
from src.server.database.connection import get_async_session_factory
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
try:
|
||||||
|
session_factory = get_async_session_factory()
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
anime_series_list = await AnimeSeriesService.get_all(
|
||||||
|
db, with_episodes=True
|
||||||
|
)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for anime_series in anime_series_list:
|
||||||
|
episode_dict: Dict[int, List[int]] = {}
|
||||||
|
if anime_series.episodes:
|
||||||
|
for ep in anime_series.episodes:
|
||||||
|
if ep.season not in episode_dict:
|
||||||
|
episode_dict[ep.season] = []
|
||||||
|
episode_dict[ep.season].append(ep.episode_number)
|
||||||
|
|
||||||
|
serie = Serie(
|
||||||
|
key=anime_series.key,
|
||||||
|
name=anime_series.name,
|
||||||
|
site=anime_series.site,
|
||||||
|
folder=anime_series.folder,
|
||||||
|
episodeDict=episode_dict,
|
||||||
|
year=anime_series.year
|
||||||
|
)
|
||||||
|
self.keyDict[serie.key] = serie
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Loaded %d series from database into in-memory cache",
|
||||||
|
count
|
||||||
|
)
|
||||||
|
return count
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning(
|
||||||
|
"Database not available, skipping DB load"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def _load_single_series_from_db(
|
||||||
|
self,
|
||||||
|
anime_folder: str
|
||||||
|
) -> Optional[Serie]:
|
||||||
|
"""Load a single series from database by folder name.
|
||||||
|
|
||||||
|
Looks up a series in the database by its folder name and adds
|
||||||
|
it to the in-memory cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_folder: The filesystem folder name to look up
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Serie if found and loaded, None otherwise
|
||||||
|
"""
|
||||||
|
from src.server.database.connection import get_async_session_factory
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
try:
|
||||||
|
session_factory = get_async_session_factory()
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
anime_series = await AnimeSeriesService.get_by_folder(
|
||||||
|
db, anime_folder
|
||||||
|
)
|
||||||
|
if not anime_series:
|
||||||
|
logger.debug(
|
||||||
|
"Series with folder '%s' not found in DB",
|
||||||
|
anime_folder
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
episode_dict: Dict[int, List[int]] = {}
|
||||||
|
if anime_series.episodes:
|
||||||
|
for ep in anime_series.episodes:
|
||||||
|
if ep.season not in episode_dict:
|
||||||
|
episode_dict[ep.season] = []
|
||||||
|
episode_dict[ep.season].append(ep.episode_number)
|
||||||
|
|
||||||
|
serie = Serie(
|
||||||
|
key=anime_series.key,
|
||||||
|
name=anime_series.name,
|
||||||
|
site=anime_series.site,
|
||||||
|
folder=anime_series.folder,
|
||||||
|
episodeDict=episode_dict,
|
||||||
|
year=anime_series.year
|
||||||
|
)
|
||||||
|
self.keyDict[serie.key] = serie
|
||||||
|
logger.debug(
|
||||||
|
"Loaded series '%s' (key=%s) from DB",
|
||||||
|
serie.name, serie.key
|
||||||
|
)
|
||||||
|
return serie
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning(
|
||||||
|
"Database not available, cannot load series '%s'",
|
||||||
|
anime_folder
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def invalidate_cache(self) -> None:
|
||||||
|
"""Clear the in-memory cache.
|
||||||
|
|
||||||
|
Use after database modifications to force reload from DB
|
||||||
|
on next access.
|
||||||
|
"""
|
||||||
|
self.keyDict.clear()
|
||||||
|
logger.debug("SerieList in-memory cache invalidated")
|
||||||
|
|
||||||
|
def reload(self) -> None:
|
||||||
|
"""Reload series from filesystem (legacy mode).
|
||||||
|
|
||||||
|
Warning:
|
||||||
|
This method uses file-based loading and should only be
|
||||||
|
used as fallback when database is not available.
|
||||||
|
"""
|
||||||
|
self.load_series()
|
||||||
|
|||||||
@@ -271,7 +271,11 @@ class Serie:
|
|||||||
'Dororo (2025)'
|
'Dororo (2025)'
|
||||||
"""
|
"""
|
||||||
if self._year:
|
if self._year:
|
||||||
return f"{self._name} ({self._year})"
|
import re
|
||||||
|
year_suffix = f" ({self._year})"
|
||||||
|
# Strip ALL trailing year suffixes before appending to prevent duplication
|
||||||
|
clean_name = re.sub(r'(\s*\(\d{4}\))+\s*$', '', self._name).strip()
|
||||||
|
return f"{clean_name}{year_suffix}"
|
||||||
return self._name
|
return self._name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ errors in provider operations with automatic retry mechanisms.
|
|||||||
|
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Callable, TypeVar
|
from typing import Any, Callable, Optional, TypeVar
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -42,41 +42,85 @@ class DownloadError(Exception):
|
|||||||
class RecoveryStrategies:
|
class RecoveryStrategies:
|
||||||
"""Strategies for handling errors and recovering from failures."""
|
"""Strategies for handling errors and recovering from failures."""
|
||||||
|
|
||||||
@staticmethod
|
def __init__(
|
||||||
def handle_network_failure(
|
self,
|
||||||
func: Callable, *args: Any, **kwargs: Any
|
max_retries: int = 3,
|
||||||
) -> Any:
|
base_delay: float = 1.0,
|
||||||
"""Handle network failures with basic retry logic."""
|
max_delay: float = 60.0,
|
||||||
max_retries = 3
|
exponential_base: float = 2.0,
|
||||||
for attempt in range(max_retries):
|
) -> None:
|
||||||
try:
|
"""Initialize recovery strategies.
|
||||||
return func(*args, **kwargs)
|
|
||||||
except (NetworkError, ConnectionError):
|
|
||||||
if attempt == max_retries - 1:
|
|
||||||
raise
|
|
||||||
logger.warning(
|
|
||||||
"Network error on attempt %d, retrying...",
|
|
||||||
attempt + 1,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
@staticmethod
|
Args:
|
||||||
def handle_download_failure(
|
max_retries: Maximum number of retry attempts.
|
||||||
|
base_delay: Initial delay between retries in seconds.
|
||||||
|
max_delay: Maximum delay between retries in seconds.
|
||||||
|
exponential_base: Base for exponential backoff multiplier.
|
||||||
|
"""
|
||||||
|
self.max_retries = max_retries
|
||||||
|
self.base_delay = base_delay
|
||||||
|
self.max_delay = max_delay
|
||||||
|
self.exponential_base = exponential_base
|
||||||
|
|
||||||
|
def _calculate_delay(self, attempt: int) -> float:
|
||||||
|
"""Calculate delay for given retry attempt using exponential backoff.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attempt: Zero-based retry attempt number.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Delay in seconds before next retry.
|
||||||
|
"""
|
||||||
|
delay = self.base_delay * (self.exponential_base ** attempt)
|
||||||
|
return min(delay, self.max_delay)
|
||||||
|
|
||||||
|
def handle_network_failure(
|
||||||
|
self,
|
||||||
func: Callable, *args: Any, **kwargs: Any
|
func: Callable, *args: Any, **kwargs: Any
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Handle download failures with retry logic."""
|
"""Handle network failures with exponential backoff retry logic."""
|
||||||
max_retries = 2
|
last_error: Optional[Exception] = None
|
||||||
for attempt in range(max_retries):
|
for attempt in range(self.max_retries):
|
||||||
try:
|
try:
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
except DownloadError:
|
except (NetworkError, ConnectionError, TimeoutError) as exc:
|
||||||
if attempt == max_retries - 1:
|
last_error = exc
|
||||||
raise
|
if attempt < self.max_retries - 1:
|
||||||
|
delay = self._calculate_delay(attempt)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Download error on attempt %d, retrying...",
|
"Network error on attempt %d/%d, retrying in %.1fs: %s",
|
||||||
attempt + 1,
|
attempt + 1, self.max_retries, delay, exc
|
||||||
)
|
)
|
||||||
|
import time
|
||||||
|
time.sleep(delay)
|
||||||
continue
|
continue
|
||||||
|
if last_error:
|
||||||
|
raise last_error
|
||||||
|
raise NetworkError("Network failure after retries")
|
||||||
|
|
||||||
|
def handle_download_failure(
|
||||||
|
self,
|
||||||
|
func: Callable, *args: Any, **kwargs: Any
|
||||||
|
) -> Any:
|
||||||
|
"""Handle download failures with exponential backoff retry logic."""
|
||||||
|
last_error: Optional[Exception] = None
|
||||||
|
for attempt in range(self.max_retries):
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except DownloadError as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt < self.max_retries - 1:
|
||||||
|
delay = self._calculate_delay(attempt)
|
||||||
|
logger.warning(
|
||||||
|
"Download error on attempt %d/%d, retrying in %.1fs: %s",
|
||||||
|
attempt + 1, self.max_retries, delay, exc
|
||||||
|
)
|
||||||
|
import time
|
||||||
|
time.sleep(delay)
|
||||||
|
continue
|
||||||
|
if last_error:
|
||||||
|
raise last_error
|
||||||
|
raise DownloadError("Download failed after retries")
|
||||||
|
|
||||||
|
|
||||||
class FileCorruptionDetector:
|
class FileCorruptionDetector:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import threading
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import chardet
|
||||||
import requests
|
import requests
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from events import Events
|
from events import Events
|
||||||
@@ -80,6 +81,37 @@ if not download_error_logger.handlers:
|
|||||||
noKeyFound_logger = logging.getLogger()
|
noKeyFound_logger = logging.getLogger()
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_html_content(content: bytes) -> str:
|
||||||
|
"""Decode HTML content with encoding detection.
|
||||||
|
|
||||||
|
Uses chardet to detect the actual encoding of the content,
|
||||||
|
falling back to utf-8 with replacement error handling.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Raw HTML bytes from the response
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decoded string content
|
||||||
|
"""
|
||||||
|
detected = chardet.detect(content)
|
||||||
|
encoding = detected.get('encoding', 'utf-8')
|
||||||
|
confidence = detected.get('confidence', 0)
|
||||||
|
|
||||||
|
if confidence < 0.7:
|
||||||
|
logger.debug(
|
||||||
|
"Low encoding confidence (%.2f) for detected encoding '%s', using utf-8",
|
||||||
|
confidence,
|
||||||
|
encoding
|
||||||
|
)
|
||||||
|
encoding = 'utf-8'
|
||||||
|
|
||||||
|
try:
|
||||||
|
return content.decode(encoding, errors='replace')
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to decode content with %s: %s, using utf-8 replace", encoding, exc)
|
||||||
|
return content.decode('utf-8', errors='replace')
|
||||||
|
|
||||||
|
|
||||||
class AniworldLoader(Loader):
|
class AniworldLoader(Loader):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.SUPPORTED_PROVIDERS = DEFAULT_PROVIDERS
|
self.SUPPORTED_PROVIDERS = DEFAULT_PROVIDERS
|
||||||
@@ -90,7 +122,10 @@ class AniworldLoader(Loader):
|
|||||||
self.LULUVDO_USER_AGENT = LULUVDO_USER_AGENT
|
self.LULUVDO_USER_AGENT = LULUVDO_USER_AGENT
|
||||||
self.PROVIDER_HEADERS = {
|
self.PROVIDER_HEADERS = {
|
||||||
ProviderType.VIDMOLY.value: ['Referer: "https://vidmoly.to"'],
|
ProviderType.VIDMOLY.value: ['Referer: "https://vidmoly.to"'],
|
||||||
ProviderType.DOODSTREAM.value: ['Referer: "https://dood.li/"'],
|
ProviderType.DOODSTREAM.value: [
|
||||||
|
'Referer: "https://dood.li/"',
|
||||||
|
'Referer: "https://playmogo.com/"',
|
||||||
|
],
|
||||||
ProviderType.VOE.value: [f"User-Agent: {self.RANDOM_USER_AGENT}"],
|
ProviderType.VOE.value: [f"User-Agent: {self.RANDOM_USER_AGENT}"],
|
||||||
ProviderType.LULUVDO.value: [
|
ProviderType.LULUVDO.value: [
|
||||||
f"User-Agent: {self.LULUVDO_USER_AGENT}",
|
f"User-Agent: {self.LULUVDO_USER_AGENT}",
|
||||||
@@ -231,7 +266,7 @@ class AniworldLoader(Loader):
|
|||||||
language_code = self._get_language_key(language)
|
language_code = self._get_language_key(language)
|
||||||
|
|
||||||
episode_soup = BeautifulSoup(
|
episode_soup = BeautifulSoup(
|
||||||
self._get_episode_html(season, episode, key).content,
|
_decode_html_content(self._get_episode_html(season, episode, key).content),
|
||||||
'html.parser'
|
'html.parser'
|
||||||
)
|
)
|
||||||
change_language_box_div = episode_soup.find(
|
change_language_box_div = episode_soup.find(
|
||||||
@@ -249,6 +284,118 @@ class AniworldLoader(Loader):
|
|||||||
logger.debug("Available languages for S%02dE%03d: %s, requested: %s, available: %s", season, episode, languages, language_code, 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 _check_url_alive(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
headers: dict | None = None,
|
||||||
|
timeout: int = 10,
|
||||||
|
) -> bool:
|
||||||
|
"""Probe a provider URL with HEAD before committing to yt-dlp.
|
||||||
|
|
||||||
|
Skips dead providers quickly so the failover loop never blocks
|
||||||
|
waiting for yt-dlp to fail on a 404. Falls back to a streaming
|
||||||
|
GET when HEAD is not allowed by the upstream server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to probe.
|
||||||
|
headers: Optional headers to forward with the probe.
|
||||||
|
timeout: Per-request timeout (seconds).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True when the URL responds with a non-4xx status, else False.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = self.session.head(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout,
|
||||||
|
allow_redirects=True,
|
||||||
|
)
|
||||||
|
if response.status_code == 405:
|
||||||
|
response = self.session.get(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout,
|
||||||
|
stream=True,
|
||||||
|
allow_redirects=True,
|
||||||
|
)
|
||||||
|
response.close()
|
||||||
|
if 400 <= response.status_code < 500:
|
||||||
|
logger.warning(
|
||||||
|
"Provider URL returned HTTP %s: %s",
|
||||||
|
response.status_code, url
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
logger.warning("Provider URL unreachable %s: %s", url, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _try_direct_stream(
|
||||||
|
self,
|
||||||
|
link: str,
|
||||||
|
output_path: str,
|
||||||
|
headers: dict | None,
|
||||||
|
timeout: int,
|
||||||
|
) -> bool:
|
||||||
|
"""Stream a direct video URL to disk without yt-dlp.
|
||||||
|
|
||||||
|
Used as a fast-path when the resolved provider link already points
|
||||||
|
at a downloadable video file (``Content-Type: video/*`` or
|
||||||
|
``application/octet-stream``). HLS and other non-video payloads
|
||||||
|
are rejected so the caller can fall back to yt-dlp.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
link: Direct download URL.
|
||||||
|
output_path: Destination file path.
|
||||||
|
headers: Optional HTTP headers.
|
||||||
|
timeout: Per-request timeout (seconds).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True on a successful save, False when the link is not a
|
||||||
|
direct video or the download fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with self.session.get(
|
||||||
|
link,
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout,
|
||||||
|
stream=True,
|
||||||
|
) as response:
|
||||||
|
if not response.ok:
|
||||||
|
logger.debug(
|
||||||
|
"Direct stream HEAD returned %s for %s",
|
||||||
|
response.status_code, link[:80]
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
content_type = response.headers.get("Content-Type", "")
|
||||||
|
if not (
|
||||||
|
content_type.startswith("video/")
|
||||||
|
or content_type == "application/octet-stream"
|
||||||
|
):
|
||||||
|
logger.debug(
|
||||||
|
"Direct stream skipped, Content-Type=%s",
|
||||||
|
content_type
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
logger.info(
|
||||||
|
"Direct stream download starting (type=%s)",
|
||||||
|
content_type
|
||||||
|
)
|
||||||
|
with open(output_path, "wb") as fh:
|
||||||
|
for chunk in response.iter_content(chunk_size=1024 * 1024):
|
||||||
|
if self._cancel_flag.is_set():
|
||||||
|
logger.info(
|
||||||
|
"Cancellation detected during direct stream"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
if chunk:
|
||||||
|
fh.write(chunk)
|
||||||
|
return True
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
logger.warning("Direct stream download failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
def download(
|
def download(
|
||||||
self,
|
self,
|
||||||
base_directory: str,
|
base_directory: str,
|
||||||
@@ -260,6 +407,11 @@ class AniworldLoader(Loader):
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""Download episode to specified directory.
|
"""Download episode to specified directory.
|
||||||
|
|
||||||
|
Iterates the providers actually advertised on the episode page
|
||||||
|
(ordered by SUPPORTED_PROVIDERS preference), probing each URL
|
||||||
|
before attempting an extraction so dead providers are skipped
|
||||||
|
immediately instead of stalling yt-dlp on a 404.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
base_directory: Base download directory path
|
base_directory: Base download directory path
|
||||||
serie_folder: Filesystem folder name (metadata only, used for
|
serie_folder: Filesystem folder name (metadata only, used for
|
||||||
@@ -308,12 +460,78 @@ class AniworldLoader(Loader):
|
|||||||
temp_path = os.path.join(temp_dir, output_file)
|
temp_path = os.path.join(temp_dir, output_file)
|
||||||
logger.debug("Temporary path: %s", temp_path)
|
logger.debug("Temporary path: %s", temp_path)
|
||||||
|
|
||||||
for provider in self.SUPPORTED_PROVIDERS:
|
candidate_providers = self._select_providers_for_episode(
|
||||||
logger.debug("Attempting download with provider: %s", provider)
|
|
||||||
link, header = self._get_direct_link_from_provider(
|
|
||||||
season, episode, key, language
|
season, episode, key, language
|
||||||
)
|
)
|
||||||
logger.debug("Direct link obtained from provider")
|
if not candidate_providers:
|
||||||
|
logger.error(
|
||||||
|
"No providers advertised for S%02dE%03d (%s) in %s",
|
||||||
|
season, episode, key, language
|
||||||
|
)
|
||||||
|
self.clear_cache()
|
||||||
|
return False
|
||||||
|
|
||||||
|
tried: list[str] = []
|
||||||
|
for provider_name, redirect_url in candidate_providers:
|
||||||
|
tried.append(provider_name)
|
||||||
|
logger.debug("Attempting download with provider: %s", provider_name)
|
||||||
|
|
||||||
|
probe_headers = {"User-Agent": self.RANDOM_USER_AGENT}
|
||||||
|
if not self._check_url_alive(
|
||||||
|
redirect_url,
|
||||||
|
headers=probe_headers,
|
||||||
|
timeout=self.DEFAULT_REQUEST_TIMEOUT,
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"Skipping provider %s, redirect URL not reachable",
|
||||||
|
provider_name
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved = self._resolve_direct_link(
|
||||||
|
redirect_url, provider_name
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Provider %s link resolution failed: %s: %s",
|
||||||
|
provider_name, type(exc).__name__, exc
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if resolved is None:
|
||||||
|
logger.info(
|
||||||
|
"Provider %s returned no direct link", provider_name
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
link, header = resolved
|
||||||
|
|
||||||
|
if self._cancel_flag.is_set():
|
||||||
|
logger.info("Cancellation requested before download start")
|
||||||
|
_cleanup_temp_file(temp_path)
|
||||||
|
self.clear_cache()
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self._try_direct_stream(
|
||||||
|
link,
|
||||||
|
temp_path,
|
||||||
|
header,
|
||||||
|
self.DEFAULT_REQUEST_TIMEOUT,
|
||||||
|
) and os.path.exists(temp_path):
|
||||||
|
logger.debug(
|
||||||
|
"Direct stream succeeded with provider %s", provider_name
|
||||||
|
)
|
||||||
|
shutil.copyfile(temp_path, output_path)
|
||||||
|
os.remove(temp_path)
|
||||||
|
logger.info(
|
||||||
|
"Download completed successfully (direct): %s",
|
||||||
|
output_file
|
||||||
|
)
|
||||||
|
self.clear_cache()
|
||||||
|
return True
|
||||||
|
|
||||||
|
_cleanup_temp_file(temp_path)
|
||||||
|
|
||||||
cancel_flag = self._cancel_flag
|
cancel_flag = self._cancel_flag
|
||||||
|
|
||||||
@@ -321,7 +539,6 @@ class AniworldLoader(Loader):
|
|||||||
if cancel_flag.is_set():
|
if cancel_flag.is_set():
|
||||||
logger.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
|
|
||||||
self.events.download_progress(d)
|
self.events.download_progress(d)
|
||||||
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
@@ -331,7 +548,12 @@ 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],
|
||||||
|
# yt-dlp defaults to native HLS downloader which warns about
|
||||||
|
# "Live HLS streams are not supported" - disable to go
|
||||||
|
# straight to ffmpeg, avoiding the warning
|
||||||
|
'hls_prefer_native': False,
|
||||||
}
|
}
|
||||||
|
|
||||||
if header:
|
if header:
|
||||||
@@ -339,9 +561,11 @@ class AniworldLoader(Loader):
|
|||||||
logger.debug("Using custom headers for download")
|
logger.debug("Using custom headers for download")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.debug("Starting YoutubeDL download")
|
logger.info(
|
||||||
|
"Starting yt-dlp download with %s: %s",
|
||||||
|
provider_name, output_file
|
||||||
|
)
|
||||||
logger.debug("Download link: %s...", link[:100])
|
logger.debug("Download link: %s...", link[:100])
|
||||||
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)
|
||||||
@@ -352,39 +576,185 @@ class AniworldLoader(Loader):
|
|||||||
|
|
||||||
if os.path.exists(temp_path):
|
if os.path.exists(temp_path):
|
||||||
logger.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
|
|
||||||
shutil.copyfile(temp_path, output_path)
|
shutil.copyfile(temp_path, output_path)
|
||||||
os.remove(temp_path)
|
os.remove(temp_path)
|
||||||
logger.info("Download completed successfully: %s", output_file)
|
logger.info(
|
||||||
|
"Download completed successfully: %s", output_file
|
||||||
|
)
|
||||||
self.clear_cache()
|
self.clear_cache()
|
||||||
return True
|
return True
|
||||||
else:
|
logger.error(
|
||||||
logger.error("Download failed: temp file not found at %s", temp_path)
|
"Download failed: temp file not found at %s", temp_path
|
||||||
|
)
|
||||||
|
except DownloadCancelled:
|
||||||
|
logger.info("Download cancelled by user")
|
||||||
|
_cleanup_temp_file(temp_path)
|
||||||
self.clear_cache()
|
self.clear_cache()
|
||||||
return False
|
return False
|
||||||
except BrokenPipeError as e:
|
except BrokenPipeError as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Broken pipe error with provider %s: %s. "
|
"Broken pipe error with provider %s: %s",
|
||||||
"This usually means the stream connection was closed.",
|
provider_name, exc
|
||||||
provider, e
|
|
||||||
)
|
)
|
||||||
_cleanup_temp_file(temp_path)
|
_cleanup_temp_file(temp_path)
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
|
# Check if this is an HLS-related failure that might succeed
|
||||||
|
# with additional ffmpeg options
|
||||||
|
exc_str = str(exc).lower()
|
||||||
|
is_hls_related = (
|
||||||
|
'hls' in exc_str or
|
||||||
|
'live' in exc_str or
|
||||||
|
'native downloader' in exc_str
|
||||||
|
)
|
||||||
|
if is_hls_related and 'ffmpeg' not in str(ydl_opts.get('downloader', '')):
|
||||||
|
logger.info(
|
||||||
|
"HLS stream detected, retrying with ffmpeg options: %s",
|
||||||
|
output_file
|
||||||
|
)
|
||||||
|
# Retry with ffmpeg explicitly set
|
||||||
|
retry_opts = ydl_opts.copy()
|
||||||
|
retry_opts['downloader'] = 'ffmpeg'
|
||||||
|
retry_opts['hls_use_mpegts'] = True
|
||||||
|
try:
|
||||||
|
with YoutubeDL(retry_opts) as ydl:
|
||||||
|
info = ydl.extract_info(link, download=True)
|
||||||
|
if os.path.exists(temp_path):
|
||||||
|
shutil.copyfile(temp_path, output_path)
|
||||||
|
os.remove(temp_path)
|
||||||
|
logger.info(
|
||||||
|
"Download completed successfully (retry): %s",
|
||||||
|
output_file
|
||||||
|
)
|
||||||
|
self.clear_cache()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
_cleanup_temp_file(temp_path)
|
||||||
|
# Continue to next provider if retry also fails
|
||||||
|
continue
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
"YoutubeDL download failed with provider %s: %s: %s",
|
"YoutubeDL download failed with provider %s: %s: %s",
|
||||||
provider, type(e).__name__, e
|
provider_name, type(exc).__name__, exc
|
||||||
)
|
)
|
||||||
_cleanup_temp_file(temp_path)
|
_cleanup_temp_file(temp_path)
|
||||||
continue
|
continue
|
||||||
break
|
|
||||||
|
|
||||||
# If we get here, all providers failed
|
logger.error(
|
||||||
logger.error("All download providers failed")
|
"All download providers failed for S%02dE%03d (%s) in %s. "
|
||||||
|
"Tried: %s. Episode may be unavailable on the source site.",
|
||||||
|
season, episode, key, language, ", ".join(tried) or "none"
|
||||||
|
)
|
||||||
|
download_error_logger.error(
|
||||||
|
"All providers failed for %s S%02dE%03d (%s); tried=%s",
|
||||||
|
key, season, episode, language, tried
|
||||||
|
)
|
||||||
_cleanup_temp_file(temp_path)
|
_cleanup_temp_file(temp_path)
|
||||||
self.clear_cache()
|
self.clear_cache()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _select_providers_for_episode(
|
||||||
|
self,
|
||||||
|
season: int,
|
||||||
|
episode: int,
|
||||||
|
key: str,
|
||||||
|
language: str,
|
||||||
|
) -> list[tuple[str, str]]:
|
||||||
|
"""Return ``[(provider_name, redirect_url), ...]`` for an episode.
|
||||||
|
|
||||||
|
Filters by requested language and orders results by
|
||||||
|
``SUPPORTED_PROVIDERS`` preference so the failover chain matches
|
||||||
|
operator expectations. Returns an empty list when nothing is
|
||||||
|
advertised on the page.
|
||||||
|
"""
|
||||||
|
if not self.is_language(season, episode, key, language):
|
||||||
|
logger.warning(
|
||||||
|
"Language %s not advertised for S%02dE%03d (%s)",
|
||||||
|
language, season, episode, key
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
language_code = self._get_language_key(language)
|
||||||
|
providers = self._get_provider_from_html(season, episode, key)
|
||||||
|
ordered: list[tuple[str, str]] = []
|
||||||
|
preferred = list(self.SUPPORTED_PROVIDERS)
|
||||||
|
for name in preferred:
|
||||||
|
lang_map = providers.get(name)
|
||||||
|
if lang_map and language_code in lang_map:
|
||||||
|
ordered.append((name, lang_map[language_code]))
|
||||||
|
for name, lang_map in providers.items():
|
||||||
|
if name in preferred:
|
||||||
|
continue
|
||||||
|
if language_code in lang_map:
|
||||||
|
ordered.append((name, lang_map[language_code]))
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
def _resolve_direct_link(
|
||||||
|
self,
|
||||||
|
redirect_url: str,
|
||||||
|
provider_name: str,
|
||||||
|
) -> tuple[str, dict] | None:
|
||||||
|
"""Resolve a provider redirect URL into a direct stream link.
|
||||||
|
|
||||||
|
Follows the redirect to the embedded player, then delegates to a
|
||||||
|
provider-specific extractor (when registered) or returns the
|
||||||
|
embed URL itself so yt-dlp can attempt extraction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
redirect_url: AniWorld redirect URL.
|
||||||
|
provider_name: Provider key (e.g. ``"VOE"``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``(direct_link, headers)`` tuple or None when extraction fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
embedded = self.session.get(
|
||||||
|
redirect_url,
|
||||||
|
timeout=self.DEFAULT_REQUEST_TIMEOUT,
|
||||||
|
headers={"User-Agent": self.RANDOM_USER_AGENT},
|
||||||
|
allow_redirects=True,
|
||||||
|
).url
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed resolving redirect for %s: %s", provider_name, exc
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
extractor = self.Providers.GetProvider(provider_name)
|
||||||
|
except (KeyError, AttributeError):
|
||||||
|
extractor = None
|
||||||
|
|
||||||
|
if extractor is not None:
|
||||||
|
try:
|
||||||
|
return extractor.get_link(
|
||||||
|
embedded, self.DEFAULT_REQUEST_TIMEOUT
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Custom extractor %s failed: %s",
|
||||||
|
provider_name, exc
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
header_list = self.PROVIDER_HEADERS.get(provider_name)
|
||||||
|
header_dict = self._parse_provider_headers(header_list)
|
||||||
|
return embedded, header_dict
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_provider_headers(
|
||||||
|
header_list: list | None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Convert legacy ``"Name: value"`` header strings to a dict."""
|
||||||
|
if not header_list:
|
||||||
|
return {}
|
||||||
|
parsed: dict[str, str] = {}
|
||||||
|
for entry in header_list:
|
||||||
|
if not isinstance(entry, str) or ":" not in entry:
|
||||||
|
continue
|
||||||
|
name, _, value = entry.partition(":")
|
||||||
|
parsed[name.strip()] = value.strip().strip('"')
|
||||||
|
return parsed
|
||||||
|
|
||||||
def get_site_key(self) -> str:
|
def get_site_key(self) -> str:
|
||||||
"""Get the site key for this provider."""
|
"""Get the site key for this provider."""
|
||||||
return "aniworld.to"
|
return "aniworld.to"
|
||||||
@@ -393,7 +763,7 @@ class AniworldLoader(Loader):
|
|||||||
"""Get anime title from series key."""
|
"""Get anime title from series key."""
|
||||||
logger.debug("Getting title for key: %s", key)
|
logger.debug("Getting title for key: %s", key)
|
||||||
soup = BeautifulSoup(
|
soup = BeautifulSoup(
|
||||||
self._get_key_html(key).content,
|
_decode_html_content(self._get_key_html(key).content),
|
||||||
'html.parser'
|
'html.parser'
|
||||||
)
|
)
|
||||||
title_div = soup.find('div', class_='series-title')
|
title_div = soup.find('div', class_='series-title')
|
||||||
@@ -424,7 +794,7 @@ class AniworldLoader(Loader):
|
|||||||
logger.debug("Getting year for key: %s", key)
|
logger.debug("Getting year for key: %s", key)
|
||||||
try:
|
try:
|
||||||
soup = BeautifulSoup(
|
soup = BeautifulSoup(
|
||||||
self._get_key_html(key).content,
|
_decode_html_content(self._get_key_html(key).content),
|
||||||
'html.parser'
|
'html.parser'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -538,7 +908,7 @@ class AniworldLoader(Loader):
|
|||||||
"""
|
"""
|
||||||
logger.debug("Extracting providers from HTML for S%02dE%03d (%s)", season, episode, 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,
|
_decode_html_content(self._get_episode_html(season, episode, key).content),
|
||||||
'html.parser'
|
'html.parser'
|
||||||
)
|
)
|
||||||
providers: dict[str, dict[int, str]] = {}
|
providers: dict[str, dict[int, str]] = {}
|
||||||
@@ -661,7 +1031,7 @@ class AniworldLoader(Loader):
|
|||||||
base_url = f"{self.ANIWORLD_TO}/anime/stream/{safe_slug}/"
|
base_url = f"{self.ANIWORLD_TO}/anime/stream/{safe_slug}/"
|
||||||
logger.debug("Base URL: %s", 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(_decode_html_content(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
|
||||||
@@ -676,7 +1046,7 @@ class AniworldLoader(Loader):
|
|||||||
season_url,
|
season_url,
|
||||||
timeout=self.DEFAULT_REQUEST_TIMEOUT,
|
timeout=self.DEFAULT_REQUEST_TIMEOUT,
|
||||||
)
|
)
|
||||||
soup = BeautifulSoup(response.content, 'html.parser')
|
soup = BeautifulSoup(_decode_html_content(response.content), 'html.parser')
|
||||||
|
|
||||||
episode_links = soup.find_all('a', href=True)
|
episode_links = soup.find_all('a', href=True)
|
||||||
unique_links = set(
|
unique_links = set(
|
||||||
|
|||||||
@@ -88,7 +88,10 @@ class EnhancedAniWorldLoader(Loader):
|
|||||||
|
|
||||||
self.PROVIDER_HEADERS = {
|
self.PROVIDER_HEADERS = {
|
||||||
ProviderType.VIDMOLY.value: ['Referer: "https://vidmoly.to"'],
|
ProviderType.VIDMOLY.value: ['Referer: "https://vidmoly.to"'],
|
||||||
ProviderType.DOODSTREAM.value: ['Referer: "https://dood.li/"'],
|
ProviderType.DOODSTREAM.value: [
|
||||||
|
'Referer: "https://dood.li/"',
|
||||||
|
'Referer: "https://playmogo.com/"',
|
||||||
|
],
|
||||||
ProviderType.VOE.value: [f'User-Agent: {self.RANDOM_USER_AGENT}'],
|
ProviderType.VOE.value: [f'User-Agent: {self.RANDOM_USER_AGENT}'],
|
||||||
ProviderType.LULUVDO.value: [
|
ProviderType.LULUVDO.value: [
|
||||||
f'User-Agent: {self.LULUVDO_USER_AGENT}',
|
f'User-Agent: {self.LULUVDO_USER_AGENT}',
|
||||||
@@ -566,6 +569,10 @@ 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,
|
||||||
|
# Use ffmpeg for HLS streams and transport stream format
|
||||||
|
"downloader": "ffmpeg",
|
||||||
|
"hls_use_mpegts": True,
|
||||||
}
|
}
|
||||||
if headers:
|
if headers:
|
||||||
ydl_opts['http_headers'] = headers
|
ydl_opts['http_headers'] = headers
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from typing import Dict, List
|
|||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
from src.core.services.nfo_service import NFOService
|
from src.core.services.nfo_service import NFOService
|
||||||
|
from src.core.services.tmdb_client import TMDBAPIError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -120,6 +121,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.
|
||||||
|
|
||||||
@@ -171,10 +203,26 @@ class NfoRepairService:
|
|||||||
", ".join(missing),
|
", ".join(missing),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
await self._nfo_service.update_tvshow_nfo(
|
await self._nfo_service.update_tvshow_nfo(
|
||||||
series_name,
|
series_name,
|
||||||
download_media=False,
|
download_media=False,
|
||||||
)
|
)
|
||||||
|
except TMDBAPIError as e:
|
||||||
|
if "No TMDB ID found" in str(e):
|
||||||
|
# No TMDB ID in existing NFO — create new one via search
|
||||||
|
logger.info(
|
||||||
|
"NFO has no TMDB ID, creating new NFO via TMDB search"
|
||||||
|
)
|
||||||
|
await self._nfo_service.create_tvshow_nfo(
|
||||||
|
serie_name=series_name,
|
||||||
|
serie_folder=series_name,
|
||||||
|
download_poster=False,
|
||||||
|
download_logo=False,
|
||||||
|
download_fanart=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
logger.info("NFO repair completed: %s", series_name)
|
logger.info("NFO repair completed: %s", series_name)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Example:
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import unicodedata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ from src.core.services.tmdb_client import TMDBAPIError, TMDBClient
|
|||||||
from src.core.utils.image_downloader import ImageDownloader
|
from src.core.utils.image_downloader import ImageDownloader
|
||||||
from src.core.utils.nfo_generator import generate_tvshow_nfo
|
from src.core.utils.nfo_generator import generate_tvshow_nfo
|
||||||
from src.core.utils.nfo_mapper import tmdb_to_nfo_model
|
from src.core.utils.nfo_mapper import tmdb_to_nfo_model
|
||||||
|
from src.core.entities.nfo_models import TVShowNFO
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -53,6 +55,18 @@ class NFOService:
|
|||||||
self.image_size = image_size
|
self.image_size = image_size
|
||||||
self.auto_create = auto_create
|
self.auto_create = auto_create
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "NFOService":
|
||||||
|
"""Enter async context manager."""
|
||||||
|
await self.tmdb_client.__aenter__()
|
||||||
|
await self.image_downloader.__aenter__()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""Exit async context manager and cleanup resources."""
|
||||||
|
await self.tmdb_client.close()
|
||||||
|
await self.image_downloader.close()
|
||||||
|
return False
|
||||||
|
|
||||||
def has_nfo(self, serie_folder: str) -> bool:
|
def has_nfo(self, serie_folder: str) -> bool:
|
||||||
"""Check if tvshow.nfo exists for a series.
|
"""Check if tvshow.nfo exists for a series.
|
||||||
|
|
||||||
@@ -83,11 +97,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
|
||||||
|
|
||||||
@@ -110,7 +125,8 @@ class NFOService:
|
|||||||
year: Optional[int] = None,
|
year: Optional[int] = None,
|
||||||
download_poster: bool = True,
|
download_poster: bool = True,
|
||||||
download_logo: bool = True,
|
download_logo: bool = True,
|
||||||
download_fanart: bool = True
|
download_fanart: bool = True,
|
||||||
|
alt_titles: Optional[List[str]] = None
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""Create tvshow.nfo by scraping TMDB.
|
"""Create tvshow.nfo by scraping TMDB.
|
||||||
|
|
||||||
@@ -122,6 +138,7 @@ class NFOService:
|
|||||||
download_poster: Whether to download poster.jpg
|
download_poster: Whether to download poster.jpg
|
||||||
download_logo: Whether to download logo.png
|
download_logo: Whether to download logo.png
|
||||||
download_fanart: Whether to download fanart.jpg
|
download_fanart: Whether to download fanart.jpg
|
||||||
|
alt_titles: Alternative titles (e.g., Japanese title) for fallback search
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path to created NFO file
|
Path to created NFO file
|
||||||
@@ -146,23 +163,46 @@ class NFOService:
|
|||||||
logger.info("Creating series folder: %s", 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)
|
||||||
|
|
||||||
|
# Check for existing NFO with TMDB ID to skip search
|
||||||
|
nfo_path = folder_path / "tvshow.nfo"
|
||||||
|
existing_ids = None
|
||||||
|
if nfo_path.exists():
|
||||||
|
try:
|
||||||
|
existing_ids = self.parse_nfo_ids(nfo_path)
|
||||||
|
if existing_ids.get("tmdb_id"):
|
||||||
|
logger.info(
|
||||||
|
"Found existing TMDB ID %s in NFO, using directly",
|
||||||
|
existing_ids["tmdb_id"]
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Could not parse existing NFO IDs: %s", e)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.tmdb_client._ensure_session()
|
await self.tmdb_client._ensure_session()
|
||||||
|
|
||||||
# Search for TV show with clean name (without year)
|
# Use existing TMDB ID if found, otherwise search
|
||||||
logger.debug("Searching TMDB for: %s", search_name)
|
if existing_ids and existing_ids.get("tmdb_id"):
|
||||||
search_results = await self.tmdb_client.search_tv_show(search_name)
|
tv_id = existing_ids["tmdb_id"]
|
||||||
|
logger.info("Fetching details directly for TMDB ID: %s", tv_id)
|
||||||
if not search_results.get("results"):
|
details = await self.tmdb_client.get_tv_show_details(
|
||||||
raise TMDBAPIError(f"No results found for: {search_name}")
|
tv_id,
|
||||||
|
append_to_response="credits,external_ids,images"
|
||||||
# Find best match (consider year if provided)
|
)
|
||||||
tv_show = self._find_best_match(search_results["results"], search_name, year)
|
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tv_id)
|
||||||
|
tv_show = {"id": tv_id, "name": details.get("name", serie_name)}
|
||||||
|
search_source = "nfo_override"
|
||||||
|
else:
|
||||||
|
# Search for TV show - try multiple strategies
|
||||||
|
tv_show, search_source = await self._search_with_fallback(
|
||||||
|
search_name, year, alt_titles
|
||||||
|
)
|
||||||
tv_id = tv_show["id"]
|
tv_id = tv_show["id"]
|
||||||
|
|
||||||
logger.info("Found match: %s (ID: %s)", tv_show['name'], 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
|
||||||
|
# Skip if we already fetched details via nfo_override
|
||||||
|
if search_source != "nfo_override":
|
||||||
details = await self.tmdb_client.get_tv_show_details(
|
details = await self.tmdb_client.get_tv_show_details(
|
||||||
tv_id,
|
tv_id,
|
||||||
append_to_response="credits,external_ids,images"
|
append_to_response="credits,external_ids,images"
|
||||||
@@ -203,6 +243,9 @@ class NFOService:
|
|||||||
details = await self._enrich_details_with_fallback(
|
details = await self._enrich_details_with_fallback(
|
||||||
details, search_overview=search_overview
|
details, search_overview=search_overview
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# When using nfo_override, content_ratings already fetched
|
||||||
|
pass
|
||||||
|
|
||||||
# Convert TMDB data to TVShowNFO model
|
# Convert TMDB data to TVShowNFO model
|
||||||
nfo_model = tmdb_to_nfo_model(
|
nfo_model = tmdb_to_nfo_model(
|
||||||
@@ -413,6 +456,62 @@ class NFOService:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def parse_nfo_year(self, nfo_path: Path) -> Optional[int]:
|
||||||
|
"""Parse year from an existing NFO file.
|
||||||
|
|
||||||
|
Extracts year from <year> or <premiered> elements.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nfo_path: Path to tvshow.nfo file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Year as integer if found, None otherwise.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> year = nfo_service.parse_nfo_year(Path("/anime/series/tvshow.nfo"))
|
||||||
|
>>> print(year)
|
||||||
|
2013
|
||||||
|
"""
|
||||||
|
if not nfo_path.exists():
|
||||||
|
logger.debug("NFO file not found: %s", nfo_path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = etree.parse(str(nfo_path))
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
# Try <year> element first
|
||||||
|
year_elem = root.find(".//year")
|
||||||
|
if year_elem is not None and year_elem.text:
|
||||||
|
try:
|
||||||
|
year = int(year_elem.text)
|
||||||
|
if 1900 <= year <= 2100:
|
||||||
|
logger.debug("Found year in NFO: %d", year)
|
||||||
|
return year
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: try <premiered> element (format: YYYY-MM-DD)
|
||||||
|
premiered_elem = root.find(".//premiered")
|
||||||
|
if premiered_elem is not None and premiered_elem.text:
|
||||||
|
if premiered_elem.text and len(premiered_elem.text) >= 4:
|
||||||
|
try:
|
||||||
|
year = int(premiered_elem.text[:4])
|
||||||
|
if 1900 <= year <= 2100:
|
||||||
|
logger.debug("Found year from premiered in NFO: %d", year)
|
||||||
|
return year
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.debug("No year found in NFO: %s", nfo_path)
|
||||||
|
|
||||||
|
except etree.XMLSyntaxError as e:
|
||||||
|
logger.error("Invalid XML in NFO file %s: %s", nfo_path, e)
|
||||||
|
except Exception as e: # pylint: disable=broad-except
|
||||||
|
logger.error("Error parsing year from NFO file %s: %s", nfo_path, e)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
async def _enrich_details_with_fallback(
|
async def _enrich_details_with_fallback(
|
||||||
self,
|
self,
|
||||||
details: Dict[str, Any],
|
details: Dict[str, Any],
|
||||||
@@ -518,6 +617,161 @@ class NFOService:
|
|||||||
# Return first result (usually best match)
|
# Return first result (usually best match)
|
||||||
return results[0]
|
return results[0]
|
||||||
|
|
||||||
|
async def _search_with_fallback(
|
||||||
|
self,
|
||||||
|
primary_query: str,
|
||||||
|
year: Optional[int],
|
||||||
|
alt_titles: Optional[List[str]] = None
|
||||||
|
) -> Tuple[Dict[str, Any], str]:
|
||||||
|
"""Search TMDB with fallback strategies.
|
||||||
|
|
||||||
|
Tries multiple search strategies in order:
|
||||||
|
1. Primary query with year filter
|
||||||
|
2. Alternative titles (e.g., Japanese name)
|
||||||
|
3. Multi-language search (en-US)
|
||||||
|
4. Search without year constraint
|
||||||
|
5. Punctuation-normalized search
|
||||||
|
|
||||||
|
Args:
|
||||||
|
primary_query: Primary search term
|
||||||
|
year: Release year for filtering
|
||||||
|
alt_titles: Alternative titles to try if primary fails
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (matched TV show dict, source description string)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TMDBAPIError: If all search strategies fail
|
||||||
|
"""
|
||||||
|
search_strategies = [
|
||||||
|
# Strategy 1: Primary query as-is
|
||||||
|
{"query": primary_query, "year": year, "lang": "de-DE", "desc": "primary"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Strategy 2: Try alt titles (typically Japanese)
|
||||||
|
if alt_titles:
|
||||||
|
for alt in alt_titles:
|
||||||
|
if alt != primary_query:
|
||||||
|
search_strategies.append(
|
||||||
|
{"query": alt, "year": year, "lang": "ja-JP", "desc": f"alt_title:{alt}"}
|
||||||
|
)
|
||||||
|
search_strategies.append(
|
||||||
|
{"query": alt, "year": year, "lang": "en-US", "desc": f"alt_title:{alt}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Strategy 3: Try English search
|
||||||
|
search_strategies.append(
|
||||||
|
{"query": primary_query, "year": year, "lang": "en-US", "desc": "english"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Strategy 4: Try without year constraint
|
||||||
|
if year:
|
||||||
|
search_strategies.append(
|
||||||
|
{"query": primary_query, "year": None, "lang": "de-DE", "desc": "no_year"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Strategy 5: Normalize punctuation
|
||||||
|
normalized = self._normalize_query_for_search(primary_query)
|
||||||
|
if normalized != primary_query:
|
||||||
|
search_strategies.append(
|
||||||
|
{"query": normalized, "year": year, "lang": "de-DE", "desc": f"normalized:{normalized}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Strategy 6: Try search/multi for series indexed as movies
|
||||||
|
search_strategies.append(
|
||||||
|
{"query": primary_query, "year": year, "lang": "en-US", "desc": "multi_search", "use_multi": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
for strategy in search_strategies:
|
||||||
|
query = strategy["query"]
|
||||||
|
lang = strategy["lang"]
|
||||||
|
desc = strategy["desc"]
|
||||||
|
use_multi = strategy.get("use_multi", False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.debug(
|
||||||
|
"TMDB search attempt: query='%s', lang=%s, year=%s, strategy=%s",
|
||||||
|
query, lang, strategy["year"], desc
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use search/multi for multi_search strategy
|
||||||
|
if use_multi:
|
||||||
|
search_results = await self.tmdb_client.search_multi(
|
||||||
|
query,
|
||||||
|
language=lang
|
||||||
|
)
|
||||||
|
# Filter for TV shows only
|
||||||
|
if search_results.get("results"):
|
||||||
|
tv_results = [
|
||||||
|
r for r in search_results["results"]
|
||||||
|
if r.get("media_type") == "tv"
|
||||||
|
]
|
||||||
|
if tv_results:
|
||||||
|
search_results["results"] = tv_results
|
||||||
|
else:
|
||||||
|
search_results["results"] = []
|
||||||
|
else:
|
||||||
|
search_results = await self.tmdb_client.search_tv_show(
|
||||||
|
query,
|
||||||
|
language=lang
|
||||||
|
)
|
||||||
|
|
||||||
|
if search_results.get("results"):
|
||||||
|
# Apply year filter if we have one
|
||||||
|
results = search_results["results"]
|
||||||
|
if strategy["year"]:
|
||||||
|
year_filtered = [
|
||||||
|
r for r in results
|
||||||
|
if r.get("first_air_date", "").startswith(str(strategy["year"]))
|
||||||
|
]
|
||||||
|
if year_filtered:
|
||||||
|
match = year_filtered[0]
|
||||||
|
else:
|
||||||
|
# Year didn't match, still use first result but log it
|
||||||
|
match = results[0]
|
||||||
|
logger.debug(
|
||||||
|
"Year %s not found in results for '%s', using: %s",
|
||||||
|
strategy["year"], query, match["name"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
match = results[0]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"TMDB search succeeded: '%s' found via strategy '%s' (ID: %s)",
|
||||||
|
match["name"], desc, match["id"]
|
||||||
|
)
|
||||||
|
return match, desc
|
||||||
|
else:
|
||||||
|
logger.debug("No results for '%s' via %s", query, desc)
|
||||||
|
|
||||||
|
except TMDBAPIError as e:
|
||||||
|
last_error = e
|
||||||
|
logger.debug("Search strategy '%s' failed: %s", desc, e)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# All strategies exhausted
|
||||||
|
raise TMDBAPIError(
|
||||||
|
f"No results found for: {primary_query} (tried {len(search_strategies)} strategies)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _normalize_query_for_search(self, query: str) -> str:
|
||||||
|
"""Normalize query by removing punctuation and special chars.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Original search query
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Query with punctuation removed
|
||||||
|
"""
|
||||||
|
# Remove common punctuation but keep CJK characters
|
||||||
|
normalized = unicodedata.normalize('NFKC', query)
|
||||||
|
# Remove punctuation but not CJK
|
||||||
|
normalized = re.sub(r'[^\w\s\u3000-\u9fff\u4e00-\u9faf]', '', normalized)
|
||||||
|
# Collapse multiple spaces
|
||||||
|
normalized = re.sub(r'\s+', ' ', normalized).strip()
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def _download_media_files(
|
async def _download_media_files(
|
||||||
@@ -585,3 +839,53 @@ class NFOService:
|
|||||||
async def close(self):
|
async def close(self):
|
||||||
"""Clean up resources."""
|
"""Clean up resources."""
|
||||||
await self.tmdb_client.close()
|
await self.tmdb_client.close()
|
||||||
|
await self.image_downloader.close()
|
||||||
|
|
||||||
|
async def create_minimal_nfo(
|
||||||
|
self,
|
||||||
|
serie_name: str,
|
||||||
|
serie_folder: str,
|
||||||
|
year: Optional[int] = None
|
||||||
|
) -> Path:
|
||||||
|
"""Create minimal tvshow.nfo when TMDB lookup fails.
|
||||||
|
|
||||||
|
Creates a basic NFO with just the title (and year if available)
|
||||||
|
so the series is tracked even without TMDB metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
serie_name: Name of the series (may include year in parentheses)
|
||||||
|
serie_folder: Series folder name
|
||||||
|
year: Optional release year
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to created NFO file
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If series folder doesn't exist
|
||||||
|
"""
|
||||||
|
# Extract year from name if not provided
|
||||||
|
clean_name, extracted_year = self._extract_year_from_name(serie_name)
|
||||||
|
if year is None and extracted_year is not None:
|
||||||
|
year = extracted_year
|
||||||
|
|
||||||
|
folder_path = self.anime_directory / serie_folder
|
||||||
|
if not folder_path.exists():
|
||||||
|
logger.info("Creating series folder: %s", folder_path)
|
||||||
|
folder_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create minimal NFO model with just title and year
|
||||||
|
nfo_model = TVShowNFO(
|
||||||
|
title=clean_name,
|
||||||
|
year=year,
|
||||||
|
plot=f"No metadata available for {clean_name}. TMDB lookup failed."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate XML
|
||||||
|
nfo_xml = generate_tvshow_nfo(nfo_model)
|
||||||
|
|
||||||
|
# Save NFO file
|
||||||
|
nfo_path = folder_path / "tvshow.nfo"
|
||||||
|
nfo_path.write_text(nfo_xml, encoding="utf-8")
|
||||||
|
logger.info("Created minimal NFO (no TMDB): %s", nfo_path)
|
||||||
|
|
||||||
|
return nfo_path
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ class SeriesManagerService:
|
|||||||
if not self.nfo_service:
|
if not self.nfo_service:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
nfo_exists = False
|
||||||
|
ids = {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
folder_path = Path(self.anime_directory) / serie_folder
|
folder_path = Path(self.anime_directory) / serie_folder
|
||||||
nfo_path = folder_path / "tvshow.nfo"
|
nfo_path = folder_path / "tvshow.nfo"
|
||||||
@@ -195,6 +198,7 @@ class SeriesManagerService:
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"Creating NFO for '{serie_name}' ({serie_folder})"
|
f"Creating NFO for '{serie_name}' ({serie_folder})"
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
await self.nfo_service.create_tvshow_nfo(
|
await self.nfo_service.create_tvshow_nfo(
|
||||||
serie_name=serie_name,
|
serie_name=serie_name,
|
||||||
serie_folder=serie_folder,
|
serie_folder=serie_folder,
|
||||||
@@ -204,12 +208,38 @@ class SeriesManagerService:
|
|||||||
download_fanart=self.download_fanart
|
download_fanart=self.download_fanart
|
||||||
)
|
)
|
||||||
logger.info("Successfully created NFO for '%s'", serie_name)
|
logger.info("Successfully created NFO for '%s'", serie_name)
|
||||||
|
except TMDBAPIError as create_error:
|
||||||
|
# TMDB lookup failed, create minimal NFO to track the series
|
||||||
|
logger.warning(
|
||||||
|
"TMDB lookup failed for '%s', creating minimal NFO: %s",
|
||||||
|
serie_name, create_error
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self.nfo_service.create_minimal_nfo(
|
||||||
|
serie_name=serie_name,
|
||||||
|
serie_folder=serie_folder,
|
||||||
|
year=year
|
||||||
|
)
|
||||||
|
logger.info("Created minimal NFO for '%s'", serie_name)
|
||||||
|
except Exception as minimal_error:
|
||||||
|
logger.error(
|
||||||
|
"Failed to create minimal NFO for '%s': %s",
|
||||||
|
serie_name, minimal_error
|
||||||
|
)
|
||||||
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:
|
||||||
|
# Only log at ERROR if no NFO exists and we have no IDs
|
||||||
|
# If NFO exists with IDs, this is just a lookup failure, log at DEBUG
|
||||||
|
if nfo_exists and (ids.get("tmdb_id") or ids.get("tvdb_id")):
|
||||||
|
logger.debug(
|
||||||
|
"TMDB API lookup failed for '%s' (has NFO with IDs): %s",
|
||||||
|
serie_name, e
|
||||||
|
)
|
||||||
|
else:
|
||||||
logger.error("TMDB API error processing '%s': %s", 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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ class TMDBClient:
|
|||||||
|
|
||||||
DEFAULT_BASE_URL = "https://api.themoviedb.org/3"
|
DEFAULT_BASE_URL = "https://api.themoviedb.org/3"
|
||||||
DEFAULT_IMAGE_BASE_URL = "https://image.tmdb.org/t/p"
|
DEFAULT_IMAGE_BASE_URL = "https://image.tmdb.org/t/p"
|
||||||
|
NEGATIVE_CACHE_TTL = 86400 # 24 hours
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -63,6 +65,12 @@ 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] = {}
|
||||||
|
self._negative_cache: Dict[str, float] = {} # query -> timestamp when cached
|
||||||
|
# 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 +91,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.
|
||||||
|
|
||||||
@@ -110,9 +118,34 @@ class TMDBClient:
|
|||||||
logger.debug("Cache hit for %s", endpoint)
|
logger.debug("Cache hit for %s", endpoint)
|
||||||
return self._cache[cache_key]
|
return self._cache[cache_key]
|
||||||
|
|
||||||
|
# Check negative cache (cached empty results)
|
||||||
|
negative_cache_key = f"{endpoint}:{str(sorted(params.items()))}"
|
||||||
|
if negative_cache_key in self._negative_cache:
|
||||||
|
if time.monotonic() - self._negative_cache[negative_cache_key] < self.NEGATIVE_CACHE_TTL:
|
||||||
|
logger.debug("Negative cache hit for %s (cached empty result)", endpoint)
|
||||||
|
return {"results": []}
|
||||||
|
else:
|
||||||
|
# Expired negative cache entry
|
||||||
|
del self._negative_cache[negative_cache_key]
|
||||||
|
|
||||||
delay = 1
|
delay = 1
|
||||||
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
|
||||||
@@ -128,8 +161,8 @@ class TMDBClient:
|
|||||||
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, 2)))
|
||||||
logger.warning("Rate limited, waiting %ss", retry_after)
|
logger.warning("Rate limited, waiting %ss", retry_after)
|
||||||
await asyncio.sleep(retry_after)
|
await asyncio.sleep(retry_after)
|
||||||
continue
|
continue
|
||||||
@@ -137,6 +170,10 @@ class TMDBClient:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
self._cache[cache_key] = data
|
self._cache[cache_key] = data
|
||||||
|
# Cache negative result if empty
|
||||||
|
if endpoint.startswith("search/") and not data.get("results"):
|
||||||
|
self._negative_cache[negative_cache_key] = time.monotonic()
|
||||||
|
logger.debug("Cached negative result for %s", endpoint)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
except asyncio.TimeoutError as e:
|
except asyncio.TimeoutError as e:
|
||||||
@@ -152,10 +189,23 @@ class TMDBClient:
|
|||||||
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("Session issue detected, recreating session: %s", e)
|
logger.warning(
|
||||||
|
"Session issue detected, recreating session: %s",
|
||||||
|
e,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
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("Request failed (attempt %s): %s, retrying in %ss", attempt + 1, e, delay)
|
logger.warning("Request failed (attempt %s): %s, retrying in %ss", attempt + 1, e, delay)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
@@ -190,6 +240,34 @@ class TMDBClient:
|
|||||||
{"query": query, "language": language, "page": page}
|
{"query": query, "language": language, "page": page}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def search_multi(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
language: str = "en-US",
|
||||||
|
page: int = 1
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Search for movies and TV shows by name using TMDB multi search.
|
||||||
|
|
||||||
|
Multi search returns both movies and TV shows, useful for anime
|
||||||
|
that might be indexed as movies on TMDB.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query (show name)
|
||||||
|
language: Language for results (default: English)
|
||||||
|
page: Page number for pagination
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Search results with list of movies and TV shows
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> results = await client.search_multi("Suzume no Tojimari")
|
||||||
|
>>> shows = [r for r in results["results"] if r["media_type"] == "tv"]
|
||||||
|
"""
|
||||||
|
return await self._request(
|
||||||
|
"search/multi",
|
||||||
|
{"query": query, "language": language, "page": page}
|
||||||
|
)
|
||||||
|
|
||||||
async def get_tv_show_details(
|
async def get_tv_show_details(
|
||||||
self,
|
self,
|
||||||
tv_id: int,
|
tv_id: int,
|
||||||
@@ -310,7 +388,37 @@ class TMDBClient:
|
|||||||
self.session = None
|
self.session = None
|
||||||
logger.debug("TMDB client session closed")
|
logger.debug("TMDB client session closed")
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
"""Warn if session is unclosed during garbage collection."""
|
||||||
|
if self.session is not None and not self.session.closed:
|
||||||
|
logger.warning(
|
||||||
|
"TMDBClient: unclosed session detected. "
|
||||||
|
"Use 'async with TMDBClient(...)' or call close() explicitly."
|
||||||
|
)
|
||||||
|
|
||||||
def clear_cache(self):
|
def clear_cache(self):
|
||||||
"""Clear the request cache."""
|
"""Clear the request cache."""
|
||||||
self._cache.clear()
|
self._cache.clear()
|
||||||
logger.debug("TMDB client cache cleared")
|
logger.debug("TMDB client cache cleared")
|
||||||
|
|
||||||
|
def clear_negative_cache(self):
|
||||||
|
"""Clear the negative result cache."""
|
||||||
|
self._negative_cache.clear()
|
||||||
|
logger.debug("TMDB negative cache cleared")
|
||||||
|
|
||||||
|
def cleanup_expired_negative_cache(self) -> int:
|
||||||
|
"""Remove expired entries from negative cache.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of entries removed
|
||||||
|
"""
|
||||||
|
now = time.monotonic()
|
||||||
|
expired_keys = [
|
||||||
|
key for key, timestamp in self._negative_cache.items()
|
||||||
|
if now - timestamp >= self.NEGATIVE_CACHE_TTL
|
||||||
|
]
|
||||||
|
for key in expired_keys:
|
||||||
|
del self._negative_cache[key]
|
||||||
|
if expired_keys:
|
||||||
|
logger.debug("Removed %d expired negative cache entries", len(expired_keys))
|
||||||
|
return len(expired_keys)
|
||||||
|
|||||||
248
src/core/utils/key_utils.py
Normal file
248
src/core/utils/key_utils.py
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
"""Utility functions for generating URL-safe keys from folder names.
|
||||||
|
|
||||||
|
This module provides key generation and normalization for anime series,
|
||||||
|
handling edge cases like non-Latin characters and special symbols.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Valid key pattern: alphanumeric, hyphens, underscores
|
||||||
|
# Must be at least 1 char, URL-safe
|
||||||
|
VALID_KEY_PATTERN = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$')
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_key(key: str) -> str:
|
||||||
|
"""Normalize a key to a URL-safe format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The key to normalize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Normalized lowercase key with spaces replaced by hyphens
|
||||||
|
"""
|
||||||
|
if not key:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Convert to lowercase
|
||||||
|
normalized = key.lower()
|
||||||
|
|
||||||
|
# Replace spaces and underscores with hyphens
|
||||||
|
normalized = re.sub(r'[\s_]+', '-', normalized)
|
||||||
|
|
||||||
|
# Remove any characters that aren't alphanumeric or hyphens
|
||||||
|
normalized = re.sub(r'[^a-z0-9-]', '', normalized)
|
||||||
|
|
||||||
|
# Collapse multiple consecutive hyphens
|
||||||
|
normalized = re.sub(r'-+', '-', normalized)
|
||||||
|
|
||||||
|
# Remove leading/trailing hyphens
|
||||||
|
normalized = normalized.strip('-')
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_key(key: str) -> bool:
|
||||||
|
"""Check if a key is valid for URL-safe use.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The key to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if key is valid (non-empty, URL-safe, alphanumeric start/end, min 2 chars)
|
||||||
|
"""
|
||||||
|
if not key or not key.strip():
|
||||||
|
return False
|
||||||
|
|
||||||
|
if len(key) < 2:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return bool(VALID_KEY_PATTERN.match(key))
|
||||||
|
|
||||||
|
|
||||||
|
def generate_key_from_folder(folder_name: str) -> str:
|
||||||
|
"""Generate a URL-safe key from a folder name.
|
||||||
|
|
||||||
|
Handles edge cases:
|
||||||
|
- Non-Latin characters (Japanese, Chinese, etc.)
|
||||||
|
- Special characters
|
||||||
|
- All-invalid names that normalize to empty
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_name: The folder name to convert to a key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A URL-safe key string. Never returns empty string.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> generate_key_from_folder("Attack on Titan (2013)")
|
||||||
|
'attack-on-titan-2013'
|
||||||
|
>>> generate_key_from_folder("A Time Called You (2023)")
|
||||||
|
'a-time-called-you-2023'
|
||||||
|
>>> generate_key_from_folder("25-sai no Joshikousei (2018)")
|
||||||
|
'25-sai-no-joshikousei-2018'
|
||||||
|
"""
|
||||||
|
if not folder_name or not folder_name.strip():
|
||||||
|
raise ValueError("Folder name cannot be empty")
|
||||||
|
|
||||||
|
# Step 1: Unicode NFC normalization (preserves international chars)
|
||||||
|
normalized = unicodedata.normalize('NFC', folder_name.strip())
|
||||||
|
|
||||||
|
# Step 2: Extract alphanumeric parts, preserving international chars
|
||||||
|
# This keeps Japanese/Chinese characters but removes special symbols
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
for char in normalized:
|
||||||
|
# Keep Unicode alphanumeric characters (letters/numbers from any script)
|
||||||
|
if char.isalnum():
|
||||||
|
parts.append(char)
|
||||||
|
elif char.isspace():
|
||||||
|
parts.append(' ')
|
||||||
|
# Handle apostrophes - treat as part of word (remove, don't replace with space)
|
||||||
|
# This normalizes e.g., "Hell's" -> "Hells"
|
||||||
|
# Includes: ' (0x27), ' (0x2018), ' (0x2019), ' (0x02BC), ` (0x0060)
|
||||||
|
elif char in ("'", "'", "'", "'", "`", """, """):
|
||||||
|
pass # Skip - drop the apostrophe
|
||||||
|
else:
|
||||||
|
parts.append(' ')
|
||||||
|
|
||||||
|
working = ''.join(parts)
|
||||||
|
|
||||||
|
# Step 3: Split into words and normalize each
|
||||||
|
words = working.split()
|
||||||
|
|
||||||
|
# Step 4: Convert to lowercase and create hyphenated key
|
||||||
|
key = '-'.join(word.lower() for word in words if word)
|
||||||
|
|
||||||
|
# Step 5: If we got a valid key, return it
|
||||||
|
if key and is_valid_key(key):
|
||||||
|
return key
|
||||||
|
|
||||||
|
# Step 6: Try just alphanumeric characters
|
||||||
|
alphanumeric_only = re.sub(r'[^a-zA-Z0-9\s]', '', working)
|
||||||
|
words = alphanumeric_only.split()
|
||||||
|
key = '-'.join(word.lower() for word in words if word)
|
||||||
|
|
||||||
|
if key and is_valid_key(key):
|
||||||
|
return key
|
||||||
|
|
||||||
|
# Step 7: Last resort - use folder name directly with transliteration
|
||||||
|
# Try to convert non-ASCII to ASCII equivalents
|
||||||
|
try:
|
||||||
|
# Use NFD normalization and strip combining characters
|
||||||
|
# This effectively Latinizes some characters
|
||||||
|
nfd_form = unicodedata.normalize('NFD', folder_name)
|
||||||
|
latinized = ''.join(
|
||||||
|
char for char in nfd_form
|
||||||
|
if unicodedata.category(char) != 'Mn' # Strip combining marks
|
||||||
|
)
|
||||||
|
# Remove non-ASCII letters
|
||||||
|
latinized = re.sub(r'[^a-zA-Z0-9\s]', ' ', latinized)
|
||||||
|
words = latinized.split()
|
||||||
|
key = '-'.join(word.lower() for word in words if word)
|
||||||
|
|
||||||
|
if key and is_valid_key(key):
|
||||||
|
return key
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Step 8: Absolute fallback - generate UUID-based key
|
||||||
|
# Use first 8 chars of UUID for brevity
|
||||||
|
uuid_key = uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
# Try to extract any meaningful words from the original name
|
||||||
|
meaningful_parts = []
|
||||||
|
for char in folder_name:
|
||||||
|
if char.isalnum():
|
||||||
|
meaningful_parts.append(char.lower())
|
||||||
|
elif len(meaningful_parts) > 0:
|
||||||
|
meaningful_parts.append('-')
|
||||||
|
|
||||||
|
fallback_base = ''.join(meaningful_parts).strip('-')
|
||||||
|
if fallback_base and len(fallback_base) >= 2:
|
||||||
|
# Combine meaningful parts with UUID for uniqueness
|
||||||
|
# Truncate meaningful parts if too long
|
||||||
|
if len(fallback_base) > 20:
|
||||||
|
fallback_base = fallback_base[:20]
|
||||||
|
return f"{fallback_base}-{uuid_key}"
|
||||||
|
|
||||||
|
return f"series-{uuid_key}"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_key_uniqueness(
|
||||||
|
key: str,
|
||||||
|
existing_keys: set[str],
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Validate that a key is unique among existing keys.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The key to validate
|
||||||
|
existing_keys: Set of keys that already exist
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if not key or not key.strip():
|
||||||
|
return False, "Key cannot be empty"
|
||||||
|
|
||||||
|
stripped = key.strip()
|
||||||
|
if len(stripped) < 2:
|
||||||
|
return False, "Key must be at least 2 characters"
|
||||||
|
|
||||||
|
if not is_valid_key(stripped):
|
||||||
|
return False, "Key must be URL-safe (alphanumeric, hyphens, underscores only)"
|
||||||
|
|
||||||
|
if stripped in existing_keys:
|
||||||
|
return False, f"Key '{stripped}' is already in use"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_key_for_url(key: str) -> str:
|
||||||
|
"""Sanitize a key for safe URL usage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The key to sanitize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
URL-safe version of the key
|
||||||
|
"""
|
||||||
|
if not key:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Replace spaces with hyphens first
|
||||||
|
sanitized = key.replace(' ', '-')
|
||||||
|
|
||||||
|
# Remove any characters that could cause URL issues (keep alphanumerics, hyphens, underscores)
|
||||||
|
sanitized = re.sub(r'[^\w\-]', '', sanitized)
|
||||||
|
|
||||||
|
# Collapse multiple hyphens
|
||||||
|
sanitized = re.sub(r'-+', '-', sanitized)
|
||||||
|
|
||||||
|
return sanitized.strip('-')
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_url_for_logging(url: str, max_length: int = 100) -> str:
|
||||||
|
"""Sanitize a URL for safe logging by removing sensitive query parameters.
|
||||||
|
|
||||||
|
Removes or truncates query parameters that may contain tokens, keys,
|
||||||
|
or other sensitive data while preserving enough structure for debugging.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The URL to sanitize
|
||||||
|
max_length: Maximum length of the returned URL string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sanitized URL safe for logging
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Truncate if too long
|
||||||
|
if len(url) > max_length:
|
||||||
|
return url[:max_length] + "..."
|
||||||
|
|
||||||
|
return url
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import logging
|
import logging
|
||||||
import warnings
|
import warnings
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.config.settings import settings
|
||||||
from src.core.entities.series import Serie
|
from src.core.entities.series import Serie
|
||||||
|
from src.core.utils.key_utils import generate_key_from_folder, is_valid_key
|
||||||
from src.server.database.service import AnimeSeriesService
|
from src.server.database.service import AnimeSeriesService
|
||||||
from src.server.exceptions import (
|
from src.server.exceptions import (
|
||||||
BadRequestError,
|
BadRequestError,
|
||||||
@@ -14,11 +17,14 @@ from src.server.exceptions import (
|
|||||||
ServerError,
|
ServerError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
)
|
)
|
||||||
|
from src.server.models.anime import AnimeMetadataUpdate
|
||||||
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
||||||
from src.server.services.background_loader_service import BackgroundLoaderService
|
from src.server.services.background_loader_service import BackgroundLoaderService
|
||||||
|
from src.server.services.folder_rename_service import _scan_for_pre_existing_duplicates
|
||||||
from src.server.utils.dependencies import (
|
from src.server.utils.dependencies import (
|
||||||
get_anime_service,
|
get_anime_service,
|
||||||
get_background_loader_service,
|
get_background_loader_service,
|
||||||
|
get_database_session,
|
||||||
get_optional_database_session,
|
get_optional_database_session,
|
||||||
get_series_app,
|
get_series_app,
|
||||||
require_auth,
|
require_auth,
|
||||||
@@ -70,6 +76,100 @@ async def get_anime_status(
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateFolderGroup(BaseModel):
|
||||||
|
"""A group of duplicate folders for the same series.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
key: Series key (provider-assigned unique identifier)
|
||||||
|
folders: List of folder names that are duplicates
|
||||||
|
folder_count: Number of duplicate folders
|
||||||
|
"""
|
||||||
|
key: str = Field(..., description="Series key (unique identifier)")
|
||||||
|
folders: List[str] = Field(..., description="List of duplicate folder names")
|
||||||
|
folder_count: int = Field(..., description="Number of duplicate folders")
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateFoldersResponse(BaseModel):
|
||||||
|
"""Response model for duplicate folders listing.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
total_groups: Total number of duplicate groups found
|
||||||
|
duplicate_groups: List of duplicate folder groups
|
||||||
|
message: Human-readable summary
|
||||||
|
"""
|
||||||
|
total_groups: int = Field(..., description="Total number of duplicate groups")
|
||||||
|
duplicate_groups: List[DuplicateFolderGroup] = Field(
|
||||||
|
..., description="List of duplicate folder groups"
|
||||||
|
)
|
||||||
|
message: str = Field(..., description="Human-readable summary")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/duplicate-folders", response_model=DuplicateFoldersResponse)
|
||||||
|
async def get_duplicate_folders(
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
) -> DuplicateFoldersResponse:
|
||||||
|
"""List all pre-existing duplicate folder groups.
|
||||||
|
|
||||||
|
Scans the anime directory for folders with tvshow.nfo files that
|
||||||
|
map to the same series key. Returns groups of duplicates for
|
||||||
|
manual review and cleanup.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DuplicateFoldersResponse with groups of duplicate folders
|
||||||
|
|
||||||
|
Note:
|
||||||
|
Not all duplicate folders are safe to merge - some may belong
|
||||||
|
to different releases (e.g., dubbed vs. subbed). Review carefully
|
||||||
|
before taking action.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not settings.anime_directory:
|
||||||
|
return DuplicateFoldersResponse(
|
||||||
|
total_groups=0,
|
||||||
|
duplicate_groups=[],
|
||||||
|
message="Anime directory not configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
anime_dir = Path(settings.anime_directory)
|
||||||
|
if not anime_dir.is_dir():
|
||||||
|
return DuplicateFoldersResponse(
|
||||||
|
total_groups=0,
|
||||||
|
duplicate_groups=[],
|
||||||
|
message=f"Anime directory not found: {anime_dir}",
|
||||||
|
)
|
||||||
|
|
||||||
|
duplicates = _scan_for_pre_existing_duplicates(anime_dir)
|
||||||
|
|
||||||
|
groups = [
|
||||||
|
DuplicateFolderGroup(
|
||||||
|
key=dup.key,
|
||||||
|
folders=dup.folders,
|
||||||
|
folder_count=dup.count,
|
||||||
|
)
|
||||||
|
for dup in duplicates
|
||||||
|
]
|
||||||
|
|
||||||
|
if groups:
|
||||||
|
message = (
|
||||||
|
f"Found {len(groups)} duplicate group(s). "
|
||||||
|
"Review carefully - some duplicates may be different releases "
|
||||||
|
"(e.g., dubbed vs. subbed)."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message = "No duplicate folders found."
|
||||||
|
|
||||||
|
return DuplicateFoldersResponse(
|
||||||
|
total_groups=len(groups),
|
||||||
|
duplicate_groups=groups,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to scan for duplicate folders: %s", str(exc))
|
||||||
|
raise ServerError(
|
||||||
|
message=f"Failed to scan for duplicates: {str(exc)}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
class AnimeSummary(BaseModel):
|
class AnimeSummary(BaseModel):
|
||||||
"""Summary of an anime series with missing episodes.
|
"""Summary of an anime series with missing episodes.
|
||||||
|
|
||||||
@@ -730,7 +830,11 @@ async def add_series(
|
|||||||
|
|
||||||
# Create folder name with year if available
|
# Create folder name with year if available
|
||||||
if year:
|
if year:
|
||||||
folder_name_with_year = f"{name} ({year})"
|
year_suffix = f" ({year})"
|
||||||
|
if name.endswith(year_suffix):
|
||||||
|
folder_name_with_year = name
|
||||||
|
else:
|
||||||
|
folder_name_with_year = f"{name}{year_suffix}"
|
||||||
else:
|
else:
|
||||||
folder_name_with_year = name
|
folder_name_with_year = name
|
||||||
|
|
||||||
@@ -1084,3 +1188,75 @@ async def get_anime(
|
|||||||
# Maximum allowed input size for security
|
# Maximum allowed input size for security
|
||||||
MAX_INPUT_LENGTH = 100000 # 100KB
|
MAX_INPUT_LENGTH = 100000 # 100KB
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{anime_key}")
|
||||||
|
async def update_anime_metadata(
|
||||||
|
anime_key: str,
|
||||||
|
body: AnimeMetadataUpdate,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
db: AsyncSession = Depends(get_database_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Update anime metadata (key, tmdb_id, tvdb_id).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_key: Current series key to update
|
||||||
|
body: Fields to update (all optional)
|
||||||
|
_auth: Authentication dependency
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated series metadata
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: Series not found
|
||||||
|
HTTPException 409: Key conflict (new key already exists)
|
||||||
|
HTTPException 422: Validation error
|
||||||
|
"""
|
||||||
|
series = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||||
|
if not series:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series with key '{anime_key}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
updates = {}
|
||||||
|
|
||||||
|
if body.key is not None and body.key != anime_key:
|
||||||
|
existing = await AnimeSeriesService.get_by_key(db, body.key)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"A series with key '{body.key}' already exists",
|
||||||
|
)
|
||||||
|
updates["key"] = body.key
|
||||||
|
|
||||||
|
if body.tmdb_id is not None:
|
||||||
|
updates["tmdb_id"] = body.tmdb_id
|
||||||
|
|
||||||
|
if body.tvdb_id is not None:
|
||||||
|
updates["tvdb_id"] = body.tvdb_id
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return {
|
||||||
|
"key": series.key,
|
||||||
|
"tmdb_id": series.tmdb_id,
|
||||||
|
"tvdb_id": series.tvdb_id,
|
||||||
|
"message": "No changes",
|
||||||
|
}
|
||||||
|
|
||||||
|
updated = await AnimeSeriesService.update(db, series.id, **updates)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Updated metadata for '%s': %s",
|
||||||
|
anime_key,
|
||||||
|
updates,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"key": updated.key,
|
||||||
|
"tmdb_id": updated.tmdb_id,
|
||||||
|
"tvdb_id": updated.tvdb_id,
|
||||||
|
"message": "Metadata updated successfully",
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -161,6 +163,22 @@ async def setup_auth(req: SetupRequest):
|
|||||||
# Perform NFO scan if configured
|
# Perform NFO scan if configured
|
||||||
await perform_nfo_scan_if_needed(progress_service)
|
await perform_nfo_scan_if_needed(progress_service)
|
||||||
|
|
||||||
|
# Start scheduler if anime_directory is now set
|
||||||
|
try:
|
||||||
|
from src.server.services.scheduler_service import (
|
||||||
|
get_scheduler_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler_svc = get_scheduler_service()
|
||||||
|
logger.info("Starting scheduler after initialization")
|
||||||
|
await scheduler_svc.ensure_started()
|
||||||
|
logger.info("Scheduler started successfully during setup")
|
||||||
|
except Exception as sched_exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to start scheduler during setup: %s", sched_exc
|
||||||
|
)
|
||||||
|
# Continue — scheduler failure should not break initialization
|
||||||
|
|
||||||
# Send completion event
|
# Send completion event
|
||||||
from src.server.services.progress_service import ProgressType
|
from src.server.services.progress_service import ProgressType
|
||||||
await progress_service.start_progress(
|
await progress_service.start_progress(
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import logging
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from src.server.models.config import AppConfig, ConfigUpdate, ValidationResult
|
from src.server.models.config import AppConfig, ConfigUpdate, ValidationResult
|
||||||
from src.server.services.config_service import (
|
from src.server.services.config_service import (
|
||||||
ConfigBackupError,
|
ConfigBackupError,
|
||||||
@@ -28,16 +31,53 @@ def get_config(auth: Optional[dict] = Depends(require_auth)) -> AppConfig:
|
|||||||
|
|
||||||
|
|
||||||
@router.put("", response_model=AppConfig)
|
@router.put("", response_model=AppConfig)
|
||||||
def update_config(
|
async def update_config(
|
||||||
update: ConfigUpdate, auth: dict = Depends(require_auth)
|
update: ConfigUpdate, auth: dict = Depends(require_auth)
|
||||||
) -> AppConfig:
|
) -> AppConfig:
|
||||||
"""Apply an update to the configuration and persist it.
|
"""Apply an update to the configuration and persist it.
|
||||||
|
|
||||||
Creates automatic backup before applying changes.
|
Creates automatic backup before applying changes. If anime_directory
|
||||||
|
is configured, starts the scheduler service.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
config_service = get_config_service()
|
config_service = get_config_service()
|
||||||
return config_service.update_config(update)
|
updated_config = config_service.update_config(update)
|
||||||
|
|
||||||
|
# Sync anime_directory to settings if it was updated
|
||||||
|
from src.config.settings import settings as app_settings
|
||||||
|
|
||||||
|
anime_dir_changed = False
|
||||||
|
if update.other and update.other.get("anime_directory"):
|
||||||
|
anime_dir = update.other.get("anime_directory")
|
||||||
|
if anime_dir and not app_settings.anime_directory:
|
||||||
|
app_settings.anime_directory = str(anime_dir)
|
||||||
|
anime_dir_changed = True
|
||||||
|
logger.info("Synced anime_directory from config: %s", anime_dir)
|
||||||
|
|
||||||
|
# Start scheduler if anime_directory was just configured
|
||||||
|
if anime_dir_changed:
|
||||||
|
try:
|
||||||
|
from src.server.services.scheduler_service import (
|
||||||
|
get_scheduler_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler_svc = get_scheduler_service()
|
||||||
|
logger.info(
|
||||||
|
"Starting scheduler after anime_directory configuration"
|
||||||
|
)
|
||||||
|
await scheduler_svc.ensure_started()
|
||||||
|
logger.info(
|
||||||
|
"Scheduler started successfully after config update"
|
||||||
|
)
|
||||||
|
except Exception as sched_exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to start scheduler after config update: %s",
|
||||||
|
sched_exc,
|
||||||
|
)
|
||||||
|
# Config was already saved, don't fail the request
|
||||||
|
|
||||||
|
return updated_config
|
||||||
|
|
||||||
except ConfigValidationError as e:
|
except ConfigValidationError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
@@ -244,9 +284,9 @@ async def update_directory(
|
|||||||
try:
|
try:
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from src.server.services.anime_service import sync_series_from_data_files
|
from src.server.services.anime_service import sync_legacy_series_to_db
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
sync_count = await sync_series_from_data_files(directory, logger)
|
sync_count = await sync_legacy_series_to_db(directory, logger)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Directory updated: synced series from data files",
|
"Directory updated: synced series from data files",
|
||||||
directory=directory,
|
directory=directory,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from datetime import datetime
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -22,10 +22,13 @@ 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
|
||||||
|
scheduler_next_run: Optional[str] = None
|
||||||
|
scheduler_last_run: Optional[str] = None
|
||||||
|
checks: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
class DatabaseHealth(BaseModel):
|
class DatabaseHealth(BaseModel):
|
||||||
@@ -60,7 +63,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
|
||||||
|
|
||||||
@@ -171,12 +174,14 @@ def get_system_metrics() -> SystemMetrics:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=HealthStatus)
|
@router.get("", response_model=HealthStatus)
|
||||||
async def basic_health_check() -> HealthStatus:
|
async def basic_health_check(request: Request) -> HealthStatus:
|
||||||
"""Basic health check endpoint.
|
"""Basic health check endpoint.
|
||||||
|
|
||||||
This endpoint does not depend on anime_directory configuration
|
This endpoint does not depend on anime_directory configuration
|
||||||
and should always return 200 OK for basic health monitoring.
|
and should always return 200 OK for basic health monitoring.
|
||||||
Includes service information for identification.
|
Includes service information for identification.
|
||||||
|
Includes scheduler next/last run times for monitoring tools.
|
||||||
|
Includes startup health check results.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
HealthStatus: Simple health status with timestamp and service info.
|
HealthStatus: Simple health status with timestamp and service info.
|
||||||
@@ -184,16 +189,75 @@ async def basic_health_check() -> HealthStatus:
|
|||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
from src.server.utils.dependencies import _series_app
|
from src.server.utils.dependencies import _series_app
|
||||||
|
|
||||||
|
# Get scheduler status for health monitoring
|
||||||
|
scheduler_status: dict = {}
|
||||||
|
try:
|
||||||
|
from src.server.services.scheduler_service import get_scheduler_service
|
||||||
|
scheduler_status = get_scheduler_service().get_status()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Get startup checks from app state
|
||||||
|
checks = getattr(request.app.state, "startup_checks", None)
|
||||||
|
|
||||||
|
# Determine overall status based on checks
|
||||||
|
overall_status = "healthy"
|
||||||
|
if checks:
|
||||||
|
for check_name, check_data in checks.items():
|
||||||
|
if check_data.get("status") == "error":
|
||||||
|
overall_status = "unhealthy"
|
||||||
|
break
|
||||||
|
elif check_data.get("status") == "warning":
|
||||||
|
overall_status = "degraded"
|
||||||
|
|
||||||
logger.debug("Basic health check requested")
|
logger.debug("Basic health check requested")
|
||||||
return HealthStatus(
|
return HealthStatus(
|
||||||
status="healthy",
|
status=overall_status,
|
||||||
timestamp=datetime.now().isoformat(),
|
timestamp=datetime.now().isoformat(),
|
||||||
service="aniworld-api",
|
service="aniworld-api",
|
||||||
series_app_initialized=_series_app is not None,
|
series_app_initialized=_series_app is not None,
|
||||||
anime_directory_configured=bool(settings.anime_directory),
|
anime_directory_configured=bool(settings.anime_directory),
|
||||||
|
scheduler_next_run=scheduler_status.get("next_run"),
|
||||||
|
scheduler_last_run=scheduler_status.get("last_run"),
|
||||||
|
checks=checks,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ready")
|
||||||
|
async def ready_check(request: Request) -> Dict[str, Any]:
|
||||||
|
"""Readiness check endpoint for container orchestrators.
|
||||||
|
|
||||||
|
Returns 503 if critical dependencies are not available.
|
||||||
|
This endpoint is used by Kubernetes, Docker Swarm, etc. to determine
|
||||||
|
if the container should receive traffic.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Readiness status with checks details.
|
||||||
|
"""
|
||||||
|
checks = getattr(request.app.state, "startup_checks", {})
|
||||||
|
|
||||||
|
critical_failures = []
|
||||||
|
for check_name, check_data in checks.items():
|
||||||
|
if check_data.get("status") == "error":
|
||||||
|
critical_failures.append(f"{check_name}: {check_data.get('message')}")
|
||||||
|
|
||||||
|
if critical_failures:
|
||||||
|
return {
|
||||||
|
"status": "not_ready",
|
||||||
|
"ready": False,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"critical_failures": critical_failures,
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ready",
|
||||||
|
"ready": True,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/detailed", response_model=DetailedHealthStatus)
|
@router.get("/detailed", response_model=DetailedHealthStatus)
|
||||||
async def detailed_health_check(
|
async def detailed_health_check(
|
||||||
db: AsyncSession = Depends(get_database_session),
|
db: AsyncSession = Depends(get_database_session),
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ from src.core.entities.series import Serie
|
|||||||
from src.core.SeriesApp import SeriesApp
|
from src.core.SeriesApp import SeriesApp
|
||||||
from src.core.services.nfo_factory import get_nfo_factory
|
from src.core.services.nfo_factory import get_nfo_factory
|
||||||
from src.core.services.nfo_service import NFOService
|
from src.core.services.nfo_service import NFOService
|
||||||
|
from src.core.services.nfo_repair_service import (
|
||||||
|
REQUIRED_TAGS,
|
||||||
|
NfoRepairService,
|
||||||
|
find_missing_tags,
|
||||||
|
)
|
||||||
from src.core.services.tmdb_client import TMDBAPIError
|
from src.core.services.tmdb_client import TMDBAPIError
|
||||||
from src.server.models.nfo import (
|
from src.server.models.nfo import (
|
||||||
MediaDownloadRequest,
|
MediaDownloadRequest,
|
||||||
@@ -27,8 +32,10 @@ from src.server.models.nfo import (
|
|||||||
NFOContentResponse,
|
NFOContentResponse,
|
||||||
NFOCreateRequest,
|
NFOCreateRequest,
|
||||||
NFOCreateResponse,
|
NFOCreateResponse,
|
||||||
|
NfoDiagnosticsResponse,
|
||||||
NFOMissingResponse,
|
NFOMissingResponse,
|
||||||
NFOMissingSeries,
|
NFOMissingSeries,
|
||||||
|
NfoRepairResponse,
|
||||||
)
|
)
|
||||||
from src.server.utils.dependencies import get_series_app, require_auth
|
from src.server.utils.dependencies import get_series_app, require_auth
|
||||||
from src.server.utils.media import check_media_files, get_media_file_paths
|
from src.server.utils.media import check_media_files, get_media_file_paths
|
||||||
@@ -144,6 +151,27 @@ async def batch_create_nfo(
|
|||||||
nfo_path=str(nfo_path)
|
nfo_path=str(nfo_path)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except TMDBAPIError as e:
|
||||||
|
logger.warning("TMDB API error for %s, creating minimal fallback: %s", serie_id, e)
|
||||||
|
# TMDB failed, create minimal NFO
|
||||||
|
try:
|
||||||
|
serie_folder = serie.ensure_folder_with_year()
|
||||||
|
except Exception:
|
||||||
|
serie_folder = serie_folder
|
||||||
|
|
||||||
|
serie_name = serie.name or serie_folder
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name=serie_name,
|
||||||
|
serie_folder=serie_folder
|
||||||
|
)
|
||||||
|
|
||||||
|
return NFOBatchResult(
|
||||||
|
serie_id=serie_id,
|
||||||
|
serie_folder=serie_folder,
|
||||||
|
success=True,
|
||||||
|
message="Created minimal NFO (TMDB lookup failed)",
|
||||||
|
nfo_path=str(nfo_path)
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Error creating NFO for {serie_id}: {e}",
|
f"Error creating NFO for {serie_id}: {e}",
|
||||||
@@ -429,11 +457,42 @@ async def create_nfo(
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except TMDBAPIError as e:
|
except TMDBAPIError as e:
|
||||||
logger.warning("TMDB API error creating NFO for %s: %s", serie_id, e)
|
logger.warning("TMDB API error for %s, creating minimal fallback: %s", serie_id, e)
|
||||||
raise HTTPException(
|
# TMDB failed, create minimal NFO with just folder name
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
try:
|
||||||
detail=f"TMDB API error: {str(e)}"
|
serie_folder = serie.ensure_folder_with_year()
|
||||||
) from e
|
except Exception:
|
||||||
|
serie_folder = serie_folder
|
||||||
|
|
||||||
|
folder_path = Path(settings.anime_directory) / serie_folder
|
||||||
|
serie_name_fallback = request.serie_name or serie.name or serie_folder
|
||||||
|
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name=serie_name_fallback,
|
||||||
|
serie_folder=serie_folder,
|
||||||
|
year=year
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check media files (will likely be empty)
|
||||||
|
media_status = check_media_files(folder_path)
|
||||||
|
file_paths = get_media_file_paths(folder_path)
|
||||||
|
|
||||||
|
media_files = MediaFilesStatus(
|
||||||
|
has_poster=media_status.get("poster", False),
|
||||||
|
has_logo=media_status.get("logo", False),
|
||||||
|
has_fanart=media_status.get("fanart", False),
|
||||||
|
poster_path=str(file_paths["poster"]) if file_paths.get("poster") else None,
|
||||||
|
logo_path=str(file_paths["logo"]) if file_paths.get("logo") else None,
|
||||||
|
fanart_path=str(file_paths["fanart"]) if file_paths.get("fanart") else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return NFOCreateResponse(
|
||||||
|
serie_id=serie_id,
|
||||||
|
serie_folder=serie_folder,
|
||||||
|
nfo_path=str(nfo_path),
|
||||||
|
media_files=media_files,
|
||||||
|
message="Created minimal NFO (TMDB lookup failed)"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Error creating NFO for {serie_id}: {e}",
|
f"Error creating NFO for {serie_id}: {e}",
|
||||||
@@ -756,3 +815,142 @@ async def download_media(
|
|||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail=f"Failed to download media: {str(e)}"
|
detail=f"Failed to download media: {str(e)}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{serie_key}/diagnostics", response_model=NfoDiagnosticsResponse)
|
||||||
|
async def get_nfo_diagnostics(
|
||||||
|
serie_key: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
series_app: SeriesApp = Depends(get_series_app),
|
||||||
|
) -> NfoDiagnosticsResponse:
|
||||||
|
"""Get NFO diagnostics showing missing required tags.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
serie_key: Series key identifier
|
||||||
|
_auth: Authentication dependency
|
||||||
|
series_app: SeriesApp instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NfoDiagnosticsResponse with has_nfo, missing_tags, required_tags
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: Series not found
|
||||||
|
"""
|
||||||
|
serie = None
|
||||||
|
for s in series_app.list.GetList():
|
||||||
|
if getattr(s, "key", None) == serie_key:
|
||||||
|
serie = s
|
||||||
|
break
|
||||||
|
|
||||||
|
if not serie:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series with key '{serie_key}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
serie_folder = serie.ensure_folder_with_year()
|
||||||
|
folder_path = Path(settings.anime_directory) / serie_folder
|
||||||
|
nfo_path = folder_path / "tvshow.nfo"
|
||||||
|
|
||||||
|
required_tag_names = list(REQUIRED_TAGS.values())
|
||||||
|
|
||||||
|
if not nfo_path.exists():
|
||||||
|
return NfoDiagnosticsResponse(
|
||||||
|
has_nfo=False,
|
||||||
|
nfo_path=None,
|
||||||
|
missing_tags=required_tag_names,
|
||||||
|
required_tags=required_tag_names,
|
||||||
|
)
|
||||||
|
|
||||||
|
missing = find_missing_tags(nfo_path)
|
||||||
|
|
||||||
|
return NfoDiagnosticsResponse(
|
||||||
|
has_nfo=True,
|
||||||
|
nfo_path=str(nfo_path),
|
||||||
|
missing_tags=missing,
|
||||||
|
required_tags=required_tag_names,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{serie_key}/repair", response_model=NfoRepairResponse)
|
||||||
|
async def repair_nfo(
|
||||||
|
serie_key: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
series_app: SeriesApp = Depends(get_series_app),
|
||||||
|
nfo_service: NFOService = Depends(get_nfo_service),
|
||||||
|
) -> NfoRepairResponse:
|
||||||
|
"""Repair or recreate NFO file for a series.
|
||||||
|
|
||||||
|
Detects missing required tags and re-fetches metadata from TMDB.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
serie_key: Series key identifier
|
||||||
|
_auth: Authentication dependency
|
||||||
|
series_app: SeriesApp instance
|
||||||
|
nfo_service: NFO service for TMDB operations
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NfoRepairResponse with success status and details
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: Series not found
|
||||||
|
HTTPException 400: Cannot repair (e.g., no TMDB data available)
|
||||||
|
"""
|
||||||
|
serie = None
|
||||||
|
for s in series_app.list.GetList():
|
||||||
|
if getattr(s, "key", None) == serie_key:
|
||||||
|
serie = s
|
||||||
|
break
|
||||||
|
|
||||||
|
if not serie:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series with key '{serie_key}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
serie_folder = serie.ensure_folder_with_year()
|
||||||
|
folder_path = Path(settings.anime_directory) / serie_folder
|
||||||
|
nfo_path = folder_path / "tvshow.nfo"
|
||||||
|
|
||||||
|
# Get missing tags before repair for reporting
|
||||||
|
missing_before = find_missing_tags(nfo_path) if nfo_path.exists() else list(REQUIRED_TAGS.values())
|
||||||
|
|
||||||
|
try:
|
||||||
|
repair_service = NfoRepairService(nfo_service)
|
||||||
|
|
||||||
|
if nfo_path.exists():
|
||||||
|
repaired = await repair_service.repair_series(folder_path, serie_folder)
|
||||||
|
if not repaired:
|
||||||
|
return NfoRepairResponse(
|
||||||
|
success=True,
|
||||||
|
message="NFO is already complete, no repair needed",
|
||||||
|
repaired_tags=[],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No NFO exists — create new one
|
||||||
|
await nfo_service.create_tvshow_nfo(
|
||||||
|
serie_name=serie.name,
|
||||||
|
serie_folder=serie_folder,
|
||||||
|
download_poster=True,
|
||||||
|
download_logo=True,
|
||||||
|
download_fanart=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return NfoRepairResponse(
|
||||||
|
success=True,
|
||||||
|
message=f"NFO repaired successfully. Fixed {len(missing_before)} missing tags.",
|
||||||
|
repaired_tags=missing_before,
|
||||||
|
)
|
||||||
|
|
||||||
|
except TMDBAPIError as e:
|
||||||
|
logger.warning("NFO repair failed for '%s': %s", serie_key, e)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Cannot repair NFO: {str(e)}. Ensure TMDB ID is set.",
|
||||||
|
) from e
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("NFO repair error for '%s': %s", serie_key, e, exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to repair NFO: {str(e)}",
|
||||||
|
) from e
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def get_settings() -> Union[DevelopmentSettings, ProductionSettings]:
|
|||||||
Example:
|
Example:
|
||||||
>>> settings = get_settings()
|
>>> settings = get_settings()
|
||||||
>>> print(settings.log_level)
|
>>> print(settings.log_level)
|
||||||
DEBUG
|
INFO
|
||||||
"""
|
"""
|
||||||
if ENVIRONMENT in {"development", "testing"}:
|
if ENVIRONMENT in {"development", "testing"}:
|
||||||
return get_development_settings()
|
return get_development_settings()
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ class DevelopmentSettings(BaseSettings):
|
|||||||
@property
|
@property
|
||||||
def debug_enabled(self) -> bool:
|
def debug_enabled(self) -> bool:
|
||||||
"""Check if debug mode is enabled."""
|
"""Check if debug mode is enabled."""
|
||||||
return True
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def reload_enabled(self) -> bool:
|
def reload_enabled(self) -> bool:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from datetime import datetime, timezone
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||||
|
|
||||||
from src.server.database.base import Base, TimestampMixin
|
from src.server.database.base import Base, TimestampMixin
|
||||||
@@ -83,6 +83,10 @@ class AnimeSeries(Base, TimestampMixin):
|
|||||||
Boolean, nullable=False, default=False, server_default="0",
|
Boolean, nullable=False, default=False, server_default="0",
|
||||||
doc="Whether tvshow.nfo file exists for this series"
|
doc="Whether tvshow.nfo file exists for this series"
|
||||||
)
|
)
|
||||||
|
nfo_path: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(1000), nullable=True,
|
||||||
|
doc="Path to the tvshow.nfo metadata file"
|
||||||
|
)
|
||||||
nfo_created_at: Mapped[Optional[datetime]] = mapped_column(
|
nfo_created_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True,
|
DateTime(timezone=True), nullable=True,
|
||||||
doc="Timestamp when NFO was first created"
|
doc="Timestamp when NFO was first created"
|
||||||
@@ -91,6 +95,7 @@ class AnimeSeries(Base, TimestampMixin):
|
|||||||
DateTime(timezone=True), nullable=True,
|
DateTime(timezone=True), nullable=True,
|
||||||
doc="Timestamp when NFO was last updated"
|
doc="Timestamp when NFO was last updated"
|
||||||
)
|
)
|
||||||
|
# TMDB (The Movie Database) ID for series metadata
|
||||||
tmdb_id: Mapped[Optional[int]] = mapped_column(
|
tmdb_id: Mapped[Optional[int]] = mapped_column(
|
||||||
Integer, nullable=True, index=True,
|
Integer, nullable=True, index=True,
|
||||||
doc="TMDB (The Movie Database) ID for series metadata"
|
doc="TMDB (The Movie Database) ID for series metadata"
|
||||||
@@ -316,6 +321,7 @@ class DownloadQueueItem(Base, TimestampMixin):
|
|||||||
id: Primary key
|
id: Primary key
|
||||||
series_id: Foreign key to AnimeSeries
|
series_id: Foreign key to AnimeSeries
|
||||||
episode_id: Foreign key to Episode
|
episode_id: Foreign key to Episode
|
||||||
|
status: Queue status (pending/downloading/completed/failed/permanently_failed)
|
||||||
error_message: Error description if failed
|
error_message: Error description if failed
|
||||||
download_url: Provider download URL
|
download_url: Provider download URL
|
||||||
file_destination: Target file path
|
file_destination: Target file path
|
||||||
@@ -347,6 +353,33 @@ class DownloadQueueItem(Base, TimestampMixin):
|
|||||||
index=True
|
index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Status column to track queue item state
|
||||||
|
# Allows distinguishing pending items from permanently failed ones
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(50), nullable=False, default="pending",
|
||||||
|
doc="Queue item status: pending, downloading, completed, failed, permanently_failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retry count to track failed download attempts
|
||||||
|
# Used to determine when to move item to permanently_failed
|
||||||
|
retry_count: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0,
|
||||||
|
doc="Number of retry attempts for this download"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unique constraint to prevent duplicate pending queue items per episode
|
||||||
|
# An episode can only have one PENDING entry at a time
|
||||||
|
# The status column allows failed items to remain in DB while new
|
||||||
|
# pending items can be added (application-level dedup still required)
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"ix_download_queue_episode_status",
|
||||||
|
"episode_id",
|
||||||
|
"status",
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Error handling
|
# Error handling
|
||||||
error_message: Mapped[Optional[str]] = mapped_column(
|
error_message: Mapped[Optional[str]] = mapped_column(
|
||||||
Text, nullable=True,
|
Text, nullable=True,
|
||||||
@@ -580,6 +613,14 @@ class SystemSettings(Base, TimestampMixin):
|
|||||||
Boolean, nullable=False, default=False, server_default="0",
|
Boolean, nullable=False, default=False, server_default="0",
|
||||||
doc="Whether the initial media scan has been completed"
|
doc="Whether the initial media scan has been completed"
|
||||||
)
|
)
|
||||||
|
migration_legacy_files_completed: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False, server_default="0",
|
||||||
|
doc="Whether legacy key/data file migration has been completed"
|
||||||
|
)
|
||||||
|
legacy_key_cleanup_completed: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False, server_default="0",
|
||||||
|
doc="Whether legacy key file cleanup has been completed"
|
||||||
|
)
|
||||||
last_scan_timestamp: Mapped[Optional[datetime]] = mapped_column(
|
last_scan_timestamp: Mapped[Optional[datetime]] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True,
|
DateTime(timezone=True), nullable=True,
|
||||||
doc="Timestamp of the last completed scan"
|
doc="Timestamp of the last completed scan"
|
||||||
|
|||||||
@@ -149,6 +149,46 @@ 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
|
||||||
|
async def get_by_folder(db: AsyncSession, folder: str) -> Optional[AnimeSeries]:
|
||||||
|
"""Look up an anime series by its filesystem folder name (async).
|
||||||
|
|
||||||
|
Intended as primary lookup for ``SerieScanner`` when scanning
|
||||||
|
directories, replacing the legacy file-based lookups (key/data files).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session.
|
||||||
|
folder: Filesystem folder name to match (e.g.
|
||||||
|
``"Rooster Fighter (2026)"``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``AnimeSeries`` instance or ``None`` if not found.
|
||||||
|
"""
|
||||||
|
result = await 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,
|
||||||
@@ -521,6 +561,7 @@ class EpisodeService:
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
series_id: int,
|
series_id: int,
|
||||||
season: Optional[int] = None,
|
season: Optional[int] = None,
|
||||||
|
only_missing: bool = False,
|
||||||
) -> List[Episode]:
|
) -> List[Episode]:
|
||||||
"""Get episodes for a series.
|
"""Get episodes for a series.
|
||||||
|
|
||||||
@@ -528,6 +569,9 @@ class EpisodeService:
|
|||||||
db: Database session
|
db: Database session
|
||||||
series_id: Foreign key to AnimeSeries
|
series_id: Foreign key to AnimeSeries
|
||||||
season: Optional season filter
|
season: Optional season filter
|
||||||
|
only_missing: If True, only return episodes where
|
||||||
|
is_downloaded is False (i.e., missing episodes).
|
||||||
|
Default False returns all episodes.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of Episode instances
|
List of Episode instances
|
||||||
@@ -537,6 +581,9 @@ class EpisodeService:
|
|||||||
if season is not None:
|
if season is not None:
|
||||||
query = query.where(Episode.season == season)
|
query = query.where(Episode.season == season)
|
||||||
|
|
||||||
|
if only_missing:
|
||||||
|
query = query.where(Episode.is_downloaded == False)
|
||||||
|
|
||||||
query = query.order_by(Episode.season, Episode.episode_number)
|
query = query.order_by(Episode.season, Episode.episode_number)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
@@ -615,6 +662,33 @@ class EpisodeService:
|
|||||||
)
|
)
|
||||||
return result.rowcount > 0
|
return result.rowcount > 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def delete_by_series(
|
||||||
|
db: AsyncSession,
|
||||||
|
series_id: int,
|
||||||
|
season: int,
|
||||||
|
episode_number: int,
|
||||||
|
) -> bool:
|
||||||
|
"""Delete episode by series ID, season, and episode number.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
series_id: Foreign key to AnimeSeries
|
||||||
|
season: Season number
|
||||||
|
episode_number: Episode number within season
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
delete(Episode).where(
|
||||||
|
Episode.series_id == series_id,
|
||||||
|
Episode.season == season,
|
||||||
|
Episode.episode_number == episode_number,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.rowcount > 0
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def delete_by_series_and_episode(
|
async def delete_by_series_and_episode(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
@@ -728,6 +802,8 @@ class DownloadQueueService:
|
|||||||
episode_id: int,
|
episode_id: int,
|
||||||
download_url: Optional[str] = None,
|
download_url: Optional[str] = None,
|
||||||
file_destination: Optional[str] = None,
|
file_destination: Optional[str] = None,
|
||||||
|
status: str = "pending",
|
||||||
|
retry_count: int = 0,
|
||||||
) -> DownloadQueueItem:
|
) -> DownloadQueueItem:
|
||||||
"""Add item to download queue.
|
"""Add item to download queue.
|
||||||
|
|
||||||
@@ -737,6 +813,8 @@ class DownloadQueueService:
|
|||||||
episode_id: Foreign key to Episode
|
episode_id: Foreign key to Episode
|
||||||
download_url: Optional provider download URL
|
download_url: Optional provider download URL
|
||||||
file_destination: Optional target file path
|
file_destination: Optional target file path
|
||||||
|
status: Queue item status (default: "pending")
|
||||||
|
retry_count: Number of retry attempts (default: 0)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Created DownloadQueueItem instance
|
Created DownloadQueueItem instance
|
||||||
@@ -746,13 +824,15 @@ class DownloadQueueService:
|
|||||||
episode_id=episode_id,
|
episode_id=episode_id,
|
||||||
download_url=download_url,
|
download_url=download_url,
|
||||||
file_destination=file_destination,
|
file_destination=file_destination,
|
||||||
|
status=status,
|
||||||
|
retry_count=retry_count,
|
||||||
)
|
)
|
||||||
db.add(item)
|
db.add(item)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(item)
|
await db.refresh(item)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Added to download queue: episode_id={episode_id} "
|
f"Added to download queue: episode_id={episode_id} "
|
||||||
f"for series_id={series_id}"
|
f"for series_id={series_id}, status={status}"
|
||||||
)
|
)
|
||||||
return item
|
return item
|
||||||
|
|
||||||
@@ -779,21 +859,24 @@ class DownloadQueueService:
|
|||||||
async def get_by_episode(
|
async def get_by_episode(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
episode_id: int,
|
episode_id: int,
|
||||||
|
status_filter: Optional[str] = None,
|
||||||
) -> Optional[DownloadQueueItem]:
|
) -> Optional[DownloadQueueItem]:
|
||||||
"""Get download queue item by episode ID.
|
"""Get download queue item by episode ID.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
episode_id: Foreign key to Episode
|
episode_id: Foreign key to Episode
|
||||||
|
status_filter: Optional status to filter by (e.g., "pending")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
DownloadQueueItem instance or None if not found
|
DownloadQueueItem instance or None if not found
|
||||||
"""
|
"""
|
||||||
result = await db.execute(
|
query = select(DownloadQueueItem).where(
|
||||||
select(DownloadQueueItem).where(
|
|
||||||
DownloadQueueItem.episode_id == episode_id
|
DownloadQueueItem.episode_id == episode_id
|
||||||
)
|
)
|
||||||
)
|
if status_filter:
|
||||||
|
query = query.where(DownloadQueueItem.status == status_filter)
|
||||||
|
result = await db.execute(query)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -853,6 +936,95 @@ class DownloadQueueService:
|
|||||||
logger.debug("Set error on download queue item %s", item_id)
|
logger.debug("Set error on download queue item %s", item_id)
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def set_status(
|
||||||
|
db: AsyncSession,
|
||||||
|
item_id: int,
|
||||||
|
status: str,
|
||||||
|
) -> Optional[DownloadQueueItem]:
|
||||||
|
"""Set status on download queue item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
item_id: Item primary key
|
||||||
|
status: New status value
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated DownloadQueueItem instance or None if not found
|
||||||
|
"""
|
||||||
|
item = await DownloadQueueService.get_by_id(db, item_id)
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
|
||||||
|
item.status = status
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(item)
|
||||||
|
logger.debug("Set status on download queue item %s to %s", item_id, status)
|
||||||
|
return item
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def increment_retry_count(
|
||||||
|
db: AsyncSession,
|
||||||
|
item_id: int,
|
||||||
|
) -> Optional[DownloadQueueItem]:
|
||||||
|
"""Increment retry count on download queue item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
item_id: Item primary key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated DownloadQueueItem instance or None if not found
|
||||||
|
"""
|
||||||
|
item = await DownloadQueueService.get_by_id(db, item_id)
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
|
||||||
|
item.retry_count += 1
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(item)
|
||||||
|
logger.debug(
|
||||||
|
"Incremented retry count on download queue item %s to %s",
|
||||||
|
item_id, item.retry_count
|
||||||
|
)
|
||||||
|
return item
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def set_status_and_error(
|
||||||
|
db: AsyncSession,
|
||||||
|
item_id: int,
|
||||||
|
status: str,
|
||||||
|
error_message: Optional[str] = None,
|
||||||
|
) -> Optional[DownloadQueueItem]:
|
||||||
|
"""Set status and error message on download queue item atomically.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
item_id: Item primary key
|
||||||
|
status: New status value
|
||||||
|
error_message: Optional error description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated DownloadQueueItem instance or None if not found
|
||||||
|
"""
|
||||||
|
item = await DownloadQueueService.get_by_id(db, item_id)
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
|
||||||
|
item.status = status
|
||||||
|
if error_message is not None:
|
||||||
|
item.error_message = error_message
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(item)
|
||||||
|
logger.debug(
|
||||||
|
"Set status=%s on download queue item %s, error=%s",
|
||||||
|
status, item_id, error_message
|
||||||
|
)
|
||||||
|
return item
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def delete(db: AsyncSession, item_id: int) -> bool:
|
async def delete(db: AsyncSession, item_id: int) -> bool:
|
||||||
"""Delete download queue item.
|
"""Delete download queue item.
|
||||||
|
|||||||
@@ -125,6 +125,66 @@ class SystemSettingsService:
|
|||||||
settings = await SystemSettingsService.get_or_create(db)
|
settings = await SystemSettingsService.get_or_create(db)
|
||||||
return settings.initial_media_scan_completed
|
return settings.initial_media_scan_completed
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def is_migration_legacy_files_completed(db: AsyncSession) -> bool:
|
||||||
|
"""Check if legacy key/data file migration has been completed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if legacy migration is completed, False otherwise
|
||||||
|
"""
|
||||||
|
settings = await SystemSettingsService.get_or_create(db)
|
||||||
|
return settings.migration_legacy_files_completed
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def mark_migration_legacy_files_completed(
|
||||||
|
db: AsyncSession,
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
) -> None:
|
||||||
|
"""Mark the legacy key/data file migration as completed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
timestamp: Optional timestamp to set, defaults to current time
|
||||||
|
"""
|
||||||
|
settings = await SystemSettingsService.get_or_create(db)
|
||||||
|
settings.migration_legacy_files_completed = True
|
||||||
|
settings.last_scan_timestamp = timestamp or datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Marked legacy files migration as completed")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def is_legacy_key_cleanup_completed(db: AsyncSession) -> bool:
|
||||||
|
"""Check if legacy key file cleanup has been completed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if cleanup is completed, False otherwise
|
||||||
|
"""
|
||||||
|
settings = await SystemSettingsService.get_or_create(db)
|
||||||
|
return settings.legacy_key_cleanup_completed
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def mark_legacy_key_cleanup_completed(
|
||||||
|
db: AsyncSession,
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
) -> None:
|
||||||
|
"""Mark the legacy key file cleanup as completed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
timestamp: Optional timestamp to set, defaults to current time
|
||||||
|
"""
|
||||||
|
settings = await SystemSettingsService.get_or_create(db)
|
||||||
|
settings.legacy_key_cleanup_completed = True
|
||||||
|
settings.last_scan_timestamp = timestamp or datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Marked legacy key file cleanup as completed")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def mark_initial_media_scan_completed(
|
async def mark_initial_media_scan_completed(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
@@ -154,6 +214,8 @@ class SystemSettingsService:
|
|||||||
settings.initial_scan_completed = False
|
settings.initial_scan_completed = False
|
||||||
settings.initial_nfo_scan_completed = False
|
settings.initial_nfo_scan_completed = False
|
||||||
settings.initial_media_scan_completed = False
|
settings.initial_media_scan_completed = False
|
||||||
|
settings.migration_legacy_files_completed = False
|
||||||
|
settings.legacy_key_cleanup_completed = False
|
||||||
settings.last_scan_timestamp = None
|
settings.last_scan_timestamp = None
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info("Reset all scan completion flags")
|
logger.info("Reset all scan completion flags")
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ from src.server.controllers.page_controller import router as page_router
|
|||||||
from src.server.middleware.auth import AuthMiddleware
|
from src.server.middleware.auth import AuthMiddleware
|
||||||
from src.server.middleware.error_handler import register_exception_handlers
|
from src.server.middleware.error_handler import register_exception_handlers
|
||||||
from src.server.middleware.setup_redirect import SetupRedirectMiddleware
|
from src.server.middleware.setup_redirect import SetupRedirectMiddleware
|
||||||
from src.server.services.anime_service import sync_series_from_data_files
|
|
||||||
from src.server.services.progress_service import get_progress_service
|
from src.server.services.progress_service import get_progress_service
|
||||||
from src.server.services.websocket_service import get_websocket_service
|
from src.server.services.websocket_service import get_websocket_service
|
||||||
|
|
||||||
@@ -104,6 +103,107 @@ async def _check_incomplete_series_on_startup(background_loader) -> None:
|
|||||||
logger.exception("Failed to check incomplete series on startup")
|
logger.exception("Failed to check incomplete series on startup")
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_startup_health_checks(logger) -> dict:
|
||||||
|
"""Run startup health checks for critical dependencies.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
- ffmpeg availability
|
||||||
|
- DNS resolution for aniworld.to and api.themoviedb.org
|
||||||
|
- anime_directory configuration and writability
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logger: Logger instance for recording check results.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Health check results with status and details for each check.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
checks: Dict[str, Any] = {
|
||||||
|
"ffmpeg": {"status": "unknown", "message": None},
|
||||||
|
"dns_aniworld": {"status": "unknown", "message": None},
|
||||||
|
"dns_tmdb": {"status": "unknown", "message": None},
|
||||||
|
"anime_directory": {"status": "unknown", "message": None, "path": None},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check ffmpeg availability
|
||||||
|
try:
|
||||||
|
ffmpeg_path = shutil.which("ffmpeg")
|
||||||
|
if ffmpeg_path:
|
||||||
|
checks["ffmpeg"]["status"] = "ok"
|
||||||
|
checks["ffmpeg"]["message"] = f"Found at {ffmpeg_path}"
|
||||||
|
logger.debug("ffmpeg health check passed: %s", ffmpeg_path)
|
||||||
|
else:
|
||||||
|
checks["ffmpeg"]["status"] = "warning"
|
||||||
|
checks["ffmpeg"]["message"] = "ffmpeg not found in PATH"
|
||||||
|
logger.warning("ffmpeg health check failed: not in PATH")
|
||||||
|
except Exception as e:
|
||||||
|
checks["ffmpeg"]["status"] = "error"
|
||||||
|
checks["ffmpeg"]["message"] = str(e)
|
||||||
|
logger.warning("Could not check ffmpeg: %s", e)
|
||||||
|
|
||||||
|
# Check DNS resolution for aniworld.to
|
||||||
|
try:
|
||||||
|
socket.gethostbyname("aniworld.to")
|
||||||
|
checks["dns_aniworld"]["status"] = "ok"
|
||||||
|
checks["dns_aniworld"]["message"] = "Resolved successfully"
|
||||||
|
logger.debug("DNS health check passed for aniworld.to")
|
||||||
|
except socket.gaierror as e:
|
||||||
|
checks["dns_aniworld"]["status"] = "warning"
|
||||||
|
checks["dns_aniworld"]["message"] = f"DNS resolution failed: {e}"
|
||||||
|
logger.warning("DNS health check failed for aniworld.to: %s", e)
|
||||||
|
except Exception as e:
|
||||||
|
checks["dns_aniworld"]["status"] = "warning"
|
||||||
|
checks["dns_aniworld"]["message"] = f"Unexpected error: {e}"
|
||||||
|
logger.warning("Unexpected DNS error for aniworld.to: %s", e)
|
||||||
|
|
||||||
|
# Check DNS resolution for api.themoviedb.org
|
||||||
|
try:
|
||||||
|
socket.gethostbyname("api.themoviedb.org")
|
||||||
|
checks["dns_tmdb"]["status"] = "ok"
|
||||||
|
checks["dns_tmdb"]["message"] = "Resolved successfully"
|
||||||
|
logger.debug("DNS health check passed for api.themoviedb.org")
|
||||||
|
except socket.gaierror as e:
|
||||||
|
checks["dns_tmdb"]["status"] = "warning"
|
||||||
|
checks["dns_tmdb"]["message"] = f"DNS resolution failed: {e}"
|
||||||
|
logger.warning("DNS health check failed for api.themoviedb.org: %s", e)
|
||||||
|
except Exception as e:
|
||||||
|
checks["dns_tmdb"]["status"] = "warning"
|
||||||
|
checks["dns_tmdb"]["message"] = f"Unexpected error: {e}"
|
||||||
|
logger.warning("Unexpected DNS error for api.themoviedb.org: %s", e)
|
||||||
|
|
||||||
|
# Check anime_directory configuration and writability
|
||||||
|
from src.config.settings import settings
|
||||||
|
anime_dir = settings.anime_directory
|
||||||
|
|
||||||
|
if not anime_dir:
|
||||||
|
checks["anime_directory"]["status"] = "error"
|
||||||
|
checks["anime_directory"]["message"] = "anime_directory not configured"
|
||||||
|
checks["anime_directory"]["path"] = None
|
||||||
|
logger.error("anime_directory health check failed: not configured")
|
||||||
|
else:
|
||||||
|
import os
|
||||||
|
checks["anime_directory"]["path"] = anime_dir
|
||||||
|
|
||||||
|
if not os.path.isdir(anime_dir):
|
||||||
|
checks["anime_directory"]["status"] = "error"
|
||||||
|
checks["anime_directory"]["message"] = f"Directory does not exist: {anime_dir}"
|
||||||
|
logger.error("anime_directory health check failed: %s does not exist", anime_dir)
|
||||||
|
elif not os.access(anime_dir, os.W_OK):
|
||||||
|
checks["anime_directory"]["status"] = "error"
|
||||||
|
checks["anime_directory"]["message"] = f"Directory not writable: {anime_dir}"
|
||||||
|
logger.error("anime_directory health check failed: %s not writable", anime_dir)
|
||||||
|
else:
|
||||||
|
checks["anime_directory"]["status"] = "ok"
|
||||||
|
checks["anime_directory"]["message"] = f"Directory exists and is writable: {anime_dir}"
|
||||||
|
logger.debug("anime_directory health check passed: %s", anime_dir)
|
||||||
|
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_application: FastAPI):
|
async def lifespan(_application: FastAPI):
|
||||||
"""Manage application lifespan (startup and shutdown).
|
"""Manage application lifespan (startup and shutdown).
|
||||||
@@ -242,7 +342,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -298,30 +397,29 @@ async def lifespan(_application: FastAPI):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to start background loader service: %s", e)
|
logger.warning("Failed to start background loader service: %s", e)
|
||||||
|
|
||||||
# Initialize and start scheduler service
|
|
||||||
try:
|
|
||||||
from src.server.services.scheduler_service import (
|
|
||||||
get_scheduler_service,
|
|
||||||
)
|
|
||||||
scheduler_service = get_scheduler_service()
|
|
||||||
await scheduler_service.start()
|
|
||||||
initialized['scheduler'] = True
|
|
||||||
logger.info("Scheduler service started")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to start scheduler service: %s", e)
|
|
||||||
# Continue - scheduler is optional
|
|
||||||
|
|
||||||
# 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 - "
|
||||||
"anime directory not configured"
|
"anime directory not configured"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Initialize and start scheduler service (independent of anime_directory)
|
||||||
|
# The scheduler loads its own config from config.json and the
|
||||||
|
# anime_directory may be configured there even if the env var is empty.
|
||||||
|
try:
|
||||||
|
logger.info("Initializing scheduler service...")
|
||||||
|
from src.server.services.scheduler_service import (
|
||||||
|
get_scheduler_service,
|
||||||
|
)
|
||||||
|
scheduler_service = get_scheduler_service()
|
||||||
|
logger.info("Scheduler service instance obtained, starting...")
|
||||||
|
await scheduler_service.start()
|
||||||
|
initialized['scheduler'] = True
|
||||||
|
logger.info("Scheduler service started successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to start scheduler service: %s", e)
|
||||||
except (OSError, RuntimeError, ValueError) as e:
|
except (OSError, RuntimeError, ValueError) as e:
|
||||||
logger.warning("Failed to initialize services: %s", e)
|
logger.warning("Failed to initialize services: %s", e)
|
||||||
# Continue startup - services can be initialized later
|
# Continue startup - services can be initialized later
|
||||||
@@ -334,6 +432,27 @@ async def lifespan(_application: FastAPI):
|
|||||||
logger.info(
|
logger.info(
|
||||||
"API documentation available at http://127.0.0.1:8000/api/docs"
|
"API documentation available at http://127.0.0.1:8000/api/docs"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check for ffmpeg availability and warn if missing
|
||||||
|
try:
|
||||||
|
import shutil as _shutil
|
||||||
|
if _shutil.which("ffmpeg") is None:
|
||||||
|
logger.warning(
|
||||||
|
"ffmpeg not found in PATH. HLS streams may fail to download. "
|
||||||
|
"Install ffmpeg to enable HLS support."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug("ffmpeg found at: %s", _shutil.which("ffmpeg"))
|
||||||
|
except Exception as _exc:
|
||||||
|
logger.warning("Could not check for ffmpeg: %s", _exc)
|
||||||
|
|
||||||
|
# Run startup health checks and store results for /health endpoint
|
||||||
|
try:
|
||||||
|
startup_checks = await _run_startup_health_checks(logger)
|
||||||
|
app.state.startup_checks = startup_checks
|
||||||
|
except Exception as _exc:
|
||||||
|
logger.warning("Could not run startup health checks: %s", _exc)
|
||||||
|
app.state.startup_checks = {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error during startup: %s", e, exc_info=True)
|
logger.error("Error during startup: %s", e, exc_info=True)
|
||||||
startup_error = e
|
startup_error = e
|
||||||
@@ -485,7 +604,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
|
||||||
|
|||||||
@@ -83,6 +83,30 @@ class AnimeSeriesResponse(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class AnimeMetadataUpdate(BaseModel):
|
||||||
|
"""Request model for updating anime metadata (key, tmdb_id, tvdb_id)."""
|
||||||
|
|
||||||
|
key: Optional[str] = Field(None, description="New series key (URL-safe, lowercase)")
|
||||||
|
tmdb_id: Optional[int] = Field(None, ge=1, description="TMDB ID (positive integer)")
|
||||||
|
tvdb_id: Optional[int] = Field(None, ge=1, description="TVDB ID (positive integer)")
|
||||||
|
|
||||||
|
@field_validator('key', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def validate_key_format(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
"""Validate key is URL-safe lowercase with hyphens only."""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
v = v.strip().lower()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("Key cannot be empty")
|
||||||
|
if not KEY_PATTERN.match(v):
|
||||||
|
raise ValueError(
|
||||||
|
"Key must contain only lowercase letters, numbers, and hyphens. "
|
||||||
|
"Cannot start or end with a hyphen."
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class SearchRequest(BaseModel):
|
class SearchRequest(BaseModel):
|
||||||
"""Request payload for searching series."""
|
"""Request payload for searching series."""
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -39,6 +39,23 @@ 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.",
|
||||||
|
)
|
||||||
|
# Legacy alias fields — read via Pydantic alias
|
||||||
|
auto_download: Optional[bool] = Field(default=None, alias="auto_download")
|
||||||
|
folder_scan: Optional[bool] = Field(default=None, alias="folder_scan")
|
||||||
|
|
||||||
|
def __init__(self, **data):
|
||||||
|
super().__init__(**data)
|
||||||
|
# Map legacy keys to primary fields only when primary key absent from data.
|
||||||
|
# "key in data" checks for explicit presence (even False/None), not just truthiness.
|
||||||
|
if self.auto_download is not None and "auto_download_after_rescan" not in data:
|
||||||
|
object.__setattr__(self, "auto_download_after_rescan", self.auto_download)
|
||||||
|
if self.folder_scan is not None and "folder_scan_enabled" not in data:
|
||||||
|
object.__setattr__(self, "folder_scan_enabled", self.folder_scan)
|
||||||
|
|
||||||
@field_validator("schedule_time")
|
@field_validator("schedule_time")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -64,6 +81,22 @@ class SchedulerConfig(BaseModel):
|
|||||||
)
|
)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
def model_dump(self, **kwargs) -> Dict[str, object]:
|
||||||
|
"""Serialize, excluding legacy alias fields when they are None.
|
||||||
|
|
||||||
|
The alias fields (auto_download, folder_scan) must not be written to
|
||||||
|
config.json as null entries, otherwise a roundtrip load sees the key
|
||||||
|
present (哪怕 value is None) and skips the alias-to-primary mapping.
|
||||||
|
"""
|
||||||
|
data = super().model_dump(**kwargs)
|
||||||
|
# Drop None alias fields so they don't pollute config.json.
|
||||||
|
# They are still settable via the constructor for backward compatibility.
|
||||||
|
if data.get("auto_download") is None:
|
||||||
|
data.pop("auto_download", None)
|
||||||
|
if data.get("folder_scan") is None:
|
||||||
|
data.pop("folder_scan", None)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class BackupConfig(BaseModel):
|
class BackupConfig(BaseModel):
|
||||||
"""Configuration for automatic backups of application data."""
|
"""Configuration for automatic backups of application data."""
|
||||||
@@ -166,6 +199,12 @@ class AppConfig(BaseModel):
|
|||||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||||
backup: BackupConfig = Field(default_factory=BackupConfig)
|
backup: BackupConfig = Field(default_factory=BackupConfig)
|
||||||
nfo: NFOConfig = Field(default_factory=NFOConfig)
|
nfo: NFOConfig = Field(default_factory=NFOConfig)
|
||||||
|
scan_key_overrides: Dict[str, str] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Map of folder names to provider keys for scan overrides. "
|
||||||
|
"Used when auto-generated keys from folder names are incorrect. "
|
||||||
|
"Format: {\"Folder Name\": \"actual-provider-key\"}"
|
||||||
|
)
|
||||||
other: Dict[str, object] = Field(
|
other: Dict[str, object] = Field(
|
||||||
default_factory=dict, description="Arbitrary other settings"
|
default_factory=dict, description="Arbitrary other settings"
|
||||||
)
|
)
|
||||||
@@ -204,6 +243,7 @@ class ConfigUpdate(BaseModel):
|
|||||||
logging: Optional[LoggingConfig] = None
|
logging: Optional[LoggingConfig] = None
|
||||||
backup: Optional[BackupConfig] = None
|
backup: Optional[BackupConfig] = None
|
||||||
nfo: Optional[NFOConfig] = None
|
nfo: Optional[NFOConfig] = None
|
||||||
|
scan_key_overrides: Optional[Dict[str, str]] = None
|
||||||
other: Optional[Dict[str, object]] = None
|
other: Optional[Dict[str, object]] = None
|
||||||
|
|
||||||
def apply_to(self, current: AppConfig) -> AppConfig:
|
def apply_to(self, current: AppConfig) -> AppConfig:
|
||||||
@@ -220,6 +260,8 @@ class ConfigUpdate(BaseModel):
|
|||||||
data["backup"] = self.backup.model_dump()
|
data["backup"] = self.backup.model_dump()
|
||||||
if self.nfo is not None:
|
if self.nfo is not None:
|
||||||
data["nfo"] = self.nfo.model_dump()
|
data["nfo"] = self.nfo.model_dump()
|
||||||
|
if self.scan_key_overrides is not None:
|
||||||
|
data["scan_key_overrides"] = self.scan_key_overrides
|
||||||
if self.other is not None:
|
if self.other is not None:
|
||||||
merged = dict(current.other or {})
|
merged = dict(current.other or {})
|
||||||
merged.update(self.other)
|
merged.update(self.other)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class DownloadStatus(str, Enum):
|
|||||||
COMPLETED = "completed"
|
COMPLETED = "completed"
|
||||||
FAILED = "failed"
|
FAILED = "failed"
|
||||||
CANCELLED = "cancelled"
|
CANCELLED = "cancelled"
|
||||||
|
PERMANENTLY_FAILED = "permanently_failed"
|
||||||
|
|
||||||
|
|
||||||
class DownloadPriority(str, Enum):
|
class DownloadPriority(str, Enum):
|
||||||
|
|||||||
@@ -355,3 +355,29 @@ class NFOMissingResponse(BaseModel):
|
|||||||
...,
|
...,
|
||||||
description="List of series missing NFO"
|
description="List of series missing NFO"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NfoDiagnosticsResponse(BaseModel):
|
||||||
|
"""Response for NFO diagnostics showing missing required tags."""
|
||||||
|
|
||||||
|
has_nfo: bool = Field(..., description="Whether tvshow.nfo exists")
|
||||||
|
nfo_path: Optional[str] = Field(None, description="Path to NFO file if exists")
|
||||||
|
missing_tags: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="List of missing required tag names"
|
||||||
|
)
|
||||||
|
required_tags: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="All required tag names for reference"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NfoRepairResponse(BaseModel):
|
||||||
|
"""Response after NFO repair attempt."""
|
||||||
|
|
||||||
|
success: bool = Field(..., description="Whether repair succeeded")
|
||||||
|
message: str = Field(..., description="Human-readable result message")
|
||||||
|
repaired_tags: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Tags that were missing before repair"
|
||||||
|
)
|
||||||
|
|||||||
@@ -498,13 +498,19 @@ class AnimeService:
|
|||||||
logger.info("No series found in SeriesApp")
|
logger.info("No series found in SeriesApp")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Build NFO metadata map and filter data from database
|
# Build NFO metadata map, episode dict, and filter data from database.
|
||||||
nfo_map = {}
|
# Using DB as authoritative source for episodeDict ensures that
|
||||||
series_with_no_episodes = set()
|
# episodes marked is_downloaded=True are never shown as missing,
|
||||||
|
# even if the in-memory state is stale.
|
||||||
|
nfo_map: dict = {}
|
||||||
|
db_episode_dict_map: dict[str, dict[int, list[int]]] = {}
|
||||||
|
series_with_no_episodes: set = set()
|
||||||
|
|
||||||
async with get_db_session() as db:
|
async with get_db_session() as db:
|
||||||
# Get all series NFO metadata using service layer
|
# Single query: load all series with their episodes eagerly
|
||||||
db_series_list = await AnimeSeriesService.get_all(db)
|
db_series_list = await AnimeSeriesService.get_all(
|
||||||
|
db, with_episodes=True
|
||||||
|
)
|
||||||
|
|
||||||
for db_series in db_series_list:
|
for db_series in db_series_list:
|
||||||
nfo_created = (
|
nfo_created = (
|
||||||
@@ -522,8 +528,24 @@ class AnimeService:
|
|||||||
"tmdb_id": db_series.tmdb_id,
|
"tmdb_id": db_series.tmdb_id,
|
||||||
"tvdb_id": db_series.tvdb_id,
|
"tvdb_id": db_series.tvdb_id,
|
||||||
"series_id": db_series.id,
|
"series_id": db_series.id,
|
||||||
|
"loading_status": db_series.loading_status,
|
||||||
|
"loading_error": db_series.loading_error,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Build episodeDict from DB, skipping is_downloaded=True
|
||||||
|
# episodes so they are never shown as missing in the UI.
|
||||||
|
ep_dict: dict[int, list[int]] = {}
|
||||||
|
if db_series.episodes:
|
||||||
|
for ep in db_series.episodes:
|
||||||
|
if ep.is_downloaded:
|
||||||
|
continue
|
||||||
|
if ep.season not in ep_dict:
|
||||||
|
ep_dict[ep.season] = []
|
||||||
|
ep_dict[ep.season].append(ep.episode_number)
|
||||||
|
for s in ep_dict:
|
||||||
|
ep_dict[s].sort()
|
||||||
|
db_episode_dict_map[db_series.folder] = ep_dict
|
||||||
|
|
||||||
# If filter is "missing_episodes", get series with any missing episodes
|
# If filter is "missing_episodes", get series with any missing episodes
|
||||||
if filter_type == "missing_episodes":
|
if filter_type == "missing_episodes":
|
||||||
series_missing = (
|
series_missing = (
|
||||||
@@ -545,7 +567,12 @@ class AnimeService:
|
|||||||
name = getattr(serie, "name", "")
|
name = getattr(serie, "name", "")
|
||||||
site = getattr(serie, "site", "")
|
site = getattr(serie, "site", "")
|
||||||
folder = getattr(serie, "folder", "")
|
folder = getattr(serie, "folder", "")
|
||||||
episode_dict = getattr(serie, "episodeDict", {}) or {}
|
# Use DB-backed episodeDict (is_downloaded=True already filtered out)
|
||||||
|
# with in-memory episodeDict as fallback if the series isn't in DB yet.
|
||||||
|
episode_dict = db_episode_dict_map.get(
|
||||||
|
folder,
|
||||||
|
getattr(serie, "episodeDict", {}) or {}
|
||||||
|
)
|
||||||
|
|
||||||
# Apply filter if specified
|
# Apply filter if specified
|
||||||
if filter_type == "missing_episodes":
|
if filter_type == "missing_episodes":
|
||||||
@@ -571,6 +598,8 @@ class AnimeService:
|
|||||||
"tmdb_id": nfo_data.get("tmdb_id"),
|
"tmdb_id": nfo_data.get("tmdb_id"),
|
||||||
"tvdb_id": nfo_data.get("tvdb_id"),
|
"tvdb_id": nfo_data.get("tvdb_id"),
|
||||||
"series_id": nfo_data.get("series_id"),
|
"series_id": nfo_data.get("series_id"),
|
||||||
|
"loading_status": nfo_data.get("loading_status"),
|
||||||
|
"loading_error": nfo_data.get("loading_error"),
|
||||||
}
|
}
|
||||||
result_list.append(series_dict)
|
result_list.append(series_dict)
|
||||||
|
|
||||||
@@ -815,18 +844,24 @@ class AnimeService:
|
|||||||
- Adds new missing episodes that are not in the database
|
- Adds new missing episodes that are not in the database
|
||||||
- Removes episodes from database that are no longer missing
|
- Removes episodes from database that are no longer missing
|
||||||
(i.e., the file has been added to the filesystem)
|
(i.e., the file has been added to the filesystem)
|
||||||
|
- Preserves episodes marked as downloaded (is_downloaded=True)
|
||||||
|
so download history is not lost
|
||||||
"""
|
"""
|
||||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||||
|
|
||||||
# Get existing episodes from database
|
# Get existing episodes from database (all episodes, including downloaded)
|
||||||
existing_episodes = await EpisodeService.get_by_series(db, existing.id)
|
existing_episodes = await EpisodeService.get_by_series(db, existing.id)
|
||||||
|
|
||||||
# Build dict of existing episodes: {season: {ep_num: episode_id}}
|
# Build dict of existing episodes: {season: {ep_num: episode_id}}
|
||||||
|
# and track which ones are already downloaded
|
||||||
existing_dict: dict[int, dict[int, int]] = {}
|
existing_dict: dict[int, dict[int, int]] = {}
|
||||||
|
downloaded_set: set[tuple[int, int]] = set()
|
||||||
for ep in existing_episodes:
|
for ep in existing_episodes:
|
||||||
if ep.season not in existing_dict:
|
if ep.season not in existing_dict:
|
||||||
existing_dict[ep.season] = {}
|
existing_dict[ep.season] = {}
|
||||||
existing_dict[ep.season][ep.episode_number] = ep.id
|
existing_dict[ep.season][ep.episode_number] = ep.id
|
||||||
|
if ep.is_downloaded:
|
||||||
|
downloaded_set.add((ep.season, ep.episode_number))
|
||||||
|
|
||||||
# Get new missing episodes from scan
|
# Get new missing episodes from scan
|
||||||
new_dict = serie.episodeDict or {}
|
new_dict = serie.episodeDict or {}
|
||||||
@@ -857,9 +892,22 @@ class AnimeService:
|
|||||||
|
|
||||||
# Remove episodes from database that are no longer missing
|
# Remove episodes from database that are no longer missing
|
||||||
# (i.e., the episode file now exists on the filesystem)
|
# (i.e., the episode file now exists on the filesystem)
|
||||||
|
# BUT: preserve episodes that are already downloaded (is_downloaded=True)
|
||||||
|
# so we don't lose download history
|
||||||
for season, eps_dict in existing_dict.items():
|
for season, eps_dict in existing_dict.items():
|
||||||
for ep_num, episode_id in eps_dict.items():
|
for ep_num, episode_id in eps_dict.items():
|
||||||
if (season, ep_num) not in new_missing_set:
|
if (season, ep_num) not in new_missing_set:
|
||||||
|
# Skip already-downloaded episodes — they should stay in DB
|
||||||
|
# with is_downloaded=True to preserve download history
|
||||||
|
if (season, ep_num) in downloaded_set:
|
||||||
|
logger.debug(
|
||||||
|
"Preserving downloaded episode in database: "
|
||||||
|
"%s S%02dE%02d",
|
||||||
|
serie.key,
|
||||||
|
season,
|
||||||
|
ep_num
|
||||||
|
)
|
||||||
|
continue
|
||||||
await EpisodeService.delete(db, episode_id)
|
await EpisodeService.delete(db, episode_id)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Removed episode from database (no longer missing): "
|
"Removed episode from database (no longer missing): "
|
||||||
@@ -889,6 +937,10 @@ class AnimeService:
|
|||||||
|
|
||||||
This method is called during initialization and after rescans
|
This method is called during initialization and after rescans
|
||||||
to ensure the in-memory series list is in sync with the database.
|
to ensure the in-memory series list is in sync with the database.
|
||||||
|
|
||||||
|
Only episodes where is_downloaded=False are loaded into the
|
||||||
|
in-memory episodeDict, so downloaded episodes are not shown
|
||||||
|
as missing.
|
||||||
"""
|
"""
|
||||||
from src.core.entities.series import Serie
|
from src.core.entities.series import Serie
|
||||||
from src.server.database.connection import get_db_session
|
from src.server.database.connection import get_db_session
|
||||||
@@ -903,9 +955,14 @@ class AnimeService:
|
|||||||
series_list = []
|
series_list = []
|
||||||
for anime_series in anime_series_list:
|
for anime_series in anime_series_list:
|
||||||
# Build episode_dict from episodes relationship
|
# Build episode_dict from episodes relationship
|
||||||
|
# Only include episodes that are NOT downloaded (is_downloaded=False)
|
||||||
|
# so the missing-episode list stays accurate
|
||||||
episode_dict: dict[int, list[int]] = {}
|
episode_dict: dict[int, list[int]] = {}
|
||||||
if anime_series.episodes:
|
if anime_series.episodes:
|
||||||
for episode in anime_series.episodes:
|
for episode in anime_series.episodes:
|
||||||
|
# Skip downloaded episodes — they are not missing
|
||||||
|
if episode.is_downloaded:
|
||||||
|
continue
|
||||||
season = episode.season
|
season = episode.season
|
||||||
if season not in episode_dict:
|
if season not in episode_dict:
|
||||||
episode_dict[season] = []
|
episode_dict[season] = []
|
||||||
@@ -919,7 +976,8 @@ class AnimeService:
|
|||||||
name=anime_series.name,
|
name=anime_series.name,
|
||||||
site=anime_series.site,
|
site=anime_series.site,
|
||||||
folder=anime_series.folder,
|
folder=anime_series.folder,
|
||||||
episodeDict=episode_dict
|
episodeDict=episode_dict,
|
||||||
|
year=anime_series.year
|
||||||
)
|
)
|
||||||
series_list.append(serie)
|
series_list.append(serie)
|
||||||
|
|
||||||
@@ -962,23 +1020,39 @@ class AnimeService:
|
|||||||
logger.warning("Series not found in database: %s", 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 (all, including downloaded)
|
||||||
existing_episodes = await EpisodeService.get_by_series(db, series_db.id)
|
existing_episodes = await EpisodeService.get_by_series(db, series_db.id)
|
||||||
|
|
||||||
# Build dict of existing episodes: {season: {ep_num: episode_id}}
|
# Build dict of existing episodes: {season: {ep_num: episode_id}}
|
||||||
|
# and track which ones are already downloaded
|
||||||
existing_dict: dict[int, dict[int, int]] = {}
|
existing_dict: dict[int, dict[int, int]] = {}
|
||||||
|
downloaded_set: set[tuple[int, int]] = set()
|
||||||
for ep in existing_episodes:
|
for ep in existing_episodes:
|
||||||
if ep.season not in existing_dict:
|
if ep.season not in existing_dict:
|
||||||
existing_dict[ep.season] = {}
|
existing_dict[ep.season] = {}
|
||||||
existing_dict[ep.season][ep.episode_number] = ep.id
|
existing_dict[ep.season][ep.episode_number] = ep.id
|
||||||
|
if ep.is_downloaded:
|
||||||
|
downloaded_set.add((ep.season, ep.episode_number))
|
||||||
|
|
||||||
# Get new missing episodes from in-memory serie
|
# Get new missing episodes from in-memory serie
|
||||||
new_dict = serie.episodeDict or {}
|
new_dict = serie.episodeDict or {}
|
||||||
|
|
||||||
# Add new missing episodes that are not in the database
|
# Add new missing episodes that are not in the database
|
||||||
|
# Skip episodes that are already downloaded (is_downloaded=True)
|
||||||
|
# so we don't re-add them as missing after they've been downloaded
|
||||||
for season, episode_numbers in new_dict.items():
|
for season, episode_numbers in new_dict.items():
|
||||||
existing_season_eps = existing_dict.get(season, {})
|
existing_season_eps = existing_dict.get(season, {})
|
||||||
for ep_num in episode_numbers:
|
for ep_num in episode_numbers:
|
||||||
|
# Skip if already downloaded — don't re-add as missing
|
||||||
|
if (season, ep_num) in downloaded_set:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping already-downloaded episode: "
|
||||||
|
"%s S%02dE%02d",
|
||||||
|
series_key,
|
||||||
|
season,
|
||||||
|
ep_num,
|
||||||
|
)
|
||||||
|
continue
|
||||||
if ep_num not in existing_season_eps:
|
if ep_num not in existing_season_eps:
|
||||||
await EpisodeService.create(
|
await EpisodeService.create(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -1014,20 +1088,23 @@ class AnimeService:
|
|||||||
if hasattr(self._app, 'list') and hasattr(self._app.list, 'keyDict'):
|
if hasattr(self._app, 'list') and hasattr(self._app.list, 'keyDict'):
|
||||||
serie = self._app.list.keyDict.get(series_key)
|
serie = self._app.list.keyDict.get(series_key)
|
||||||
if serie:
|
if serie:
|
||||||
# Convert episode dict keys to strings for JSON
|
# Fetch NFO metadata and episodes from database.
|
||||||
missing_episodes = {str(k): v for k, v in (serie.episodeDict or {}).items()}
|
# Using DB as the authoritative source for missing_episodes
|
||||||
total_missing = sum(len(eps) for eps in missing_episodes.values())
|
# ensures that episodes marked is_downloaded=True are never
|
||||||
|
# broadcast as missing, even if in-memory state is stale.
|
||||||
# Fetch NFO metadata from database
|
|
||||||
has_nfo = False
|
has_nfo = False
|
||||||
nfo_created_at = None
|
nfo_created_at = None
|
||||||
nfo_updated_at = None
|
nfo_updated_at = None
|
||||||
tmdb_id = None
|
tmdb_id = None
|
||||||
tvdb_id = None
|
tvdb_id = None
|
||||||
|
missing_episodes: dict[str, list] = {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from src.server.database.connection import get_db_session
|
from src.server.database.connection import get_db_session
|
||||||
from src.server.database.service import AnimeSeriesService
|
from src.server.database.service import (
|
||||||
|
AnimeSeriesService,
|
||||||
|
EpisodeService,
|
||||||
|
)
|
||||||
|
|
||||||
async with get_db_session() as db:
|
async with get_db_session() as db:
|
||||||
db_series = await AnimeSeriesService.get_by_key(db, series_key)
|
db_series = await AnimeSeriesService.get_by_key(db, series_key)
|
||||||
@@ -1043,12 +1120,31 @@ class AnimeService:
|
|||||||
)
|
)
|
||||||
tmdb_id = db_series.tmdb_id
|
tmdb_id = db_series.tmdb_id
|
||||||
tvdb_id = db_series.tvdb_id
|
tvdb_id = db_series.tvdb_id
|
||||||
|
|
||||||
|
# Build missing_episodes from DB, skipping is_downloaded=True
|
||||||
|
db_episodes = await EpisodeService.get_by_series(
|
||||||
|
db, db_series.id, only_missing=True
|
||||||
|
)
|
||||||
|
for ep in db_episodes:
|
||||||
|
key_str = str(ep.season)
|
||||||
|
if key_str not in missing_episodes:
|
||||||
|
missing_episodes[key_str] = []
|
||||||
|
missing_episodes[key_str].append(ep.episode_number)
|
||||||
|
for s in missing_episodes:
|
||||||
|
missing_episodes[s].sort()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Could not fetch NFO data for %s: %s",
|
"Could not fetch series data for %s from DB: %s",
|
||||||
series_key,
|
series_key,
|
||||||
str(e)
|
str(e)
|
||||||
)
|
)
|
||||||
|
# Fallback to in-memory state
|
||||||
|
missing_episodes = {
|
||||||
|
str(k): v
|
||||||
|
for k, v in (serie.episodeDict or {}).items()
|
||||||
|
}
|
||||||
|
|
||||||
|
total_missing = sum(len(eps) for eps in missing_episodes.values())
|
||||||
|
|
||||||
series_data = {
|
series_data = {
|
||||||
"key": serie.key,
|
"key": serie.key,
|
||||||
@@ -1462,19 +1558,17 @@ def get_anime_service(series_app: SeriesApp) -> AnimeService:
|
|||||||
return AnimeService(series_app)
|
return AnimeService(series_app)
|
||||||
|
|
||||||
|
|
||||||
async def sync_series_from_data_files(
|
async def sync_legacy_series_to_db(
|
||||||
anime_directory: str,
|
anime_directory: str,
|
||||||
log_instance=None # pylint: disable=unused-argument
|
log_instance=None # pylint: disable=unused-argument
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
Sync series from data files to the database.
|
One-time legacy sync: import any series from 'data' files
|
||||||
|
not already in the database.
|
||||||
|
|
||||||
Scans the anime directory for data files and adds any new series
|
Deprecated: Series are now loaded directly from the database.
|
||||||
to the database. Existing series are skipped (no duplicates).
|
This function remains for backwards compatibility with legacy
|
||||||
|
file-based data during migration.
|
||||||
This function is typically called during application startup to ensure
|
|
||||||
series metadata stored in filesystem data files is available in the
|
|
||||||
database.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
anime_directory: Path to the anime directory with data files
|
anime_directory: Path to the anime directory with data files
|
||||||
@@ -1487,6 +1581,11 @@ async def sync_series_from_data_files(
|
|||||||
# Always use structlog for structured logging with keyword arguments
|
# Always use structlog for structured logging with keyword arguments
|
||||||
log = structlog.get_logger(__name__)
|
log = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
log.warning(
|
||||||
|
"sync_legacy_series_to_db is deprecated. "
|
||||||
|
"Series are now loaded directly from database."
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from src.server.database.connection import get_db_session
|
from src.server.database.connection import get_db_session
|
||||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||||
@@ -1550,6 +1649,7 @@ async def sync_series_from_data_files(
|
|||||||
name=serie.name,
|
name=serie.name,
|
||||||
site=serie.site,
|
site=serie.site,
|
||||||
folder=serie.folder,
|
folder=serie.folder,
|
||||||
|
year=serie.year if hasattr(serie, 'year') else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create Episode records for each episode in episodeDict
|
# Create Episode records for each episode in episodeDict
|
||||||
|
|||||||
@@ -44,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,
|
||||||
@@ -145,6 +145,12 @@ class ConfigService:
|
|||||||
data = config.model_dump()
|
data = config.model_dump()
|
||||||
data["version"] = self.CONFIG_VERSION
|
data["version"] = self.CONFIG_VERSION
|
||||||
|
|
||||||
|
# Re-serialize SchedulerConfig through its overridden model_dump so
|
||||||
|
# that None legacy alias fields are stripped before writing to disk.
|
||||||
|
# Pydantic converts nested models to plain dicts in model_dump() output,
|
||||||
|
# so we call the override explicitly on the scheduler field.
|
||||||
|
data["scheduler"] = config.scheduler.model_dump()
|
||||||
|
|
||||||
# Write to temporary file first for atomic operation
|
# Write to temporary file first for atomic operation
|
||||||
temp_path = self.config_path.with_suffix(".tmp")
|
temp_path = self.config_path.with_suffix(".tmp")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import uuid
|
|||||||
from collections import deque
|
from collections import deque
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Dict, List, Optional
|
from typing import TYPE_CHECKING, Dict, List, Optional
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
@@ -68,6 +69,7 @@ class DownloadService:
|
|||||||
progress_service: Optional progress service for tracking
|
progress_service: Optional progress service for tracking
|
||||||
"""
|
"""
|
||||||
self._anime_service = anime_service
|
self._anime_service = anime_service
|
||||||
|
self._directory = anime_service._directory
|
||||||
self._max_retries = max_retries
|
self._max_retries = max_retries
|
||||||
self._progress_service = progress_service or get_progress_service()
|
self._progress_service = progress_service or get_progress_service()
|
||||||
|
|
||||||
@@ -79,6 +81,9 @@ class DownloadService:
|
|||||||
self._pending_queue: deque[DownloadItem] = deque()
|
self._pending_queue: deque[DownloadItem] = deque()
|
||||||
# Helper dict for O(1) lookup of pending items by ID
|
# Helper dict for O(1) lookup of pending items by ID
|
||||||
self._pending_items_by_id: Dict[str, DownloadItem] = {}
|
self._pending_items_by_id: Dict[str, DownloadItem] = {}
|
||||||
|
# Helper dict for O(1) lookup of pending items by episode identity
|
||||||
|
# Key: (serie_id, season, episode), Value: item ID
|
||||||
|
self._pending_by_episode: Dict[tuple, str] = {}
|
||||||
self._active_download: Optional[DownloadItem] = None
|
self._active_download: Optional[DownloadItem] = None
|
||||||
self._completed_items: deque[DownloadItem] = deque(maxlen=100)
|
self._completed_items: deque[DownloadItem] = deque(maxlen=100)
|
||||||
self._failed_items: deque[DownloadItem] = deque(maxlen=50)
|
self._failed_items: deque[DownloadItem] = deque(maxlen=50)
|
||||||
@@ -165,6 +170,27 @@ class DownloadService:
|
|||||||
logger.error("Failed to save item to database: %s", e)
|
logger.error("Failed to save item to database: %s", e)
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
async def _set_status_in_database(
|
||||||
|
self,
|
||||||
|
item_id: str,
|
||||||
|
status: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Set status on an item in the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: Download item ID
|
||||||
|
status: New status value
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if update succeeded
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
repository = self._get_repository()
|
||||||
|
return await repository.set_status(item_id, status)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to set status in database: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
async def _set_error_in_database(
|
async def _set_error_in_database(
|
||||||
self,
|
self,
|
||||||
item_id: str,
|
item_id: str,
|
||||||
@@ -186,6 +212,25 @@ class DownloadService:
|
|||||||
logger.error("Failed to set error in database: %s", e)
|
logger.error("Failed to set error in database: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def _increment_retry_in_database(
|
||||||
|
self,
|
||||||
|
item_id: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Increment retry count on an item in the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: Download item ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if update succeeded
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
repository = self._get_repository()
|
||||||
|
return await repository.increment_retry(item_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to increment retry in database: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
async def _delete_from_database(self, item_id: str) -> bool:
|
async def _delete_from_database(self, item_id: str) -> bool:
|
||||||
"""Delete an item from the database.
|
"""Delete an item from the database.
|
||||||
|
|
||||||
@@ -207,30 +252,33 @@ class DownloadService:
|
|||||||
series_key: str,
|
series_key: str,
|
||||||
season: int,
|
season: int,
|
||||||
episode: int,
|
episode: int,
|
||||||
|
serie_folder: Optional[str] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Remove a downloaded episode from the missing episodes list.
|
"""Mark a downloaded episode as downloaded instead of deleting it.
|
||||||
|
|
||||||
Called when a download completes successfully to update both:
|
Called when a download completes successfully to update both:
|
||||||
1. The database (Episode record deleted)
|
1. The database (Episode record marked is_downloaded=True)
|
||||||
2. The in-memory Serie.episodeDict and series_list cache
|
2. The in-memory Serie.episodeDict and series_list cache
|
||||||
|
|
||||||
This ensures the episode no longer appears as missing in both
|
This ensures the episode no longer appears as missing in both
|
||||||
the API responses and the UI immediately after download.
|
the API responses and the UI immediately after download,
|
||||||
|
while preserving the download history.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
series_key: Unique provider key for the series
|
series_key: Unique provider key for the series
|
||||||
season: Season number
|
season: Season number
|
||||||
episode: Episode number within season
|
episode: Episode number within season
|
||||||
|
serie_folder: Series folder name (required for file_path)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if episode was removed, False otherwise
|
True if episode was updated, False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from src.server.database.connection import get_db_session
|
from src.server.database.connection import get_db_session
|
||||||
from src.server.database.service import EpisodeService
|
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Attempting to remove missing episode from DB: "
|
"Attempting to mark episode as downloaded in DB: "
|
||||||
"%s S%02dE%02d",
|
"%s S%02dE%02d",
|
||||||
series_key,
|
series_key,
|
||||||
season,
|
season,
|
||||||
@@ -238,28 +286,63 @@ class DownloadService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async with get_db_session() as db:
|
async with get_db_session() as db:
|
||||||
deleted = await EpisodeService.delete_by_series_and_episode(
|
# Get series by key to find series_id
|
||||||
|
series = await AnimeSeriesService.get_by_key(db, series_key)
|
||||||
|
if not series:
|
||||||
|
logger.warning(
|
||||||
|
"Series not found for key: %s", series_key
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get episode by series_id, season, episode_number
|
||||||
|
ep = await EpisodeService.get_by_episode(
|
||||||
db=db,
|
db=db,
|
||||||
series_key=series_key,
|
series_id=series.id,
|
||||||
season=season,
|
season=season,
|
||||||
episode_number=episode,
|
episode_number=episode,
|
||||||
)
|
)
|
||||||
if deleted:
|
if not ep:
|
||||||
|
logger.warning(
|
||||||
|
"Episode not found in DB: %s S%02dE%02d",
|
||||||
|
series_key,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Construct file_path if serie_folder provided
|
||||||
|
file_path = None
|
||||||
|
if serie_folder:
|
||||||
|
season_folder = f"Season {season}"
|
||||||
|
file_path = str(
|
||||||
|
Path(self._directory) / serie_folder / season_folder
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark episode as downloaded instead of deleting
|
||||||
|
updated = await EpisodeService.mark_downloaded(
|
||||||
|
db=db,
|
||||||
|
episode_id=ep.id,
|
||||||
|
file_path=file_path or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
if updated:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Successfully removed episode from DB missing list: "
|
"Marked episode as downloaded in DB: "
|
||||||
|
"%s S%02dE%02d, file_path=%s",
|
||||||
|
series_key,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
file_path,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to mark episode as downloaded: "
|
||||||
"%s S%02dE%02d",
|
"%s S%02dE%02d",
|
||||||
series_key,
|
series_key,
|
||||||
season,
|
season,
|
||||||
episode,
|
episode,
|
||||||
)
|
)
|
||||||
else:
|
return False
|
||||||
logger.warning(
|
|
||||||
"Episode not found in DB missing list "
|
|
||||||
"(may already be removed): %s S%02dE%02d",
|
|
||||||
series_key,
|
|
||||||
season,
|
|
||||||
episode,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update in-memory Serie.episodeDict so list_missing is
|
# Update in-memory Serie.episodeDict so list_missing is
|
||||||
# immediately consistent without a full DB reload
|
# immediately consistent without a full DB reload
|
||||||
@@ -270,8 +353,8 @@ class DownloadService:
|
|||||||
try:
|
try:
|
||||||
self._anime_service._cached_list_missing.cache_clear()
|
self._anime_service._cached_list_missing.cache_clear()
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Cleared list_missing cache after removing "
|
"Cleared list_missing cache after marking "
|
||||||
"%s S%02dE%02d",
|
"%s S%02dE%02d as downloaded",
|
||||||
series_key,
|
series_key,
|
||||||
season,
|
season,
|
||||||
episode,
|
episode,
|
||||||
@@ -279,10 +362,35 @@ class DownloadService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return deleted
|
# Broadcast real-time update to frontend so the series card
|
||||||
|
# immediately reflects the new downloaded state (no longer
|
||||||
|
# shows the episode as missing) without waiting for a full
|
||||||
|
# reload on DOWNLOAD_COMPLETED.
|
||||||
|
try:
|
||||||
|
await self._anime_service._broadcast_series_updated(
|
||||||
|
series_key
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Broadcast series_updated after marking "
|
||||||
|
"%s S%02dE%02d as downloaded",
|
||||||
|
series_key,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
)
|
||||||
|
except Exception as broadcast_exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to broadcast series update after marking "
|
||||||
|
"%s S%02dE%02d as downloaded: %s",
|
||||||
|
series_key,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
broadcast_exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to remove episode from missing list: "
|
"Failed to mark episode as downloaded: "
|
||||||
"%s S%02dE%02d - %s",
|
"%s S%02dE%02d - %s",
|
||||||
series_key,
|
series_key,
|
||||||
season,
|
season,
|
||||||
@@ -358,6 +466,27 @@ class DownloadService:
|
|||||||
"missing episodes remaining",
|
"missing episodes remaining",
|
||||||
len(app.series_list),
|
len(app.series_list),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Update deprecated data file if it exists
|
||||||
|
# DB is authoritative; data file is optional backup
|
||||||
|
serie_folder = serie.folder
|
||||||
|
data_path = Path(self._directory) / serie_folder / "data"
|
||||||
|
if data_path.exists():
|
||||||
|
try:
|
||||||
|
import warnings
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore", DeprecationWarning)
|
||||||
|
serie.save_to_file(str(data_path))
|
||||||
|
logger.debug(
|
||||||
|
"Updated data file after download: %s",
|
||||||
|
data_path,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to update data file %s: %s",
|
||||||
|
data_path,
|
||||||
|
e,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Episode %d not in season %d for %s, "
|
"Episode %d not in season %d for %s, "
|
||||||
@@ -409,7 +538,7 @@ class DownloadService:
|
|||||||
def _add_to_pending_queue(
|
def _add_to_pending_queue(
|
||||||
self, item: DownloadItem, front: bool = False
|
self, item: DownloadItem, front: bool = False
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add item to pending queue and update helper dict.
|
"""Add item to pending queue and update helper dicts.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
item: Download item to add
|
item: Download item to add
|
||||||
@@ -420,9 +549,12 @@ class DownloadService:
|
|||||||
else:
|
else:
|
||||||
self._pending_queue.append(item)
|
self._pending_queue.append(item)
|
||||||
self._pending_items_by_id[item.id] = item
|
self._pending_items_by_id[item.id] = item
|
||||||
|
# Track by episode identity for deduplication
|
||||||
|
ep_key = (item.serie_id, item.episode.season, item.episode.episode)
|
||||||
|
self._pending_by_episode[ep_key] = item.id
|
||||||
|
|
||||||
def _remove_from_pending_queue(self, item_or_id: str) -> Optional[DownloadItem]: # noqa: E501
|
def _remove_from_pending_queue(self, item_or_id: str) -> Optional[DownloadItem]: # noqa: E501
|
||||||
"""Remove item from pending queue and update helper dict.
|
"""Remove item from pending queue and update helper dicts.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
item_or_id: Item ID to remove
|
item_or_id: Item ID to remove
|
||||||
@@ -442,6 +574,10 @@ class DownloadService:
|
|||||||
try:
|
try:
|
||||||
self._pending_queue.remove(item)
|
self._pending_queue.remove(item)
|
||||||
del self._pending_items_by_id[item_id]
|
del self._pending_items_by_id[item_id]
|
||||||
|
# Clean up episode tracking
|
||||||
|
ep_key = (item.serie_id, item.episode.season, item.episode.episode)
|
||||||
|
if self._pending_by_episode.get(ep_key) == item_id:
|
||||||
|
del self._pending_by_episode[ep_key]
|
||||||
return item
|
return item
|
||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
return None
|
return None
|
||||||
@@ -481,10 +617,35 @@ class DownloadService:
|
|||||||
# Initialize queue progress tracking if not already done
|
# Initialize queue progress tracking if not already done
|
||||||
await self._init_queue_progress()
|
await self._init_queue_progress()
|
||||||
|
|
||||||
|
# Filter out episodes already in pending queue
|
||||||
|
episodes_to_add = []
|
||||||
|
skipped_count = 0
|
||||||
|
seen_in_batch: set = set() # Track duplicates within this batch
|
||||||
|
for ep in episodes:
|
||||||
|
ep_key = (serie_id, ep.season, ep.episode)
|
||||||
|
if ep_key in self._pending_by_episode or ep_key in seen_in_batch:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping duplicate episode in queue",
|
||||||
|
serie_key=serie_id,
|
||||||
|
season=ep.season,
|
||||||
|
episode=ep.episode,
|
||||||
|
)
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
seen_in_batch.add(ep_key)
|
||||||
|
episodes_to_add.append(ep)
|
||||||
|
|
||||||
|
if skipped_count > 0:
|
||||||
|
logger.info(
|
||||||
|
"Skipped %d duplicate episodes in queue",
|
||||||
|
skipped_count,
|
||||||
|
serie_key=serie_id,
|
||||||
|
)
|
||||||
|
|
||||||
created_ids = []
|
created_ids = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for episode in episodes:
|
for episode in episodes_to_add:
|
||||||
item = DownloadItem(
|
item = DownloadItem(
|
||||||
id=self._generate_item_id(),
|
id=self._generate_item_id(),
|
||||||
serie_id=serie_id,
|
serie_id=serie_id,
|
||||||
@@ -976,17 +1137,16 @@ class DownloadService:
|
|||||||
if item.retry_count >= self._max_retries:
|
if item.retry_count >= self._max_retries:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Move back to pending
|
# Move back to pending (retry_count will be incremented
|
||||||
|
# by _process_download when the item fails again)
|
||||||
self._failed_items.remove(item)
|
self._failed_items.remove(item)
|
||||||
item.status = DownloadStatus.PENDING
|
item.status = DownloadStatus.PENDING
|
||||||
item.retry_count += 1
|
|
||||||
item.error = None
|
item.error = None
|
||||||
item.progress = None
|
item.progress = None
|
||||||
|
item.retry_count += 1
|
||||||
self._add_to_pending_queue(item)
|
self._add_to_pending_queue(item)
|
||||||
retried_ids.append(item.id)
|
retried_ids.append(item.id)
|
||||||
|
|
||||||
# Status is now managed in-memory only
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Retrying failed item: item_id=%s, retry_count=%d",
|
"Retrying failed item: item_id=%s, retry_count=%d",
|
||||||
item.id,
|
item.id,
|
||||||
@@ -994,7 +1154,8 @@ class DownloadService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if retried_ids:
|
if retried_ids:
|
||||||
# Notify via progress service
|
# Notify via progress service if available
|
||||||
|
try:
|
||||||
queue_status = await self.get_queue_status()
|
queue_status = await self.get_queue_status()
|
||||||
await self._progress_service.update_progress(
|
await self._progress_service.update_progress(
|
||||||
progress_id="download_queue",
|
progress_id="download_queue",
|
||||||
@@ -1006,6 +1167,10 @@ class DownloadService:
|
|||||||
},
|
},
|
||||||
force_broadcast=True,
|
force_broadcast=True,
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to broadcast retry progress: %s", e
|
||||||
|
)
|
||||||
|
|
||||||
return retried_ids
|
return retried_ids
|
||||||
|
|
||||||
@@ -1084,12 +1249,13 @@ class DownloadService:
|
|||||||
# Delete completed item from download queue database
|
# Delete completed item from download queue database
|
||||||
await self._delete_from_database(item.id)
|
await self._delete_from_database(item.id)
|
||||||
|
|
||||||
# Remove episode from missing episodes list
|
# Mark episode as downloaded in missing episodes list
|
||||||
# (both database and in-memory)
|
# (both database and in-memory)
|
||||||
removed = await self._remove_episode_from_missing_list(
|
removed = await self._remove_episode_from_missing_list(
|
||||||
series_key=item.serie_id,
|
series_key=item.serie_id,
|
||||||
season=item.episode.season,
|
season=item.episode.season,
|
||||||
episode=item.episode.episode,
|
episode=item.episode.episode,
|
||||||
|
serie_folder=item.serie_folder,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1144,11 +1310,29 @@ class DownloadService:
|
|||||||
item.status = DownloadStatus.FAILED
|
item.status = DownloadStatus.FAILED
|
||||||
item.completed_at = datetime.now(timezone.utc)
|
item.completed_at = datetime.now(timezone.utc)
|
||||||
item.error = str(e)
|
item.error = str(e)
|
||||||
|
|
||||||
|
# Increment retry count in memory and database
|
||||||
|
item.retry_count += 1
|
||||||
|
await self._increment_retry_in_database(item.id)
|
||||||
|
|
||||||
self._failed_items.append(item)
|
self._failed_items.append(item)
|
||||||
|
|
||||||
# Set error in database
|
# Set error in database
|
||||||
await self._set_error_in_database(item.id, str(e))
|
await self._set_error_in_database(item.id, str(e))
|
||||||
|
|
||||||
|
# Check if max retries exceeded - move to dead-letter
|
||||||
|
if item.retry_count >= self._max_retries:
|
||||||
|
await self._set_status_in_database(
|
||||||
|
item.id, DownloadStatus.PERMANENTLY_FAILED.value
|
||||||
|
)
|
||||||
|
logger.error(
|
||||||
|
"Download permanently failed after max retries: "
|
||||||
|
"item_id=%s, error=%s, retry_count=%d",
|
||||||
|
item.id,
|
||||||
|
str(e),
|
||||||
|
item.retry_count,
|
||||||
|
)
|
||||||
|
else:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Download failed: item_id=%s, error=%s, retry_count=%d",
|
"Download failed: item_id=%s, error=%s, retry_count=%d",
|
||||||
item.id,
|
item.id,
|
||||||
|
|||||||
710
src/server/services/folder_rename_service.py
Normal file
710
src/server/services/folder_rename_service.py
Normal file
@@ -0,0 +1,710 @@
|
|||||||
|
"""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 collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Set, 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'
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateGroup:
|
||||||
|
"""Represents a group of duplicate folders for the same series.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
key: The series key (folder name before rename).
|
||||||
|
folders: List of folder paths that map to this series.
|
||||||
|
nfo_paths: List of corresponding NFO file paths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, key: str, folders: List[str], nfo_paths: List[Path]):
|
||||||
|
self.key = key
|
||||||
|
self.folders = folders
|
||||||
|
self.nfo_paths = nfo_paths
|
||||||
|
|
||||||
|
@property
|
||||||
|
def count(self) -> int:
|
||||||
|
return len(self.folders)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"DuplicateGroup(key={self.key!r}, folders={self.folders})"
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_for_pre_existing_duplicates(anime_dir: Path) -> List[DuplicateGroup]:
|
||||||
|
"""Scan anime directory for pre-existing duplicate folders.
|
||||||
|
|
||||||
|
Groups folders by the series key extracted from their NFO files.
|
||||||
|
Folders with the same title+year (same expected name) are flagged as duplicates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_dir: Path to the anime directory to scan.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of DuplicateGroup objects, one per series with duplicate folders.
|
||||||
|
"""
|
||||||
|
# Group folders by their expected name (title+year from NFO)
|
||||||
|
groups: Dict[str, List[Tuple[str, Path]]] = defaultdict(list)
|
||||||
|
|
||||||
|
for series_dir in anime_dir.iterdir():
|
||||||
|
if not series_dir.is_dir():
|
||||||
|
continue
|
||||||
|
nfo_path = series_dir / "tvshow.nfo"
|
||||||
|
if not nfo_path.exists():
|
||||||
|
continue
|
||||||
|
title, year = _parse_nfo_title_and_year(nfo_path)
|
||||||
|
if not title or not year:
|
||||||
|
continue
|
||||||
|
expected_name = _compute_expected_folder_name(title, year)
|
||||||
|
groups[expected_name].append((series_dir.name, nfo_path))
|
||||||
|
|
||||||
|
# Filter to only groups with more than one folder
|
||||||
|
duplicates = []
|
||||||
|
for key, items in groups.items():
|
||||||
|
if len(items) > 1:
|
||||||
|
folders = [item[0] for item in items]
|
||||||
|
nfo_paths = [item[1] for item in items]
|
||||||
|
duplicates.append(DuplicateGroup(key=key, folders=folders, nfo_paths=nfo_paths))
|
||||||
|
|
||||||
|
return duplicates
|
||||||
|
|
||||||
|
|
||||||
|
def _try_merge_duplicate_group(group: DuplicateGroup, dry_run: bool = False) -> bool:
|
||||||
|
"""Attempt to merge a duplicate group automatically.
|
||||||
|
|
||||||
|
Uses the first folder as the canonical one and removes others if they are
|
||||||
|
empty or contain only symlinks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group: The DuplicateGroup to merge.
|
||||||
|
dry_run: If True, only log actions without executing them.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if merge was successful, False otherwise.
|
||||||
|
"""
|
||||||
|
if len(group.folders) < 2:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Keep first folder as canonical, mark others for removal
|
||||||
|
canonical = group.folders[0]
|
||||||
|
to_remove = group.folders[1:]
|
||||||
|
|
||||||
|
for folder in to_remove:
|
||||||
|
folder_path = group.nfo_paths[0].parent.parent / folder # same parent dir
|
||||||
|
if not folder_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if folder is empty or only has symlinks
|
||||||
|
try:
|
||||||
|
contents = list(folder_path.iterdir())
|
||||||
|
except PermissionError:
|
||||||
|
logger.warning("Permission denied accessing %s, skip merge", folder_path)
|
||||||
|
return False
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not contents:
|
||||||
|
# Empty folder - safe to remove
|
||||||
|
if dry_run:
|
||||||
|
logger.info("[DRY-RUN] Would delete empty duplicate folder: %s", folder_path)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
folder_path.rmdir()
|
||||||
|
logger.info("Deleted empty duplicate folder: %s", folder_path)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if all contents are symlinks pointing to canonical
|
||||||
|
all_symlinks = all(
|
||||||
|
item.is_symlink() and item.resolve() == (folder_path.parent / canonical).resolve()
|
||||||
|
for item in contents
|
||||||
|
)
|
||||||
|
if all_symlinks:
|
||||||
|
if dry_run:
|
||||||
|
logger.info("[DRY-RUN] Would remove symlinks in duplicate folder: %s", folder_path)
|
||||||
|
else:
|
||||||
|
for item in contents:
|
||||||
|
item.unlink()
|
||||||
|
try:
|
||||||
|
folder_path.rmdir()
|
||||||
|
logger.info("Removed symlink-only duplicate folder: %s", folder_path)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Cannot auto-merge - requires manual intervention
|
||||||
|
logger.warning(
|
||||||
|
"Cannot auto-merge duplicate folders for '%s': %s (manual merge required)",
|
||||||
|
group.key,
|
||||||
|
[canonical] + to_remove,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_stale_files_after_rename(new_path: Path, new_name: str) -> None:
|
||||||
|
"""Remove legacy 'key' file after successful folder rename.
|
||||||
|
|
||||||
|
Also checks for orphaned folders with the same key that may have been
|
||||||
|
left behind from previous rename operations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
new_path: The new folder path after rename.
|
||||||
|
new_name: The new folder name.
|
||||||
|
"""
|
||||||
|
key_file = new_path / "key"
|
||||||
|
if key_file.exists():
|
||||||
|
try:
|
||||||
|
key_file.unlink()
|
||||||
|
logger.info(
|
||||||
|
"Removed legacy 'key' file after rename: %s", key_file
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Could not remove legacy 'key' file %s: %s", key_file, exc
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_orphaned_folder(old_path: Path, new_path: Path, dry_run: bool = False) -> bool:
|
||||||
|
"""Clean up orphaned folder after successful rename.
|
||||||
|
|
||||||
|
After a folder is successfully renamed to new_path, this function checks
|
||||||
|
if the old_path still exists (orphaned folder) and removes it. If the
|
||||||
|
old folder contains files, they are moved to new_path before deletion.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_path: The original folder path before rename.
|
||||||
|
new_path: The new folder path after rename.
|
||||||
|
dry_run: If True, only log actions without executing them.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if old folder was cleaned up (or would be in dry-run mode),
|
||||||
|
False if old folder does not exist or cleanup failed.
|
||||||
|
"""
|
||||||
|
if not old_path.exists():
|
||||||
|
logger.debug(
|
||||||
|
"Old folder does not exist, no cleanup needed: %s", old_path
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if folder is empty
|
||||||
|
try:
|
||||||
|
contents = list(old_path.iterdir())
|
||||||
|
except PermissionError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Permission denied accessing old folder %s: %s", old_path, exc
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"OS error accessing old folder %s: %s", old_path, exc
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not contents:
|
||||||
|
# Empty folder — delete it
|
||||||
|
if dry_run:
|
||||||
|
logger.info(
|
||||||
|
"[DRY-RUN] Would delete empty orphaned folder: %s", old_path
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
old_path.rmdir()
|
||||||
|
logger.info("Deleted empty orphaned folder: %s", old_path)
|
||||||
|
return True
|
||||||
|
except PermissionError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Permission denied deleting folder %s: %s", old_path, exc
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"OS error deleting folder %s: %s", old_path, exc
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Folder has contents — move files to new_path then delete
|
||||||
|
if dry_run:
|
||||||
|
logger.info(
|
||||||
|
"[DRY-RUN] Would move %d files from orphaned folder %s to %s",
|
||||||
|
len(contents), old_path, new_path
|
||||||
|
)
|
||||||
|
for item in contents:
|
||||||
|
logger.info("[DRY-RUN] Would move: %s → %s", item, new_path / item.name)
|
||||||
|
logger.info("[DRY-RUN] Would then delete orphaned folder: %s", old_path)
|
||||||
|
return True
|
||||||
|
|
||||||
|
files_moved = 0
|
||||||
|
errors = 0
|
||||||
|
for item in contents:
|
||||||
|
try:
|
||||||
|
dest = new_path / item.name
|
||||||
|
item.rename(dest)
|
||||||
|
logger.debug("Moved %s → %s", item, dest)
|
||||||
|
files_moved += 1
|
||||||
|
except PermissionError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Permission denied moving %s: %s", item, exc
|
||||||
|
)
|
||||||
|
errors += 1
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"OS error moving %s: %s", item, exc
|
||||||
|
)
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
if files_moved > 0:
|
||||||
|
logger.info(
|
||||||
|
"Moved %d files from orphaned folder to %s",
|
||||||
|
files_moved, new_path
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete the now-empty old folder
|
||||||
|
try:
|
||||||
|
old_path.rmdir()
|
||||||
|
logger.info("Deleted orphaned folder after moving contents: %s", old_path)
|
||||||
|
return errors == 0
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Could not delete orphaned folder %s (may not be empty): %s",
|
||||||
|
old_path, exc
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
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(dry_run: bool = False) -> 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.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dry_run: If True, simulate rename operations without actually
|
||||||
|
moving folders or updating the database.
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
logger.info("Running in DRY-RUN mode — no changes will be made")
|
||||||
|
|
||||||
|
stats = {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}
|
||||||
|
|
||||||
|
# Detect pre-existing duplicates before rename loop
|
||||||
|
pre_existing_duplicates: Set[str] = set()
|
||||||
|
duplicates = _scan_for_pre_existing_duplicates(anime_dir)
|
||||||
|
for dup_group in duplicates:
|
||||||
|
# Try automatic merge first
|
||||||
|
if _try_merge_duplicate_group(dup_group, dry_run=dry_run):
|
||||||
|
logger.info(
|
||||||
|
"Auto-merged duplicate group for '%s' (%d folders)",
|
||||||
|
dup_group.key,
|
||||||
|
dup_group.count,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Flag all folders in this group as pre-existing duplicates
|
||||||
|
for folder in dup_group.folders:
|
||||||
|
pre_existing_duplicates.add(folder)
|
||||||
|
logger.warning(
|
||||||
|
"Duplicate folders detected for series '%s': %s — "
|
||||||
|
"manual cleanup required (different releases or non-empty duplicates)",
|
||||||
|
dup_group.key,
|
||||||
|
dup_group.folders,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 pre-existing duplicate
|
||||||
|
if current_name in pre_existing_duplicates:
|
||||||
|
logger.warning(
|
||||||
|
"Skipping rename for '%s' — pre-existing duplicate folder detected",
|
||||||
|
current_name,
|
||||||
|
)
|
||||||
|
stats["errors"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for duplicate target
|
||||||
|
if expected_path.exists():
|
||||||
|
logger.warning(
|
||||||
|
"Cannot rename '%s' → '%s' — target already exists",
|
||||||
|
current_name,
|
||||||
|
expected_name,
|
||||||
|
)
|
||||||
|
# Target folder exists — remove source folder and delete its DB record
|
||||||
|
# (target folder's DB record survives, source folder's record must be removed
|
||||||
|
# to avoid orphaning episodes/downloads)
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Removing source duplicate folder '%s' — target '%s' already exists",
|
||||||
|
current_name,
|
||||||
|
expected_name,
|
||||||
|
)
|
||||||
|
shutil.rmtree(series_dir)
|
||||||
|
logger.info(
|
||||||
|
"Removed source folder '%s' — series already exists at target",
|
||||||
|
current_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete source DB record (cascades to episodes and download items)
|
||||||
|
async with get_db_session() as db:
|
||||||
|
source_series = await AnimeSeriesService.get_by_key(db, current_name)
|
||||||
|
if source_series is None:
|
||||||
|
# Fallback: find by folder name
|
||||||
|
all_series = await AnimeSeriesService.get_all(db)
|
||||||
|
for s in all_series:
|
||||||
|
if s.folder == current_name:
|
||||||
|
source_series = s
|
||||||
|
break
|
||||||
|
if source_series is not None:
|
||||||
|
await AnimeSeriesService.delete(db, source_series.id)
|
||||||
|
logger.info(
|
||||||
|
"Deleted source DB record for '%s' (id=%s) — target folder '%s' retains DB record",
|
||||||
|
current_name,
|
||||||
|
source_series.id,
|
||||||
|
expected_name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"No DB record found for source folder '%s' — folder removed only",
|
||||||
|
current_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
stats["renamed"] += 1
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to remove source folder '%s': %s",
|
||||||
|
current_name,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
logger.info(
|
||||||
|
"[DRY-RUN] Would rename folder: '%s' → '%s'",
|
||||||
|
current_name,
|
||||||
|
expected_name,
|
||||||
|
)
|
||||||
|
stats["renamed"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
old_path = series_dir
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Clean up stale/legacy files after successful rename
|
||||||
|
_cleanup_stale_files_after_rename(expected_path, expected_name)
|
||||||
|
|
||||||
|
# Clean up orphaned folder if old path still exists
|
||||||
|
_cleanup_orphaned_folder(old_path, expected_path, dry_run=False)
|
||||||
|
|
||||||
|
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
|
||||||
428
src/server/services/folder_scan_service.py
Normal file
428
src/server/services/folder_scan_service.py
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
"""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 _create_missing_nfo(series_dir: Path, series_name: str) -> None:
|
||||||
|
"""Create minimal NFO for series without one.
|
||||||
|
|
||||||
|
Creates a fresh :class:`NFOService` per invocation so concurrent
|
||||||
|
tasks cannot interfere with each other.
|
||||||
|
|
||||||
|
A module-level semaphore limits concurrent TMDB operations to 3.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
async with _NFO_REPAIR_SEMAPHORE:
|
||||||
|
try:
|
||||||
|
factory = NFOServiceFactory()
|
||||||
|
nfo_service = factory.create()
|
||||||
|
await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name=series_name,
|
||||||
|
serie_folder=series_dir.name,
|
||||||
|
)
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
logger.error(
|
||||||
|
"NFO creation failed for %s: %s",
|
||||||
|
series_name,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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, repair incomplete and create missing 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``:
|
||||||
|
- Missing NFOs: creates minimal NFO via ``_create_missing_nfo``
|
||||||
|
- Incomplete NFOs: repairs via ``_repair_one_series``
|
||||||
|
|
||||||
|
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
|
||||||
|
missing_nfo_count = 0
|
||||||
|
repair_tasks: list[asyncio.Task] = []
|
||||||
|
for series_dir in sorted(anime_dir.iterdir()):
|
||||||
|
if not series_dir.is_dir():
|
||||||
|
continue
|
||||||
|
nfo_path = series_dir / "tvshow.nfo"
|
||||||
|
series_name = series_dir.name
|
||||||
|
if not nfo_path.exists():
|
||||||
|
# Create minimal NFO for series without one
|
||||||
|
missing_nfo_count += 1
|
||||||
|
repair_tasks.append(
|
||||||
|
asyncio.create_task(
|
||||||
|
_create_missing_nfo(series_dir, series_name),
|
||||||
|
name=f"nfo_create:{series_name}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
total += 1
|
||||||
|
if nfo_needs_repair(nfo_path):
|
||||||
|
queued += 1
|
||||||
|
repair_tasks.append(
|
||||||
|
asyncio.create_task(
|
||||||
|
_repair_one_series(series_dir, series_name),
|
||||||
|
name=f"nfo_repair:{series_name}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if repair_tasks:
|
||||||
|
logger.info(
|
||||||
|
"NFO repair scan: waiting for %d repair/create tasks to complete",
|
||||||
|
len(repair_tasks),
|
||||||
|
)
|
||||||
|
await asyncio.gather(*repair_tasks, return_exceptions=True)
|
||||||
|
logger.info("NFO repair scan tasks completed")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"NFO repair scan complete: %d of %d series queued for repair, %d missing NFOs queued for creation",
|
||||||
|
queued,
|
||||||
|
total,
|
||||||
|
missing_nfo_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 (synchronous, waits for completion).
|
||||||
|
logger.info("Starting NFO repair scan as part of folder scan")
|
||||||
|
await perform_nfo_repair_scan(background_loader=None)
|
||||||
|
logger.info("NFO repair scan complete")
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
"""Centralized initialization service for application startup and setup."""
|
"""Centralized initialization service for application startup and setup."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
from src.server.services.anime_service import sync_series_from_data_files
|
from src.server.services.anime_service import sync_legacy_series_to_db
|
||||||
|
from src.server.services.legacy_file_migration import migrate_series_from_files_to_db
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
@@ -99,6 +101,151 @@ async def _mark_initial_scan_completed() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_legacy_migration_status() -> bool:
|
||||||
|
"""Check if legacy key/data file migration has been completed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if migration was completed, False otherwise
|
||||||
|
"""
|
||||||
|
return await _check_scan_status(
|
||||||
|
check_method=lambda svc, db: svc.is_migration_legacy_files_completed(db),
|
||||||
|
scan_type="legacy_migration",
|
||||||
|
log_completed_msg="Legacy file migration already completed, skipping",
|
||||||
|
log_not_completed_msg="Legacy file migration not yet run, will check for files"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _mark_legacy_migration_completed() -> None:
|
||||||
|
"""Mark the legacy file migration as completed in system settings."""
|
||||||
|
await _mark_scan_completed(
|
||||||
|
mark_method=lambda svc, db: svc.mark_migration_legacy_files_completed(db),
|
||||||
|
scan_type="legacy_migration"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_legacy_key_cleanup_status() -> bool:
|
||||||
|
"""Check if legacy key file cleanup has been completed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if cleanup was completed, False otherwise
|
||||||
|
"""
|
||||||
|
return await _check_scan_status(
|
||||||
|
check_method=lambda svc, db: svc.is_legacy_key_cleanup_completed(db),
|
||||||
|
scan_type="legacy_key_cleanup",
|
||||||
|
log_completed_msg="Legacy key file cleanup already completed, skipping",
|
||||||
|
log_not_completed_msg="Legacy key file cleanup not yet run, will clean up key files"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _mark_legacy_key_cleanup_completed() -> None:
|
||||||
|
"""Mark the legacy key file cleanup as completed in system settings."""
|
||||||
|
await _mark_scan_completed(
|
||||||
|
mark_method=lambda svc, db: svc.mark_legacy_key_cleanup_completed(db),
|
||||||
|
scan_type="legacy_key_cleanup"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_legacy_files() -> int:
|
||||||
|
"""Migrate series from legacy key/data files to database.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Number of series migrated
|
||||||
|
"""
|
||||||
|
from src.server.database.connection import get_db_session
|
||||||
|
|
||||||
|
logger.info("Checking for legacy key/data files to migrate...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with get_db_session() as db:
|
||||||
|
migrated_count = await migrate_series_from_files_to_db(
|
||||||
|
settings.anime_directory,
|
||||||
|
db
|
||||||
|
)
|
||||||
|
|
||||||
|
if migrated_count > 0:
|
||||||
|
logger.info("Migrated %d series from legacy files", migrated_count)
|
||||||
|
else:
|
||||||
|
logger.info("No series found in legacy files to migrate")
|
||||||
|
|
||||||
|
return migrated_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to migrate legacy files: %s", e)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
async def _cleanup_legacy_key_files() -> int:
|
||||||
|
"""Remove legacy key files from folders that already have DB entries.
|
||||||
|
|
||||||
|
This is a one-time cleanup task that runs at startup after legacy migration.
|
||||||
|
It removes deprecated 'key' files that cause duplicate key errors when
|
||||||
|
folders are renamed, since the DB is now the source of truth.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Number of key files deleted
|
||||||
|
"""
|
||||||
|
from src.server.database.connection import get_db_session
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
logger.info("Checking for legacy key files to clean up...")
|
||||||
|
|
||||||
|
if not settings.anime_directory or not os.path.isdir(settings.anime_directory):
|
||||||
|
logger.warning(
|
||||||
|
"Anime directory not configured or does not exist, skipping legacy key cleanup"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
deleted_count = 0
|
||||||
|
scanned_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with get_db_session() as db:
|
||||||
|
# Get all series from DB to know which folders should have key files removed
|
||||||
|
all_series = await AnimeSeriesService.get_all(db)
|
||||||
|
|
||||||
|
# Build a set of known folder names from DB
|
||||||
|
db_folders: set[str] = {series.folder for series in all_series if series.folder}
|
||||||
|
|
||||||
|
for folder_name in db_folders:
|
||||||
|
folder_path = settings.anime_directory / folder_name
|
||||||
|
key_file = folder_path / "key"
|
||||||
|
|
||||||
|
if not key_file.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
scanned_count += 1
|
||||||
|
try:
|
||||||
|
key_file.unlink()
|
||||||
|
deleted_count += 1
|
||||||
|
logger.info(
|
||||||
|
"Removed legacy key file",
|
||||||
|
folder=folder_name,
|
||||||
|
key_file=str(key_file)
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Could not remove legacy key file",
|
||||||
|
folder=folder_name,
|
||||||
|
key_file=str(key_file),
|
||||||
|
error=str(exc)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Legacy key file cleanup failed",
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Legacy key file cleanup complete",
|
||||||
|
scanned=scanned_count,
|
||||||
|
deleted=deleted_count
|
||||||
|
)
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
|
||||||
async def _sync_anime_folders(progress_service=None) -> int:
|
async def _sync_anime_folders(progress_service=None) -> int:
|
||||||
"""Scan anime folders and sync series to database.
|
"""Scan anime folders and sync series to database.
|
||||||
|
|
||||||
@@ -118,7 +265,7 @@ async def _sync_anime_folders(progress_service=None) -> int:
|
|||||||
metadata={"step_id": "series_sync"}
|
metadata={"step_id": "series_sync"}
|
||||||
)
|
)
|
||||||
|
|
||||||
sync_count = await sync_series_from_data_files(settings.anime_directory)
|
sync_count = await sync_legacy_series_to_db(settings.anime_directory)
|
||||||
logger.info("Data file sync complete. Added %d series.", sync_count)
|
logger.info("Data file sync complete. Added %d series.", sync_count)
|
||||||
|
|
||||||
if progress_service:
|
if progress_service:
|
||||||
@@ -184,11 +331,12 @@ async def perform_initial_setup(progress_service=None):
|
|||||||
|
|
||||||
This function is called both during application lifespan startup
|
This function is called both during application lifespan startup
|
||||||
and when the setup endpoint is completed. It ensures that:
|
and when the setup endpoint is completed. It ensures that:
|
||||||
1. Series are synced from data files to database
|
1. Legacy key/data files are migrated to database (one-time)
|
||||||
2. Initial scan is marked as completed
|
2. Series are synced from data files to database
|
||||||
3. Series are loaded into memory
|
3. Initial scan is marked as completed
|
||||||
4. NFO scan is performed if configured
|
4. Series are loaded into memory
|
||||||
5. Media scan is performed
|
5. NFO scan is performed if configured
|
||||||
|
6. Media scan is performed
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
progress_service: Optional ProgressService for emitting updates
|
progress_service: Optional ProgressService for emitting updates
|
||||||
@@ -225,9 +373,22 @@ async def perform_initial_setup(progress_service=None):
|
|||||||
|
|
||||||
# Perform the actual initialization
|
# Perform the actual initialization
|
||||||
try:
|
try:
|
||||||
|
# First, run legacy file migration if needed (independent of initial scan)
|
||||||
|
is_legacy_migration_done = await _check_legacy_migration_status()
|
||||||
|
if not is_legacy_migration_done:
|
||||||
|
await _migrate_legacy_files()
|
||||||
|
await _mark_legacy_migration_completed()
|
||||||
|
|
||||||
# Sync series from anime folders to database
|
# Sync series from anime folders to database
|
||||||
await _sync_anime_folders(progress_service)
|
await _sync_anime_folders(progress_service)
|
||||||
|
|
||||||
|
# Clean up legacy key files from folders that now have DB entries
|
||||||
|
# This runs after migration/sync to ensure DB entries exist before deletion
|
||||||
|
is_key_cleanup_done = await _check_legacy_key_cleanup_status()
|
||||||
|
if not is_key_cleanup_done:
|
||||||
|
await _cleanup_legacy_key_files()
|
||||||
|
await _mark_legacy_key_cleanup_completed()
|
||||||
|
|
||||||
# Mark the initial scan as completed
|
# Mark the initial scan as completed
|
||||||
await _mark_initial_scan_completed()
|
await _mark_initial_scan_completed()
|
||||||
|
|
||||||
@@ -377,101 +538,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.
|
||||||
|
|
||||||
|
|||||||
317
src/server/services/key_resolution_service.py
Normal file
317
src/server/services/key_resolution_service.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
"""Key resolution service for orphaned anime folders.
|
||||||
|
|
||||||
|
Attempts to resolve provider keys for anime folders that have no key/data
|
||||||
|
file and no database entry, by searching the anime provider and matching
|
||||||
|
folder names to search results.
|
||||||
|
|
||||||
|
This service runs after nfo_repair_service during the daily folder scan.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from src.config.settings import settings as _settings
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
# Limit concurrent provider searches to avoid rate-limiting.
|
||||||
|
_SEARCH_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(2)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_year_from_folder(folder_name: str) -> str:
|
||||||
|
"""Remove trailing year suffix like ' (2020)' from folder name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_name: Folder name, e.g. 'Rent-A-Girlfriend (2020)'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Name without year, e.g. 'Rent-A-Girlfriend'
|
||||||
|
"""
|
||||||
|
return re.sub(r"\s*\(\d{4}\)\s*$", "", folder_name).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_year_from_folder(folder_name: str) -> Optional[int]:
|
||||||
|
"""Extract year from folder name like 'Anime Name (2020)'.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Year as int or None if not present.
|
||||||
|
"""
|
||||||
|
match = re.search(r"\((\d{4})\)$", folder_name.strip())
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_key_from_link(link: str) -> Optional[str]:
|
||||||
|
"""Extract provider key from search result link.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
link: Link like '/anime/stream/rent-a-girlfriend' or full URL.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Key slug like 'rent-a-girlfriend' or None.
|
||||||
|
"""
|
||||||
|
if not link:
|
||||||
|
return None
|
||||||
|
if "/anime/stream/" in link:
|
||||||
|
parts = link.split("/anime/stream/")[-1].split("/")
|
||||||
|
key = parts[0].strip()
|
||||||
|
return key if key else None
|
||||||
|
# If link is just a slug
|
||||||
|
if "/" not in link and link.strip():
|
||||||
|
return link.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_for_comparison(text: str) -> str:
|
||||||
|
"""Normalize text for case-insensitive comparison.
|
||||||
|
|
||||||
|
Strips whitespace, lowercases, and removes common punctuation
|
||||||
|
differences that shouldn't affect matching.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Raw text string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Normalized lowercase string.
|
||||||
|
"""
|
||||||
|
normalized = text.strip().lower()
|
||||||
|
# Remove common punctuation that varies between sources
|
||||||
|
normalized = re.sub(r"[:\-–—]", " ", normalized)
|
||||||
|
# Collapse multiple spaces
|
||||||
|
normalized = re.sub(r"\s+", " ", normalized)
|
||||||
|
return normalized.strip()
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_key_for_folder(folder_name: str) -> Optional[str]:
|
||||||
|
"""Attempt to resolve the provider key for a single folder.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1. Strip year suffix from folder name to get search query.
|
||||||
|
2. Search the anime provider with that query.
|
||||||
|
3. If exactly ONE result matches the folder name (case-insensitive),
|
||||||
|
return the key extracted from the result link.
|
||||||
|
4. If zero or multiple matches, return None (not confident enough).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_name: The anime folder name, e.g. 'Rent-A-Girlfriend (2020)'.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The provider key string, or None if resolution is not confident.
|
||||||
|
"""
|
||||||
|
search_query = _strip_year_from_folder(folder_name)
|
||||||
|
if not search_query:
|
||||||
|
logger.debug("Empty search query after stripping year from '%s'", folder_name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async with _SEARCH_SEMAPHORE:
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
results = await loop.run_in_executor(None, _search_provider, search_query)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Provider search failed for '%s': %s", search_query, exc
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
logger.debug("No search results for folder '%s'", folder_name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Filter results: find exact name matches (case-insensitive)
|
||||||
|
normalized_query = _normalize_for_comparison(search_query)
|
||||||
|
exact_matches = []
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
title = result.get("title") or result.get("name") or ""
|
||||||
|
normalized_title = _normalize_for_comparison(title)
|
||||||
|
|
||||||
|
if normalized_title == normalized_query:
|
||||||
|
key = _extract_key_from_link(result.get("link", ""))
|
||||||
|
if key:
|
||||||
|
exact_matches.append((key, title))
|
||||||
|
|
||||||
|
if len(exact_matches) == 1:
|
||||||
|
resolved_key, matched_title = exact_matches[0]
|
||||||
|
logger.info(
|
||||||
|
"Resolved key for folder '%s': key='%s' (matched title: '%s')",
|
||||||
|
folder_name,
|
||||||
|
resolved_key,
|
||||||
|
matched_title,
|
||||||
|
)
|
||||||
|
return resolved_key
|
||||||
|
|
||||||
|
if len(exact_matches) > 1:
|
||||||
|
logger.info(
|
||||||
|
"Multiple exact matches for folder '%s' (%d matches), skipping",
|
||||||
|
folder_name,
|
||||||
|
len(exact_matches),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"No exact title match for folder '%s' in %d results",
|
||||||
|
folder_name,
|
||||||
|
len(results),
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _search_provider(query: str) -> list:
|
||||||
|
"""Call the anime provider search synchronously.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search term.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of search result dicts with 'link' and 'title'/'name' fields.
|
||||||
|
"""
|
||||||
|
from src.core.providers.provider_factory import Loaders
|
||||||
|
|
||||||
|
loader = Loaders().GetLoader("aniworld.to")
|
||||||
|
return loader.search(query)
|
||||||
|
|
||||||
|
|
||||||
|
async def perform_key_resolution_scan() -> dict[str, int]:
|
||||||
|
"""Scan all anime folders and resolve missing keys.
|
||||||
|
|
||||||
|
Iterates over all subfolders of the anime directory. For each folder
|
||||||
|
that has no corresponding database entry, attempts to resolve the
|
||||||
|
provider key via provider search and saves it to the database.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with counts:
|
||||||
|
- 'scanned': total folders checked
|
||||||
|
- 'resolved': keys successfully resolved and saved
|
||||||
|
- 'skipped': folders already in DB or resolution uncertain
|
||||||
|
- 'errors': folders that caused errors during resolution
|
||||||
|
"""
|
||||||
|
from src.server.database.connection import get_db_session
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
stats = {"scanned": 0, "resolved": 0, "skipped": 0, "errors": 0}
|
||||||
|
|
||||||
|
if not _settings.anime_directory:
|
||||||
|
logger.warning("Key resolution scan skipped — anime directory not configured")
|
||||||
|
return stats
|
||||||
|
|
||||||
|
anime_dir = Path(_settings.anime_directory)
|
||||||
|
if not anime_dir.is_dir():
|
||||||
|
logger.warning(
|
||||||
|
"Key resolution scan skipped — anime directory not found: %s",
|
||||||
|
anime_dir,
|
||||||
|
)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
# Collect folders that need resolution
|
||||||
|
folders_to_resolve: list[str] = []
|
||||||
|
|
||||||
|
async with get_db_session() as db:
|
||||||
|
for series_dir in sorted(anime_dir.iterdir()):
|
||||||
|
if not series_dir.is_dir():
|
||||||
|
continue
|
||||||
|
folder_name = series_dir.name
|
||||||
|
stats["scanned"] += 1
|
||||||
|
|
||||||
|
# Check if already in database
|
||||||
|
existing = await AnimeSeriesService.get_by_folder(db, folder_name)
|
||||||
|
if existing:
|
||||||
|
stats["skipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
folders_to_resolve.append(folder_name)
|
||||||
|
|
||||||
|
if not folders_to_resolve:
|
||||||
|
logger.info("Key resolution scan: all folders already have DB entries")
|
||||||
|
return stats
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Key resolution scan: %d folders need resolution", len(folders_to_resolve)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve keys one by one (provider search is rate-limited)
|
||||||
|
for folder_name in folders_to_resolve:
|
||||||
|
try:
|
||||||
|
key = await resolve_key_for_folder(folder_name)
|
||||||
|
if key:
|
||||||
|
# Save to database
|
||||||
|
await _save_resolved_key(folder_name, key)
|
||||||
|
stats["resolved"] += 1
|
||||||
|
else:
|
||||||
|
stats["skipped"] += 1
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Error resolving key for folder '%s': %s",
|
||||||
|
folder_name,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
stats["errors"] += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Key resolution scan complete: scanned=%d, resolved=%d, skipped=%d, errors=%d",
|
||||||
|
stats["scanned"],
|
||||||
|
stats["resolved"],
|
||||||
|
stats["skipped"],
|
||||||
|
stats["errors"],
|
||||||
|
)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_resolved_key(folder_name: str, key: str) -> None:
|
||||||
|
"""Save a resolved key to the database.
|
||||||
|
|
||||||
|
Creates a new AnimeSeries entry with the resolved key and folder name.
|
||||||
|
Does NOT write any key/data file to disk.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_name: The anime folder name (e.g. 'Rent-A-Girlfriend (2020)').
|
||||||
|
key: The resolved provider key (e.g. 'rent-a-girlfriend').
|
||||||
|
"""
|
||||||
|
from src.server.database.connection import get_db_session
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
name = _strip_year_from_folder(folder_name)
|
||||||
|
year = _extract_year_from_folder(folder_name)
|
||||||
|
|
||||||
|
async with get_db_session() as db:
|
||||||
|
# Double-check: another task might have resolved it concurrently
|
||||||
|
existing = await AnimeSeriesService.get_by_folder(db, folder_name)
|
||||||
|
if existing:
|
||||||
|
logger.debug(
|
||||||
|
"Folder '%s' already in DB (resolved concurrently), skipping",
|
||||||
|
folder_name,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Also check if a series with this key already exists
|
||||||
|
existing_key = await AnimeSeriesService.get_by_key(db, key)
|
||||||
|
if existing_key:
|
||||||
|
logger.warning(
|
||||||
|
"Key '%s' already exists in DB for folder '%s', "
|
||||||
|
"cannot assign to folder '%s'",
|
||||||
|
key,
|
||||||
|
existing_key.folder,
|
||||||
|
folder_name,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await AnimeSeriesService.create(
|
||||||
|
db,
|
||||||
|
key=key,
|
||||||
|
name=name,
|
||||||
|
site="aniworld.to",
|
||||||
|
folder=folder_name,
|
||||||
|
year=year,
|
||||||
|
loading_status="pending",
|
||||||
|
episodes_loaded=False,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Saved resolved key '%s' for folder '%s' to database",
|
||||||
|
key,
|
||||||
|
folder_name,
|
||||||
|
)
|
||||||
233
src/server/services/legacy_file_migration.py
Normal file
233
src/server/services/legacy_file_migration.py
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
"""One-time migration service for legacy key and data files.
|
||||||
|
|
||||||
|
This module provides functionality to migrate series data from legacy
|
||||||
|
file-based storage (key/data files) to the database. The migration is
|
||||||
|
designed to be idempotent and run only once per environment.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate_series_from_files_to_db(
|
||||||
|
anime_dir: str,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> int:
|
||||||
|
"""Migrate series from legacy key/data files to database.
|
||||||
|
|
||||||
|
Scans for folders containing legacy 'key' or 'data' files and imports
|
||||||
|
any series not already in the database. The DB version wins if a series
|
||||||
|
exists in both places.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_dir: Path to the anime directory
|
||||||
|
db: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of series imported
|
||||||
|
"""
|
||||||
|
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||||
|
|
||||||
|
if not anime_dir or not os.path.isdir(anime_dir):
|
||||||
|
logger.warning(
|
||||||
|
"Anime directory does not exist, skipping legacy migration",
|
||||||
|
anime_dir=anime_dir
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
migrated_count = 0
|
||||||
|
scanned_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
for folder_name in os.listdir(anime_dir):
|
||||||
|
folder_path = os.path.join(anime_dir, folder_name)
|
||||||
|
|
||||||
|
if not os.path.isdir(folder_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
scanned_count += 1
|
||||||
|
|
||||||
|
# Check for 'key' file (single line with series key)
|
||||||
|
key_file = os.path.join(folder_path, "key")
|
||||||
|
# Check for 'data' file (JSON with series metadata)
|
||||||
|
data_file = os.path.join(folder_path, "data")
|
||||||
|
|
||||||
|
series_data: Optional[dict] = None
|
||||||
|
|
||||||
|
# Try to load from 'data' file first (more complete)
|
||||||
|
if os.path.isfile(data_file):
|
||||||
|
series_data = _load_data_file(data_file)
|
||||||
|
elif os.path.isfile(key_file):
|
||||||
|
# Fall back to 'key' file - just the key, need to infer other data
|
||||||
|
series_data = _load_key_file(key_file, folder_name)
|
||||||
|
|
||||||
|
if series_data is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = series_data.get("key")
|
||||||
|
if not key:
|
||||||
|
logger.warning(
|
||||||
|
"Skipping folder with no valid key",
|
||||||
|
folder=folder_name
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if already in DB
|
||||||
|
existing = await AnimeSeriesService.get_by_key(db, key)
|
||||||
|
if existing:
|
||||||
|
logger.debug(
|
||||||
|
"Series already in database, skipping",
|
||||||
|
key=key,
|
||||||
|
folder=folder_name
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create the series in DB
|
||||||
|
try:
|
||||||
|
name = series_data.get("name") or folder_name
|
||||||
|
site = series_data.get("site", "https://aniworld.to")
|
||||||
|
folder = series_data.get("folder", folder_name)
|
||||||
|
year = series_data.get("year")
|
||||||
|
|
||||||
|
anime_series = await AnimeSeriesService.create(
|
||||||
|
db=db,
|
||||||
|
key=key,
|
||||||
|
name=name,
|
||||||
|
site=site,
|
||||||
|
folder=folder,
|
||||||
|
year=year,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create episodes if present
|
||||||
|
episode_dict = series_data.get("episodeDict", {})
|
||||||
|
if episode_dict:
|
||||||
|
for season, episode_numbers in episode_dict.items():
|
||||||
|
for episode_number in episode_numbers:
|
||||||
|
await EpisodeService.create(
|
||||||
|
db=db,
|
||||||
|
series_id=anime_series.id,
|
||||||
|
season=season,
|
||||||
|
episode_number=episode_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
migrated_count += 1
|
||||||
|
logger.info(
|
||||||
|
"Migrated series from legacy file",
|
||||||
|
key=key,
|
||||||
|
name=name,
|
||||||
|
folder=folder_name
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to migrate series from legacy file",
|
||||||
|
key=key,
|
||||||
|
folder=folder_name,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Legacy migration failed",
|
||||||
|
anime_dir=anime_dir,
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Legacy file migration complete",
|
||||||
|
scanned_folders=scanned_count,
|
||||||
|
migrated=migrated_count
|
||||||
|
)
|
||||||
|
return migrated_count
|
||||||
|
|
||||||
|
|
||||||
|
def _load_data_file(data_file_path: str) -> Optional[dict]:
|
||||||
|
"""Load and parse a legacy 'data' file (JSON).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data_file_path: Path to the data file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed data dict or None if parsing fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(data_file_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
logger.warning(
|
||||||
|
"Data file is not a dictionary",
|
||||||
|
file=data_file_path
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Ensure episodeDict has int keys
|
||||||
|
if "episodeDict" in data and isinstance(data["episodeDict"], dict):
|
||||||
|
data["episodeDict"] = {
|
||||||
|
int(k): v for k, v in data["episodeDict"].items()
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to parse legacy data file (JSON error)",
|
||||||
|
file=data_file_path,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to read legacy data file",
|
||||||
|
file=data_file_path,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_key_file(key_file_path: str, folder_name: str) -> Optional[dict]:
|
||||||
|
"""Load a legacy 'key' file (single line with series key).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_file_path: Path to the key file
|
||||||
|
folder_name: Folder name to use as fallback name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Data dict with key and inferred fields, or None if loading fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(key_file_path, "r", encoding="utf-8") as f:
|
||||||
|
key = f.read().strip()
|
||||||
|
|
||||||
|
if not key:
|
||||||
|
logger.warning(
|
||||||
|
"Key file is empty",
|
||||||
|
file=key_file_path
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Infer basic data from key file
|
||||||
|
return {
|
||||||
|
"key": key,
|
||||||
|
"name": folder_name,
|
||||||
|
"site": "https://aniworld.to",
|
||||||
|
"folder": folder_name,
|
||||||
|
"episodeDict": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to read legacy key file",
|
||||||
|
file=key_file_path,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
return None
|
||||||
@@ -83,15 +83,12 @@ class QueueRepository:
|
|||||||
) -> DownloadItem:
|
) -> DownloadItem:
|
||||||
"""Convert database model to DownloadItem.
|
"""Convert database model to DownloadItem.
|
||||||
|
|
||||||
Note: Since the database model is simplified, status, priority,
|
|
||||||
progress, and retry_count default to initial values.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db_item: SQLAlchemy download queue item
|
db_item: SQLAlchemy download queue item
|
||||||
item_id: Optional override for item ID
|
item_id: Optional override for item ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Pydantic download item with default status/priority
|
Pydantic download item with status/retry_count from database
|
||||||
"""
|
"""
|
||||||
# Get episode info from the related Episode object
|
# Get episode info from the related Episode object
|
||||||
episode = db_item.episode
|
episode = db_item.episode
|
||||||
@@ -109,14 +106,14 @@ class QueueRepository:
|
|||||||
serie_folder=series.folder if series else "",
|
serie_folder=series.folder if series else "",
|
||||||
serie_name=series.name if series else "",
|
serie_name=series.name if series else "",
|
||||||
episode=episode_identifier,
|
episode=episode_identifier,
|
||||||
status=DownloadStatus.PENDING, # Default - managed in-memory
|
status=DownloadStatus(db_item.status), # From database
|
||||||
priority=DownloadPriority.NORMAL, # Default - managed in-memory
|
priority=DownloadPriority.NORMAL, # Managed in-memory
|
||||||
added_at=db_item.created_at or datetime.now(timezone.utc),
|
added_at=db_item.created_at or datetime.now(timezone.utc),
|
||||||
started_at=db_item.started_at,
|
started_at=db_item.started_at,
|
||||||
completed_at=db_item.completed_at,
|
completed_at=db_item.completed_at,
|
||||||
progress=None, # Managed in-memory
|
progress=None, # Managed in-memory
|
||||||
error=db_item.error_message,
|
error=db_item.error_message,
|
||||||
retry_count=0, # Managed in-memory
|
retry_count=db_item.retry_count, # From database
|
||||||
source_url=db_item.download_url,
|
source_url=db_item.download_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -351,6 +348,110 @@ class QueueRepository:
|
|||||||
if manage_session:
|
if manage_session:
|
||||||
await session.close()
|
await session.close()
|
||||||
|
|
||||||
|
async def set_status(
|
||||||
|
self,
|
||||||
|
item_id: str,
|
||||||
|
status: str,
|
||||||
|
db: Optional[AsyncSession] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Set status on a download item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: Download item ID
|
||||||
|
status: New status value
|
||||||
|
db: Optional existing database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if update succeeded, False if item not found
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
QueueRepositoryError: If update fails
|
||||||
|
"""
|
||||||
|
session = db or self._db_session_factory()
|
||||||
|
manage_session = db is None
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await DownloadQueueService.set_status(
|
||||||
|
session,
|
||||||
|
int(item_id),
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
|
||||||
|
if manage_session:
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
success = result is not None
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.debug(
|
||||||
|
"Set status on queue item: item_id=%s, status=%s",
|
||||||
|
item_id,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
if manage_session:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error("Failed to set status: %s", e)
|
||||||
|
raise QueueRepositoryError(f"Failed to set status: {e}") from e
|
||||||
|
finally:
|
||||||
|
if manage_session:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
async def increment_retry(
|
||||||
|
self,
|
||||||
|
item_id: str,
|
||||||
|
db: Optional[AsyncSession] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Increment retry count on a download item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: Download item ID
|
||||||
|
db: Optional existing database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if update succeeded, False if item not found
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
QueueRepositoryError: If update fails
|
||||||
|
"""
|
||||||
|
session = db or self._db_session_factory()
|
||||||
|
manage_session = db is None
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await DownloadQueueService.increment_retry_count(
|
||||||
|
session,
|
||||||
|
int(item_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
if manage_session:
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
success = result is not None
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.debug(
|
||||||
|
"Incremented retry count on queue item: item_id=%s",
|
||||||
|
item_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
if manage_session:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error("Failed to increment retry: %s", e)
|
||||||
|
raise QueueRepositoryError(f"Failed to increment retry: {e}") from e
|
||||||
|
finally:
|
||||||
|
if manage_session:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
async def delete_item(
|
async def delete_item(
|
||||||
self,
|
self,
|
||||||
item_id: str,
|
item_id: str,
|
||||||
|
|||||||
@@ -3,23 +3,31 @@
|
|||||||
Uses APScheduler's AsyncIOScheduler with CronTrigger for precise
|
Uses APScheduler's AsyncIOScheduler with CronTrigger for precise
|
||||||
cron-based scheduling. The legacy interval-based loop has been removed
|
cron-based scheduling. The legacy interval-based loop has been removed
|
||||||
in favour of the cron approach.
|
in favour of the cron approach.
|
||||||
|
|
||||||
|
Jobs are held in memory (no separate scheduler database). On startup,
|
||||||
|
if the last scan timestamp indicates a missed run (server was down at the
|
||||||
|
scheduled cron time), a rescan is triggered immediately.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
import structlog
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
|
||||||
from src.server.models.config import SchedulerConfig
|
from src.server.models.config import SchedulerConfig
|
||||||
from src.server.services.config_service import ConfigServiceError, get_config_service
|
from src.server.services.config_service import ConfigServiceError, get_config_service
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_JOB_ID = "scheduled_rescan"
|
_JOB_ID = "scheduled_rescan"
|
||||||
|
|
||||||
|
# Grace period for missed jobs (1 hour — handles server downtime between
|
||||||
|
# scheduled time and startup).
|
||||||
|
_MISFIRE_GRACE_SECONDS = 3600
|
||||||
|
|
||||||
|
|
||||||
class SchedulerServiceError(Exception):
|
class SchedulerServiceError(Exception):
|
||||||
"""Service-level exception for scheduler operations."""
|
"""Service-level exception for scheduler operations."""
|
||||||
@@ -44,6 +52,9 @@ class SchedulerService:
|
|||||||
self._config: Optional[SchedulerConfig] = None
|
self._config: Optional[SchedulerConfig] = None
|
||||||
self._last_scan_time: Optional[datetime] = None
|
self._last_scan_time: Optional[datetime] = None
|
||||||
self._scan_in_progress: bool = False
|
self._scan_in_progress: bool = False
|
||||||
|
# Cooldown tracking for auto-download to prevent rapid re-triggers
|
||||||
|
self._last_auto_download_time: Optional[datetime] = None
|
||||||
|
self._auto_download_cooldown_seconds: int = 300 # 5 minutes default
|
||||||
logger.info("SchedulerService initialised")
|
logger.info("SchedulerService initialised")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -57,17 +68,22 @@ class SchedulerService:
|
|||||||
SchedulerServiceError: If the scheduler is already running or
|
SchedulerServiceError: If the scheduler is already running or
|
||||||
config cannot be loaded.
|
config cannot be loaded.
|
||||||
"""
|
"""
|
||||||
|
logger.info("SchedulerService.start() called")
|
||||||
if self._is_running:
|
if self._is_running:
|
||||||
|
logger.warning("Scheduler start called but already running")
|
||||||
raise SchedulerServiceError("Scheduler is already running")
|
raise SchedulerServiceError("Scheduler is already running")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config_service = get_config_service()
|
config_service = get_config_service()
|
||||||
config = config_service.load_config()
|
config = config_service.load_config()
|
||||||
self._config = config.scheduler
|
self._config = config.scheduler
|
||||||
|
logger.info("Scheduler config loaded successfully")
|
||||||
except ConfigServiceError as exc:
|
except ConfigServiceError as exc:
|
||||||
logger.error("Failed to load scheduler configuration", error=str(exc))
|
logger.error("Failed to load scheduler configuration: %s", exc)
|
||||||
raise SchedulerServiceError(f"Failed to load config: {exc}") from exc
|
raise SchedulerServiceError(f"Failed to load config: {exc}") from exc
|
||||||
|
|
||||||
|
# Use in-memory job store — no separate scheduler.db needed.
|
||||||
|
# Jobs are reconstructed from config on every startup.
|
||||||
self._scheduler = AsyncIOScheduler()
|
self._scheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
if not self._config.enabled:
|
if not self._config.enabled:
|
||||||
@@ -75,6 +91,15 @@ class SchedulerService:
|
|||||||
self._is_running = True
|
self._is_running = True
|
||||||
return
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Scheduler config loaded: enabled=%s time=%s days=%s auto_download=%s folder_scan=%s",
|
||||||
|
self._config.enabled,
|
||||||
|
self._config.schedule_time,
|
||||||
|
self._config.schedule_days,
|
||||||
|
self._config.auto_download_after_rescan,
|
||||||
|
self._config.folder_scan_enabled,
|
||||||
|
)
|
||||||
|
|
||||||
trigger = self._build_cron_trigger()
|
trigger = self._build_cron_trigger()
|
||||||
if trigger is None:
|
if trigger is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -82,23 +107,39 @@ class SchedulerService:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._scheduler.add_job(
|
self._scheduler.add_job(
|
||||||
self._perform_rescan,
|
_run_rescan_job,
|
||||||
trigger=trigger,
|
trigger=trigger,
|
||||||
id=_JOB_ID,
|
id=_JOB_ID,
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
misfire_grace_time=300,
|
misfire_grace_time=_MISFIRE_GRACE_SECONDS,
|
||||||
|
coalesce=True,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Scheduler started with cron trigger",
|
"Scheduler started with cron trigger: time=%s days=%s",
|
||||||
schedule_time=self._config.schedule_time,
|
self._config.schedule_time,
|
||||||
schedule_days=self._config.schedule_days,
|
self._config.schedule_days,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._scheduler.start()
|
self._scheduler.start()
|
||||||
self._is_running = True
|
self._is_running = True
|
||||||
|
|
||||||
|
# Log next scheduled run for visibility.
|
||||||
|
job = self._scheduler.get_job(_JOB_ID)
|
||||||
|
if job:
|
||||||
|
next_run = job.next_run_time
|
||||||
|
logger.info(
|
||||||
|
"Scheduler next run: %s",
|
||||||
|
next_run.isoformat() if next_run else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Startup misfire recovery: check if the last scan was missed while
|
||||||
|
# the server was down. If overdue by more than one interval but within
|
||||||
|
# the grace period, trigger an immediate rescan.
|
||||||
|
await self._check_missed_run()
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
"""Stop the APScheduler gracefully."""
|
"""Stop the APScheduler gracefully."""
|
||||||
|
logger.info("SchedulerService.stop() called")
|
||||||
if not self._is_running:
|
if not self._is_running:
|
||||||
logger.debug("Scheduler stop called but not running")
|
logger.debug("Scheduler stop called but not running")
|
||||||
return
|
return
|
||||||
@@ -106,8 +147,27 @@ class SchedulerService:
|
|||||||
if self._scheduler and self._scheduler.running:
|
if self._scheduler and self._scheduler.running:
|
||||||
self._scheduler.shutdown(wait=False)
|
self._scheduler.shutdown(wait=False)
|
||||||
logger.info("Scheduler stopped")
|
logger.info("Scheduler stopped")
|
||||||
|
else:
|
||||||
|
logger.info("Scheduler stop: scheduler was not running")
|
||||||
|
|
||||||
self._is_running = False
|
self._is_running = False
|
||||||
|
logger.info("SchedulerService stopped successfully")
|
||||||
|
|
||||||
|
async def ensure_started(self) -> None:
|
||||||
|
"""Ensure the scheduler is running (idempotent).
|
||||||
|
|
||||||
|
If already running, returns immediately. Otherwise, starts the scheduler.
|
||||||
|
This method is safe to call multiple times and from multiple callers.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SchedulerServiceError: If startup fails (except for already running).
|
||||||
|
"""
|
||||||
|
if self._is_running:
|
||||||
|
logger.debug("Scheduler ensure_started called but already running")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Scheduler ensure_started: starting scheduler")
|
||||||
|
await self.start()
|
||||||
|
|
||||||
async def trigger_rescan(self) -> bool:
|
async def trigger_rescan(self) -> bool:
|
||||||
"""Manually trigger a library rescan.
|
"""Manually trigger a library rescan.
|
||||||
@@ -140,11 +200,12 @@ class SchedulerService:
|
|||||||
"""
|
"""
|
||||||
self._config = config
|
self._config = config
|
||||||
logger.info(
|
logger.info(
|
||||||
"Scheduler config reloaded",
|
"Scheduler config reloaded: enabled=%s time=%s days=%s auto_download=%s folder_scan=%s",
|
||||||
enabled=config.enabled,
|
config.enabled,
|
||||||
schedule_time=config.schedule_time,
|
config.schedule_time,
|
||||||
schedule_days=config.schedule_days,
|
config.schedule_days,
|
||||||
auto_download=config.auto_download_after_rescan,
|
config.auto_download_after_rescan,
|
||||||
|
config.folder_scan_enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not self._scheduler or not self._scheduler.running:
|
if not self._scheduler or not self._scheduler.running:
|
||||||
@@ -165,22 +226,23 @@ class SchedulerService:
|
|||||||
if self._scheduler.get_job(_JOB_ID):
|
if self._scheduler.get_job(_JOB_ID):
|
||||||
self._scheduler.reschedule_job(_JOB_ID, trigger=trigger)
|
self._scheduler.reschedule_job(_JOB_ID, trigger=trigger)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Scheduler rescheduled with cron trigger",
|
"Scheduler rescheduled with cron trigger: time=%s days=%s",
|
||||||
schedule_time=config.schedule_time,
|
config.schedule_time,
|
||||||
schedule_days=config.schedule_days,
|
config.schedule_days,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._scheduler.add_job(
|
self._scheduler.add_job(
|
||||||
self._perform_rescan,
|
_run_rescan_job,
|
||||||
trigger=trigger,
|
trigger=trigger,
|
||||||
id=_JOB_ID,
|
id=_JOB_ID,
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
misfire_grace_time=300,
|
misfire_grace_time=_MISFIRE_GRACE_SECONDS,
|
||||||
|
coalesce=True,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Scheduler job added with cron trigger",
|
"Scheduler job added with cron trigger: time=%s days=%s",
|
||||||
schedule_time=config.schedule_time,
|
config.schedule_time,
|
||||||
schedule_days=config.schedule_days,
|
config.schedule_days,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_status(self) -> dict:
|
def get_status(self) -> dict:
|
||||||
@@ -204,6 +266,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,
|
||||||
@@ -231,33 +296,106 @@ class SchedulerService:
|
|||||||
day_of_week=day_of_week,
|
day_of_week=day_of_week,
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"CronTrigger built",
|
"CronTrigger built: hour=%s minute=%s day_of_week=%s",
|
||||||
hour=hour_str,
|
hour_str,
|
||||||
minute=minute_str,
|
minute_str,
|
||||||
day_of_week=day_of_week,
|
day_of_week,
|
||||||
)
|
)
|
||||||
return trigger
|
return trigger
|
||||||
|
|
||||||
|
async def _check_missed_run(self) -> None:
|
||||||
|
"""Check if a scheduled rescan was missed while the server was down.
|
||||||
|
|
||||||
|
Compares system_settings.last_scan_timestamp against the expected
|
||||||
|
schedule. If the last scan is overdue (more than 24h ago for a daily
|
||||||
|
schedule) but within the grace period, triggers an immediate rescan.
|
||||||
|
"""
|
||||||
|
if not self._config or not self._config.enabled:
|
||||||
|
return
|
||||||
|
if not self._config.schedule_days:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.server.database.connection import get_db_session # noqa: PLC0415
|
||||||
|
from src.server.database.system_settings_service import (
|
||||||
|
SystemSettingsService, # noqa: PLC0415
|
||||||
|
)
|
||||||
|
|
||||||
|
async with get_db_session() as db:
|
||||||
|
settings = await SystemSettingsService.get_or_create(db)
|
||||||
|
last_scan = settings.last_scan_timestamp
|
||||||
|
|
||||||
|
if last_scan is None:
|
||||||
|
# Never scanned before — trigger immediately
|
||||||
|
logger.info("No previous scan recorded — triggering immediate rescan")
|
||||||
|
await self._perform_rescan()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ensure timezone-aware comparison
|
||||||
|
if last_scan.tzinfo is None:
|
||||||
|
last_scan = last_scan.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
elapsed = now - last_scan
|
||||||
|
|
||||||
|
# If last scan was more than 24h + grace period ago, don't trigger
|
||||||
|
# (avoids surprise rescans after long downtime).
|
||||||
|
max_overdue = timedelta(hours=24, seconds=_MISFIRE_GRACE_SECONDS)
|
||||||
|
# If last scan was more than ~25h ago, skip (too stale)
|
||||||
|
if elapsed > max_overdue:
|
||||||
|
logger.info(
|
||||||
|
"Last scan was %s ago (> %s) — skipping missed-run recovery",
|
||||||
|
elapsed,
|
||||||
|
max_overdue,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if a run should have happened between last_scan and now.
|
||||||
|
# Simple heuristic: if elapsed > 24h, we missed at least one daily run.
|
||||||
|
if elapsed > timedelta(hours=23):
|
||||||
|
logger.info(
|
||||||
|
"Missed scheduled rescan detected (last scan %s ago) — triggering now",
|
||||||
|
elapsed,
|
||||||
|
)
|
||||||
|
await self._perform_rescan()
|
||||||
|
|
||||||
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||||
|
logger.warning("Missed-run check failed (non-fatal): %s", exc)
|
||||||
|
|
||||||
async def _broadcast(self, event_type: str, data: dict) -> None:
|
async def _broadcast(self, event_type: str, data: dict) -> None:
|
||||||
"""Broadcast a WebSocket event to all connected clients."""
|
"""Broadcast a WebSocket event to all connected clients."""
|
||||||
try:
|
try:
|
||||||
from src.server.services.websocket_service import ( # noqa: PLC0415
|
from src.server.services.websocket_service import (
|
||||||
get_websocket_service,
|
get_websocket_service, # noqa: PLC0415
|
||||||
)
|
)
|
||||||
|
|
||||||
ws_service = get_websocket_service()
|
ws_service = get_websocket_service()
|
||||||
await ws_service.manager.broadcast({"type": event_type, "data": data})
|
await ws_service.manager.broadcast({"type": event_type, "data": data})
|
||||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||||
logger.warning("WebSocket broadcast failed", event=event_type, error=str(exc))
|
logger.warning("WebSocket broadcast failed: event=%s error=%s", event_type, exc)
|
||||||
|
|
||||||
async def _auto_download_missing(self) -> None:
|
async def _auto_download_missing(self) -> None:
|
||||||
"""Queue and start downloads for all series with missing episodes."""
|
"""Queue and start downloads for all series with missing episodes."""
|
||||||
|
from datetime import timedelta # noqa: PLC0415
|
||||||
|
|
||||||
from src.server.models.download import EpisodeIdentifier # noqa: PLC0415
|
from src.server.models.download import EpisodeIdentifier # noqa: PLC0415
|
||||||
from src.server.utils.dependencies import ( # noqa: PLC0415
|
from src.server.utils.dependencies import ( # noqa: PLC0415
|
||||||
get_anime_service,
|
get_anime_service,
|
||||||
get_download_service,
|
get_download_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check cooldown to prevent rapid re-triggers
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if self._last_auto_download_time is not None:
|
||||||
|
elapsed = now - self._last_auto_download_time
|
||||||
|
if elapsed < timedelta(seconds=self._auto_download_cooldown_seconds):
|
||||||
|
logger.debug(
|
||||||
|
"Auto-download skipped: cooldown active (elapsed=%.1fs cooldown=%ds)",
|
||||||
|
elapsed.total_seconds(),
|
||||||
|
self._auto_download_cooldown_seconds,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
anime_service = get_anime_service()
|
anime_service = get_anime_service()
|
||||||
download_service = get_download_service()
|
download_service = get_download_service()
|
||||||
|
|
||||||
@@ -287,26 +425,31 @@ class SchedulerService:
|
|||||||
)
|
)
|
||||||
queued_count += len(episodes)
|
queued_count += len(episodes)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Auto-download queued episodes",
|
"Auto-download queued episodes for series=%s count=%d",
|
||||||
series=series.get("key"),
|
series.get("key"),
|
||||||
count=len(episodes),
|
len(episodes),
|
||||||
)
|
)
|
||||||
|
|
||||||
if queued_count:
|
if queued_count:
|
||||||
await download_service.start_queue_processing()
|
await download_service.start_queue_processing()
|
||||||
logger.info("Auto-download queue processing started", queued=queued_count)
|
logger.info("Auto-download queue processing started: queued=%d", queued_count)
|
||||||
|
|
||||||
await self._broadcast("auto_download_started", {"queued_count": queued_count})
|
await self._broadcast("auto_download_started", {"queued_count": queued_count})
|
||||||
logger.info("Auto-download completed", queued_count=queued_count)
|
logger.info("Auto-download completed: queued_count=%d", queued_count)
|
||||||
|
|
||||||
|
# Update cooldown timestamp after successful auto-download
|
||||||
|
self._last_auto_download_time = datetime.now(timezone.utc)
|
||||||
|
|
||||||
async def _perform_rescan(self) -> None:
|
async def _perform_rescan(self) -> None:
|
||||||
"""Execute a library rescan and optionally trigger auto-download."""
|
"""Execute a library rescan and optionally trigger auto-download."""
|
||||||
|
logger.info("Scheduler _perform_rescan entered: scan_in_progress=%s", self._scan_in_progress)
|
||||||
if self._scan_in_progress:
|
if self._scan_in_progress:
|
||||||
logger.warning("Skipping rescan: previous scan still in progress")
|
logger.warning("Skipping rescan: previous scan still in progress")
|
||||||
return
|
return
|
||||||
|
|
||||||
self._scan_in_progress = True
|
self._scan_in_progress = True
|
||||||
scan_start = datetime.now(timezone.utc)
|
scan_start = datetime.now(timezone.utc)
|
||||||
|
logger.info("Scheduled rescan started at %s", scan_start.isoformat())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("Starting scheduled library rescan")
|
logger.info("Starting scheduled library rescan")
|
||||||
@@ -314,18 +457,20 @@ class SchedulerService:
|
|||||||
from src.server.utils.dependencies import get_anime_service # noqa: PLC0415
|
from src.server.utils.dependencies import get_anime_service # noqa: PLC0415
|
||||||
|
|
||||||
anime_service = get_anime_service()
|
anime_service = get_anime_service()
|
||||||
|
logger.info("Anime service obtained for rescan")
|
||||||
|
|
||||||
await self._broadcast(
|
await self._broadcast(
|
||||||
"scheduled_rescan_started",
|
"scheduled_rescan_started",
|
||||||
{"timestamp": scan_start.isoformat()},
|
{"timestamp": scan_start.isoformat()},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info("Calling anime_service.rescan()...")
|
||||||
await anime_service.rescan()
|
await anime_service.rescan()
|
||||||
|
|
||||||
self._last_scan_time = datetime.now(timezone.utc)
|
self._last_scan_time = datetime.now(timezone.utc)
|
||||||
duration = (self._last_scan_time - scan_start).total_seconds()
|
duration = (self._last_scan_time - scan_start).total_seconds()
|
||||||
|
|
||||||
logger.info("Scheduled library rescan completed", duration_seconds=duration)
|
logger.info("Scheduled library rescan completed: duration=%.2fs", duration)
|
||||||
|
|
||||||
await self._broadcast(
|
await self._broadcast(
|
||||||
"scheduled_rescan_completed",
|
"scheduled_rescan_completed",
|
||||||
@@ -342,8 +487,8 @@ class SchedulerService:
|
|||||||
await self._auto_download_missing()
|
await self._auto_download_missing()
|
||||||
except Exception as dl_exc: # pylint: disable=broad-exception-caught
|
except Exception as dl_exc: # pylint: disable=broad-exception-caught
|
||||||
logger.error(
|
logger.error(
|
||||||
"Auto-download after rescan failed",
|
"Auto-download after rescan failed: %s",
|
||||||
error=str(dl_exc),
|
dl_exc,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
await self._broadcast(
|
await self._broadcast(
|
||||||
@@ -352,8 +497,51 @@ 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 (
|
||||||
|
FolderScanService, # noqa: PLC0415
|
||||||
|
)
|
||||||
|
|
||||||
|
folder_scan_service = FolderScanService()
|
||||||
|
await folder_scan_service.run_folder_scan()
|
||||||
|
logger.info("Folder scan completed successfully")
|
||||||
|
except Exception as fs_exc: # pylint: disable=broad-exception-caught
|
||||||
|
logger.error(
|
||||||
|
"Folder scan failed: %s",
|
||||||
|
fs_exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
await self._broadcast(
|
||||||
|
"folder_scan_error", {"error": str(fs_exc)}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Key resolution scan (resolve orphaned folders)
|
||||||
|
try:
|
||||||
|
from src.server.services.key_resolution_service import (
|
||||||
|
perform_key_resolution_scan, # noqa: PLC0415
|
||||||
|
)
|
||||||
|
|
||||||
|
key_stats = await perform_key_resolution_scan()
|
||||||
|
logger.info(
|
||||||
|
"Key resolution scan completed: resolved=%d, skipped=%d, errors=%d",
|
||||||
|
key_stats["resolved"],
|
||||||
|
key_stats["skipped"],
|
||||||
|
key_stats["errors"],
|
||||||
|
)
|
||||||
|
except Exception as kr_exc: # pylint: disable=broad-exception-caught
|
||||||
|
logger.error(
|
||||||
|
"Key resolution scan failed: %s",
|
||||||
|
kr_exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
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: %s", exc, exc_info=True)
|
||||||
await self._broadcast(
|
await self._broadcast(
|
||||||
"scheduled_rescan_error",
|
"scheduled_rescan_error",
|
||||||
{"error": str(exc), "timestamp": datetime.now(timezone.utc).isoformat()},
|
{"error": str(exc), "timestamp": datetime.now(timezone.utc).isoformat()},
|
||||||
@@ -361,6 +549,27 @@ class SchedulerService:
|
|||||||
|
|
||||||
finally:
|
finally:
|
||||||
self._scan_in_progress = False
|
self._scan_in_progress = False
|
||||||
|
logger.info("Scheduled rescan finished: scan_in_progress reset to False")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module-level job runner
|
||||||
|
#
|
||||||
|
# APScheduler cannot serialize bound methods (SchedulerService instance
|
||||||
|
# contains a reference to the scheduler itself, creating a circular pickle
|
||||||
|
# error). Using a module-level function avoids this.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _run_rescan_job() -> None:
|
||||||
|
"""Module-level job entry point — delegates to the current service."""
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("APScheduler triggered _run_rescan_job")
|
||||||
|
logger.info("Getting scheduler service singleton...")
|
||||||
|
svc = get_scheduler_service()
|
||||||
|
logger.info("Scheduler service obtained, calling _perform_rescan()")
|
||||||
|
await svc._perform_rescan()
|
||||||
|
logger.info("_run_rescan_job completed")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -374,7 +583,10 @@ def get_scheduler_service() -> SchedulerService:
|
|||||||
"""Return the singleton SchedulerService instance."""
|
"""Return the singleton SchedulerService instance."""
|
||||||
global _scheduler_service
|
global _scheduler_service
|
||||||
if _scheduler_service is None:
|
if _scheduler_service is None:
|
||||||
|
logger.info("Creating new SchedulerService singleton")
|
||||||
_scheduler_service = SchedulerService()
|
_scheduler_service = SchedulerService()
|
||||||
|
else:
|
||||||
|
logger.debug("Returning existing SchedulerService singleton")
|
||||||
return _scheduler_service
|
return _scheduler_service
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -268,3 +268,205 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Context Menu
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1500;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--shadow-elevated);
|
||||||
|
min-width: 180px;
|
||||||
|
padding: var(--spacing-xs) 0;
|
||||||
|
animation: contextMenuFadeIn 0.12s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes contextMenuFadeIn {
|
||||||
|
from { opacity: 0; transform: scale(0.95); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
transition: background-color 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item:hover {
|
||||||
|
background-color: var(--color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item i {
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Edit Metadata Modal
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.edit-modal-content {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-section {
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
padding-bottom: var(--spacing-lg);
|
||||||
|
border-bottom: 1px solid var(--color-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-section h4 {
|
||||||
|
margin: 0 0 var(--spacing-md) 0;
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-section h4 i {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
color: var(--color-error, #e74c3c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-error {
|
||||||
|
border-color: var(--color-error, #e74c3c) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-warning {
|
||||||
|
background: rgba(255, 193, 7, 0.1);
|
||||||
|
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
color: var(--color-warning, #f39c12);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* NFO Diagnostics */
|
||||||
|
.nfo-diagnostics {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nfo-status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
font-weight: 600;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nfo-status-badge.nfo-complete {
|
||||||
|
background: rgba(46, 204, 113, 0.15);
|
||||||
|
color: var(--color-success, #2ecc71);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nfo-status-badge.nfo-incomplete {
|
||||||
|
background: rgba(243, 156, 18, 0.15);
|
||||||
|
color: var(--color-warning, #f39c12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nfo-status-badge.nfo-missing {
|
||||||
|
background: rgba(231, 76, 60, 0.15);
|
||||||
|
color: var(--color-error, #e74c3c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.missing-tags-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.missing-tag-chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: var(--color-background-subtle);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nfo-all-good {
|
||||||
|
color: var(--color-success, #2ecc71);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nfo-error {
|
||||||
|
color: var(--color-error, #e74c3c);
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repair-hint {
|
||||||
|
font-size: var(--font-size-caption);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-repair {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ AniWorld.IndexApp = (function() {
|
|||||||
AniWorld.Search.init();
|
AniWorld.Search.init();
|
||||||
AniWorld.ScanManager.init();
|
AniWorld.ScanManager.init();
|
||||||
AniWorld.ConfigManager.init();
|
AniWorld.ConfigManager.init();
|
||||||
|
AniWorld.ContextMenu.init();
|
||||||
|
|
||||||
// Bind global events
|
// Bind global events
|
||||||
bindGlobalEvents();
|
bindGlobalEvents();
|
||||||
|
|||||||
123
src/server/web/static/js/index/context-menu.js
Normal file
123
src/server/web/static/js/index/context-menu.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* AniWorld - Context Menu Component
|
||||||
|
*
|
||||||
|
* Right-click context menu for anime series cards.
|
||||||
|
* Provides quick access to edit metadata.
|
||||||
|
*
|
||||||
|
* Dependencies: ui-utils.js, edit-modal.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
var AniWorld = window.AniWorld || {};
|
||||||
|
|
||||||
|
AniWorld.ContextMenu = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let menuElement = null;
|
||||||
|
let currentSeriesKey = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the context menu system.
|
||||||
|
* Attaches global dismissal listeners.
|
||||||
|
*/
|
||||||
|
function init() {
|
||||||
|
// Dismiss on click outside
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (menuElement && !menuElement.contains(e.target)) {
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dismiss on Escape
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dismiss on scroll or resize
|
||||||
|
window.addEventListener('scroll', hide, true);
|
||||||
|
window.addEventListener('resize', hide);
|
||||||
|
|
||||||
|
// Attach context menu via event delegation on the series grid
|
||||||
|
const grid = document.getElementById('series-grid');
|
||||||
|
if (grid) {
|
||||||
|
grid.addEventListener('contextmenu', function(e) {
|
||||||
|
const card = e.target.closest('.series-card');
|
||||||
|
if (card) {
|
||||||
|
e.preventDefault();
|
||||||
|
const key = card.getAttribute('data-key');
|
||||||
|
if (key) {
|
||||||
|
show(e, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show context menu at cursor position.
|
||||||
|
* @param {MouseEvent} event - The contextmenu event
|
||||||
|
* @param {string} seriesKey - The series key to operate on
|
||||||
|
*/
|
||||||
|
function show(event, seriesKey) {
|
||||||
|
hide(); // Remove any existing menu first
|
||||||
|
|
||||||
|
currentSeriesKey = seriesKey;
|
||||||
|
|
||||||
|
menuElement = document.createElement('div');
|
||||||
|
menuElement.className = 'context-menu';
|
||||||
|
menuElement.innerHTML = `
|
||||||
|
<div class="context-menu-item" data-action="edit">
|
||||||
|
<i class="fa-solid fa-pen-to-square"></i>
|
||||||
|
<span>Edit Metadata</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(menuElement);
|
||||||
|
|
||||||
|
// Position within viewport bounds
|
||||||
|
const x = event.clientX;
|
||||||
|
const y = event.clientY;
|
||||||
|
const menuRect = menuElement.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
let posX = x;
|
||||||
|
let posY = y;
|
||||||
|
|
||||||
|
if (x + menuRect.width > viewportWidth) {
|
||||||
|
posX = viewportWidth - menuRect.width - 8;
|
||||||
|
}
|
||||||
|
if (y + menuRect.height > viewportHeight) {
|
||||||
|
posY = viewportHeight - menuRect.height - 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
menuElement.style.left = posX + 'px';
|
||||||
|
menuElement.style.top = posY + 'px';
|
||||||
|
|
||||||
|
// Attach action handlers
|
||||||
|
menuElement.querySelector('[data-action="edit"]').addEventListener('click', function() {
|
||||||
|
hide();
|
||||||
|
if (AniWorld.EditModal) {
|
||||||
|
AniWorld.EditModal.open(currentSeriesKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide and remove the context menu from DOM.
|
||||||
|
*/
|
||||||
|
function hide() {
|
||||||
|
if (menuElement) {
|
||||||
|
menuElement.remove();
|
||||||
|
menuElement = null;
|
||||||
|
currentSeriesKey = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: init,
|
||||||
|
show: show,
|
||||||
|
hide: hide
|
||||||
|
};
|
||||||
|
})();
|
||||||
450
src/server/web/static/js/index/edit-modal.js
Normal file
450
src/server/web/static/js/index/edit-modal.js
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
/**
|
||||||
|
* AniWorld - Edit Modal Component
|
||||||
|
*
|
||||||
|
* Modal dialog for viewing/editing anime metadata (key, tmdb_id, tvdb_id)
|
||||||
|
* and NFO diagnostics with repair functionality.
|
||||||
|
*
|
||||||
|
* Dependencies: api-client.js, ui-utils.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
var AniWorld = window.AniWorld || {};
|
||||||
|
|
||||||
|
AniWorld.EditModal = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let modalElement = null;
|
||||||
|
let originalData = null;
|
||||||
|
let currentKey = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the edit modal for a specific anime series.
|
||||||
|
* @param {string} seriesKey - The series key to edit
|
||||||
|
*/
|
||||||
|
async function open(seriesKey) {
|
||||||
|
currentKey = seriesKey;
|
||||||
|
modalElement = document.getElementById('edit-metadata-modal');
|
||||||
|
if (!modalElement) return;
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
modalElement.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Reset form state
|
||||||
|
setLoading(true);
|
||||||
|
clearErrors();
|
||||||
|
hideKeyWarning();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find series data from the local series list
|
||||||
|
const seriesData = findSeriesData(seriesKey);
|
||||||
|
|
||||||
|
originalData = {
|
||||||
|
key: seriesKey,
|
||||||
|
tmdb_id: seriesData ? seriesData.tmdb_id : null,
|
||||||
|
tvdb_id: seriesData ? seriesData.tvdb_id : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Populate form fields
|
||||||
|
setFieldValue('edit-key', originalData.key);
|
||||||
|
setFieldValue('edit-tmdb-id', originalData.tmdb_id || '');
|
||||||
|
setFieldValue('edit-tvdb-id', originalData.tvdb_id || '');
|
||||||
|
|
||||||
|
// Load NFO diagnostics
|
||||||
|
await loadDiagnostics(seriesKey);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
AniWorld.UI.showToast('Failed to load series data', 'error');
|
||||||
|
console.error('Edit modal load error:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach event listeners
|
||||||
|
attachListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the edit modal and reset state.
|
||||||
|
*/
|
||||||
|
function close() {
|
||||||
|
if (modalElement) {
|
||||||
|
modalElement.classList.add('hidden');
|
||||||
|
}
|
||||||
|
originalData = null;
|
||||||
|
currentKey = null;
|
||||||
|
detachListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save changed metadata to the backend.
|
||||||
|
*/
|
||||||
|
async function save() {
|
||||||
|
clearErrors();
|
||||||
|
|
||||||
|
const newKey = getFieldValue('edit-key').trim().toLowerCase();
|
||||||
|
const tmdbIdStr = getFieldValue('edit-tmdb-id').trim();
|
||||||
|
const tvdbIdStr = getFieldValue('edit-tvdb-id').trim();
|
||||||
|
|
||||||
|
// Validate key
|
||||||
|
if (!newKey) {
|
||||||
|
showFieldError('edit-key', 'Key cannot be empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(newKey)) {
|
||||||
|
showFieldError('edit-key', 'Key must contain only lowercase letters, numbers, and hyphens');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate IDs
|
||||||
|
const tmdbId = tmdbIdStr ? parseInt(tmdbIdStr, 10) : null;
|
||||||
|
const tvdbId = tvdbIdStr ? parseInt(tvdbIdStr, 10) : null;
|
||||||
|
|
||||||
|
if (tmdbIdStr && (isNaN(tmdbId) || tmdbId < 1)) {
|
||||||
|
showFieldError('edit-tmdb-id', 'TMDB ID must be a positive number');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tvdbIdStr && (isNaN(tvdbId) || tvdbId < 1)) {
|
||||||
|
showFieldError('edit-tvdb-id', 'TVDB ID must be a positive number');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if key changed — show confirmation
|
||||||
|
if (newKey !== originalData.key) {
|
||||||
|
const confirmed = await AniWorld.UI.showConfirmModal(
|
||||||
|
'Rename Series Key',
|
||||||
|
`Changing the key from "${originalData.key}" to "${newKey}" will update the primary identifier. ` +
|
||||||
|
'This may affect provider linkage. Are you sure?'
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update payload (only changed fields)
|
||||||
|
const payload = {};
|
||||||
|
if (newKey !== originalData.key) payload.key = newKey;
|
||||||
|
if (tmdbId !== originalData.tmdb_id) payload.tmdb_id = tmdbId;
|
||||||
|
if (tvdbId !== originalData.tvdb_id) payload.tvdb_id = tvdbId;
|
||||||
|
|
||||||
|
if (Object.keys(payload).length === 0) {
|
||||||
|
AniWorld.UI.showToast('No changes to save', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send update
|
||||||
|
setSaveLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await AniWorld.ApiClient.put(
|
||||||
|
'/api/anime/' + encodeURIComponent(currentKey),
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
AniWorld.UI.showToast('Metadata updated successfully', 'success');
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
const oldKey = currentKey;
|
||||||
|
currentKey = result.key;
|
||||||
|
originalData = {
|
||||||
|
key: result.key,
|
||||||
|
tmdb_id: result.tmdb_id,
|
||||||
|
tvdb_id: result.tvdb_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the card in the DOM
|
||||||
|
updateCardAfterSave(oldKey, result);
|
||||||
|
|
||||||
|
// Update repair button state
|
||||||
|
updateRepairButtonState();
|
||||||
|
|
||||||
|
} else if (response.status === 409) {
|
||||||
|
showFieldError('edit-key', 'A series with this key already exists');
|
||||||
|
} else if (response.status === 422) {
|
||||||
|
const err = await response.json();
|
||||||
|
AniWorld.UI.showToast('Validation error: ' + (err.detail || 'Invalid input'), 'error');
|
||||||
|
} else {
|
||||||
|
AniWorld.UI.showToast('Failed to update metadata', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
AniWorld.UI.showToast('Connection error. Check your network.', 'error');
|
||||||
|
console.error('Save error:', err);
|
||||||
|
} finally {
|
||||||
|
setSaveLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger NFO repair for the current series.
|
||||||
|
*/
|
||||||
|
async function repairNfo() {
|
||||||
|
setRepairLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await AniWorld.ApiClient.post(
|
||||||
|
'/api/nfo/' + encodeURIComponent(currentKey) + '/repair',
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
AniWorld.UI.showToast(result.message, 'success');
|
||||||
|
|
||||||
|
// Refresh diagnostics
|
||||||
|
await loadDiagnostics(currentKey);
|
||||||
|
} else if (response.status === 400) {
|
||||||
|
const err = await response.json();
|
||||||
|
AniWorld.UI.showToast(err.detail || 'Cannot repair NFO', 'error');
|
||||||
|
} else {
|
||||||
|
AniWorld.UI.showToast('Failed to repair NFO', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
AniWorld.UI.showToast('Connection error during repair', 'error');
|
||||||
|
console.error('Repair error:', err);
|
||||||
|
} finally {
|
||||||
|
setRepairLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load NFO diagnostics for the current series.
|
||||||
|
* @param {string} key - Series key
|
||||||
|
*/
|
||||||
|
async function loadDiagnostics(key) {
|
||||||
|
const container = document.getElementById('nfo-diagnostics-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await AniWorld.ApiClient.get(
|
||||||
|
'/api/nfo/' + encodeURIComponent(key) + '/diagnostics'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response || !response.ok) {
|
||||||
|
container.innerHTML = '<p class="nfo-error">Failed to load NFO diagnostics</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
renderDiagnostics(data);
|
||||||
|
updateRepairButtonState();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
container.innerHTML = '<p class="nfo-error">Error loading diagnostics</p>';
|
||||||
|
console.error('Diagnostics error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render NFO diagnostics data into the modal.
|
||||||
|
* @param {Object} data - NfoDiagnosticsResponse
|
||||||
|
*/
|
||||||
|
function renderDiagnostics(data) {
|
||||||
|
const badge = document.getElementById('nfo-status-badge');
|
||||||
|
const tagsList = document.getElementById('nfo-missing-tags');
|
||||||
|
|
||||||
|
if (badge) {
|
||||||
|
if (!data.has_nfo) {
|
||||||
|
badge.className = 'nfo-status-badge nfo-missing';
|
||||||
|
badge.textContent = 'No NFO File';
|
||||||
|
} else if (data.missing_tags.length === 0) {
|
||||||
|
badge.className = 'nfo-status-badge nfo-complete';
|
||||||
|
badge.textContent = 'Complete';
|
||||||
|
} else {
|
||||||
|
badge.className = 'nfo-status-badge nfo-incomplete';
|
||||||
|
badge.textContent = data.missing_tags.length + ' Missing';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagsList) {
|
||||||
|
if (data.missing_tags.length === 0) {
|
||||||
|
tagsList.innerHTML = '<p class="nfo-all-good">All required tags present</p>';
|
||||||
|
} else {
|
||||||
|
tagsList.innerHTML = data.missing_tags.map(function(tag) {
|
||||||
|
return '<span class="missing-tag-chip">' + escapeHtml(tag) + '</span>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update repair button disabled state based on tmdb_id field.
|
||||||
|
*/
|
||||||
|
function updateRepairButtonState() {
|
||||||
|
const btn = document.getElementById('btn-repair-nfo');
|
||||||
|
const hint = document.getElementById('repair-hint');
|
||||||
|
const tmdbValue = getFieldValue('edit-tmdb-id').trim();
|
||||||
|
|
||||||
|
if (btn) {
|
||||||
|
// Enable repair even without tmdb_id — the service can search by name
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
if (hint) {
|
||||||
|
hint.style.display = tmdbValue ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
function findSeriesData(key) {
|
||||||
|
// Access the series data from the series manager if available
|
||||||
|
if (AniWorld.SeriesManager && AniWorld.SeriesManager.getSeriesData) {
|
||||||
|
const allSeries = AniWorld.SeriesManager.getSeriesData();
|
||||||
|
if (allSeries) {
|
||||||
|
return allSeries.find(function(s) { return s.key === key; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCardAfterSave(oldKey, result) {
|
||||||
|
const card = document.querySelector('[data-series-id="' + oldKey + '"]');
|
||||||
|
if (card) {
|
||||||
|
card.setAttribute('data-key', result.key);
|
||||||
|
card.setAttribute('data-series-id', result.key);
|
||||||
|
// Update checkbox data-key
|
||||||
|
const checkbox = card.querySelector('.series-checkbox');
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.setAttribute('data-key', result.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local series data array
|
||||||
|
if (AniWorld.SeriesManager && AniWorld.SeriesManager.updateSeriesKey) {
|
||||||
|
AniWorld.SeriesManager.updateSeriesKey(oldKey, result.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFieldValue(id, value) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.value = value !== null && value !== undefined ? value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFieldValue(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
return el ? el.value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFieldError(fieldId, message) {
|
||||||
|
const el = document.getElementById(fieldId);
|
||||||
|
if (el) {
|
||||||
|
const errorEl = el.parentElement.querySelector('.field-error');
|
||||||
|
if (errorEl) {
|
||||||
|
errorEl.textContent = message;
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
el.classList.add('input-error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearErrors() {
|
||||||
|
if (!modalElement) return;
|
||||||
|
modalElement.querySelectorAll('.field-error').forEach(function(el) {
|
||||||
|
el.style.display = 'none';
|
||||||
|
el.textContent = '';
|
||||||
|
});
|
||||||
|
modalElement.querySelectorAll('.input-error').forEach(function(el) {
|
||||||
|
el.classList.remove('input-error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideKeyWarning() {
|
||||||
|
const warning = document.getElementById('key-change-warning');
|
||||||
|
if (warning) warning.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(loading) {
|
||||||
|
const form = document.getElementById('edit-metadata-form');
|
||||||
|
if (form) {
|
||||||
|
form.style.opacity = loading ? '0.5' : '1';
|
||||||
|
form.style.pointerEvents = loading ? 'none' : 'auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSaveLoading(loading) {
|
||||||
|
const btn = document.getElementById('btn-save-metadata');
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = loading;
|
||||||
|
btn.innerHTML = loading
|
||||||
|
? '<i class="fa-solid fa-spinner fa-spin"></i> Saving...'
|
||||||
|
: '<i class="fa-solid fa-floppy-disk"></i> Save';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRepairLoading(loading) {
|
||||||
|
const btn = document.getElementById('btn-repair-nfo');
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = loading;
|
||||||
|
btn.innerHTML = loading
|
||||||
|
? '<i class="fa-solid fa-spinner fa-spin"></i> Repairing...'
|
||||||
|
: '<i class="fa-solid fa-wrench"></i> Repair NFO';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listener management
|
||||||
|
let listeners = [];
|
||||||
|
|
||||||
|
function attachListeners() {
|
||||||
|
detachListeners();
|
||||||
|
|
||||||
|
const saveBtn = document.getElementById('btn-save-metadata');
|
||||||
|
const cancelBtn = document.getElementById('btn-cancel-metadata');
|
||||||
|
const repairBtn = document.getElementById('btn-repair-nfo');
|
||||||
|
const overlay = modalElement ? modalElement.querySelector('.modal-overlay') : null;
|
||||||
|
const keyInput = document.getElementById('edit-key');
|
||||||
|
|
||||||
|
if (saveBtn) {
|
||||||
|
var saveFn = function() { save(); };
|
||||||
|
saveBtn.addEventListener('click', saveFn);
|
||||||
|
listeners.push({ el: saveBtn, event: 'click', fn: saveFn });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelBtn) {
|
||||||
|
var cancelFn = function() { close(); };
|
||||||
|
cancelBtn.addEventListener('click', cancelFn);
|
||||||
|
listeners.push({ el: cancelBtn, event: 'click', fn: cancelFn });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (repairBtn) {
|
||||||
|
var repairFn = function() { repairNfo(); };
|
||||||
|
repairBtn.addEventListener('click', repairFn);
|
||||||
|
listeners.push({ el: repairBtn, event: 'click', fn: repairFn });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlay) {
|
||||||
|
var overlayFn = function() { close(); };
|
||||||
|
overlay.addEventListener('click', overlayFn);
|
||||||
|
listeners.push({ el: overlay, event: 'click', fn: overlayFn });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyInput) {
|
||||||
|
var keyFn = function() {
|
||||||
|
var warning = document.getElementById('key-change-warning');
|
||||||
|
if (warning) {
|
||||||
|
warning.style.display = keyInput.value !== originalData.key ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
keyInput.addEventListener('input', keyFn);
|
||||||
|
listeners.push({ el: keyInput, event: 'input', fn: keyFn });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detachListeners() {
|
||||||
|
listeners.forEach(function(l) {
|
||||||
|
l.el.removeEventListener(l.event, l.fn);
|
||||||
|
});
|
||||||
|
listeners = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
open: open,
|
||||||
|
close: close,
|
||||||
|
save: save,
|
||||||
|
repairNfo: repairNfo
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -203,6 +203,17 @@ AniWorld.SeriesManager = (function() {
|
|||||||
function applyFiltersAndSort() {
|
function applyFiltersAndSort() {
|
||||||
let filtered = seriesData.slice();
|
let filtered = seriesData.slice();
|
||||||
|
|
||||||
|
// Apply client-side filter so that real-time WebSocket updates
|
||||||
|
// (e.g. an episode being marked downloaded) are immediately
|
||||||
|
// reflected without a full server reload.
|
||||||
|
if (filterMode === 'missing_episodes') {
|
||||||
|
filtered = filtered.filter(function(s) {
|
||||||
|
return s.missing_episodes > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 'no_episodes' filter state is maintained server-side;
|
||||||
|
// don't try to replicate it client-side here.
|
||||||
|
|
||||||
// Sort based on the current sorting mode
|
// Sort based on the current sorting mode
|
||||||
filtered.sort(function(a, b) {
|
filtered.sort(function(a, b) {
|
||||||
if (sortAlphabetical) {
|
if (sortAlphabetical) {
|
||||||
@@ -233,8 +244,12 @@ AniWorld.SeriesManager = (function() {
|
|||||||
*/
|
*/
|
||||||
function renderSeries() {
|
function renderSeries() {
|
||||||
const grid = document.getElementById('series-grid');
|
const grid = document.getElementById('series-grid');
|
||||||
const dataToRender = filteredSeriesData.length > 0 ? filteredSeriesData :
|
// Always use filteredSeriesData — applyFiltersAndSort() is always
|
||||||
(seriesData.length > 0 ? seriesData : []);
|
// called before renderSeries(), so filteredSeriesData is current.
|
||||||
|
// The old fallback to seriesData was incorrect: when a filter is
|
||||||
|
// active and filteredSeriesData is empty it must show the empty-state
|
||||||
|
// message, not fall through to unfiltered seriesData.
|
||||||
|
const dataToRender = filteredSeriesData;
|
||||||
|
|
||||||
if (dataToRender.length === 0) {
|
if (dataToRender.length === 0) {
|
||||||
let message;
|
let message;
|
||||||
@@ -377,6 +392,22 @@ AniWorld.SeriesManager = (function() {
|
|||||||
return seriesData;
|
return seriesData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a series key in the local data arrays after rename.
|
||||||
|
* @param {string} oldKey - The previous key
|
||||||
|
* @param {string} newKey - The new key
|
||||||
|
*/
|
||||||
|
function updateSeriesKey(oldKey, newKey) {
|
||||||
|
if (seriesData) {
|
||||||
|
var s = seriesData.find(function(item) { return item.key === oldKey; });
|
||||||
|
if (s) s.key = newKey;
|
||||||
|
}
|
||||||
|
if (filteredSeriesData) {
|
||||||
|
var fs = filteredSeriesData.find(function(item) { return item.key === oldKey; });
|
||||||
|
if (fs) fs.key = newKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get filtered series data
|
* Get filtered series data
|
||||||
* @returns {Array} Filtered series data array
|
* @returns {Array} Filtered series data array
|
||||||
@@ -528,6 +559,7 @@ AniWorld.SeriesManager = (function() {
|
|||||||
getFilteredSeriesData: getFilteredSeriesData,
|
getFilteredSeriesData: getFilteredSeriesData,
|
||||||
findByKey: findByKey,
|
findByKey: findByKey,
|
||||||
updateSeriesLoadingStatus: updateSeriesLoadingStatus,
|
updateSeriesLoadingStatus: updateSeriesLoadingStatus,
|
||||||
updateSingleSeries: updateSingleSeries
|
updateSingleSeries: updateSingleSeries,
|
||||||
|
updateSeriesKey: updateSeriesKey
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -629,6 +640,80 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Metadata Modal -->
|
||||||
|
<div id="edit-metadata-modal" class="modal hidden">
|
||||||
|
<div class="modal-overlay"></div>
|
||||||
|
<div class="modal-content edit-modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Edit Metadata</h3>
|
||||||
|
<button id="btn-cancel-metadata" class="btn btn-icon">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="edit-metadata-form" onsubmit="return false;">
|
||||||
|
<!-- Identity Section -->
|
||||||
|
<div class="edit-section">
|
||||||
|
<h4><i class="fa-solid fa-key"></i> Identity</h4>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-key">Series Key</label>
|
||||||
|
<input type="text" id="edit-key" class="input-field"
|
||||||
|
placeholder="e.g. attack-on-titan"
|
||||||
|
pattern="[a-z0-9][a-z0-9-]*[a-z0-9]">
|
||||||
|
<span class="field-error" style="display:none;"></span>
|
||||||
|
</div>
|
||||||
|
<div id="key-change-warning" class="key-warning" style="display:none;">
|
||||||
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
|
Changing the key will update the primary identifier. This may affect provider linkage.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- External IDs Section -->
|
||||||
|
<div class="edit-section">
|
||||||
|
<h4><i class="fa-solid fa-database"></i> External IDs</h4>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-tmdb-id">TMDB ID</label>
|
||||||
|
<input type="number" id="edit-tmdb-id" class="input-field"
|
||||||
|
placeholder="e.g. 1429" min="1">
|
||||||
|
<span class="field-error" style="display:none;"></span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-tvdb-id">TVDB ID</label>
|
||||||
|
<input type="number" id="edit-tvdb-id" class="input-field"
|
||||||
|
placeholder="e.g. 267440" min="1">
|
||||||
|
<span class="field-error" style="display:none;"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NFO Status Section -->
|
||||||
|
<div class="edit-section">
|
||||||
|
<h4><i class="fa-solid fa-file-lines"></i> NFO Status</h4>
|
||||||
|
<div class="nfo-diagnostics">
|
||||||
|
<div id="nfo-status-badge" class="nfo-status-badge">Loading...</div>
|
||||||
|
<div id="nfo-diagnostics-container">
|
||||||
|
<div id="nfo-missing-tags" class="missing-tags-list"></div>
|
||||||
|
</div>
|
||||||
|
<p id="repair-hint" class="repair-hint" style="display:none;">
|
||||||
|
<i class="fa-solid fa-circle-info"></i>
|
||||||
|
No TMDB ID set. Repair will search TMDB by series name.
|
||||||
|
</p>
|
||||||
|
<button type="button" id="btn-repair-nfo" class="btn btn-secondary btn-repair">
|
||||||
|
<i class="fa-solid fa-wrench"></i> Repair NFO
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" id="btn-save-metadata" class="btn btn-primary">
|
||||||
|
<i class="fa-solid fa-floppy-disk"></i> Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Toast notifications -->
|
<!-- Toast notifications -->
|
||||||
<div id="toast-container" class="toast-container"></div>
|
<div id="toast-container" class="toast-container"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -654,6 +739,8 @@
|
|||||||
<script src="/static/js/user_preferences.js?v={{ static_v }}"></script>
|
<script src="/static/js/user_preferences.js?v={{ static_v }}"></script>
|
||||||
|
|
||||||
<!-- Index Page Modules -->
|
<!-- Index Page Modules -->
|
||||||
|
<script src="/static/js/index/context-menu.js?v={{ static_v }}"></script>
|
||||||
|
<script src="/static/js/index/edit-modal.js?v={{ static_v }}"></script>
|
||||||
<script src="/static/js/index/series-manager.js?v={{ static_v }}"></script>
|
<script src="/static/js/index/series-manager.js?v={{ static_v }}"></script>
|
||||||
<script src="/static/js/index/selection-manager.js?v={{ static_v }}"></script>
|
<script src="/static/js/index/selection-manager.js?v={{ static_v }}"></script>
|
||||||
<script src="/static/js/index/search.js?v={{ static_v }}"></script>
|
<script src="/static/js/index/search.js?v={{ static_v }}"></script>
|
||||||
|
|||||||
@@ -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 ?
|
||||||
|
|||||||
255
tests/api/test_anime_edit_endpoints.py
Normal file
255
tests/api/test_anime_edit_endpoints.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
"""Tests for anime metadata edit (PUT /api/anime/{anime_key}) endpoint."""
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from src.server.fastapi_app import app
|
||||||
|
from src.server.services.auth_service import auth_service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def reset_auth():
|
||||||
|
"""Reset auth state before each test."""
|
||||||
|
auth_service._hash = None
|
||||||
|
auth_service._failed = {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client():
|
||||||
|
"""Create async test client."""
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def authenticated_client(client):
|
||||||
|
"""Get authenticated client with Bearer token."""
|
||||||
|
# Setup auth
|
||||||
|
await client.post("/api/auth/setup", json={"master_password": "TestPass123!"})
|
||||||
|
response = await client.post(
|
||||||
|
"/api/auth/login", json={"password": "TestPass123!"}
|
||||||
|
)
|
||||||
|
token = response.json()["access_token"]
|
||||||
|
client.headers["Authorization"] = f"Bearer {token}"
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_db_session():
|
||||||
|
"""Create a mock async database session."""
|
||||||
|
session = AsyncMock()
|
||||||
|
session.commit = AsyncMock()
|
||||||
|
session.flush = AsyncMock()
|
||||||
|
session.refresh = AsyncMock()
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_series_in_db():
|
||||||
|
"""Create a mock AnimeSeries DB record."""
|
||||||
|
series = MagicMock()
|
||||||
|
series.id = 1
|
||||||
|
series.key = "test-anime"
|
||||||
|
series.name = "Test Anime"
|
||||||
|
series.tmdb_id = 1234
|
||||||
|
series.tvdb_id = 5678
|
||||||
|
series.folder = "Test Anime (2023)"
|
||||||
|
return series
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def override_db_dependency(mock_db_session):
|
||||||
|
"""Override database session dependency."""
|
||||||
|
from src.server.utils.dependencies import get_database_session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_database_session] = lambda: mock_db_session
|
||||||
|
yield mock_db_session
|
||||||
|
app.dependency_overrides.pop(get_database_session, None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateAnimeMetadata:
|
||||||
|
"""Tests for PUT /api/anime/{anime_key}."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_tmdb_id_success(
|
||||||
|
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||||
|
):
|
||||||
|
"""Test successful tmdb_id update."""
|
||||||
|
with patch(
|
||||||
|
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_series_in_db,
|
||||||
|
), patch(
|
||||||
|
"src.server.api.anime.AnimeSeriesService.update",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_update:
|
||||||
|
mock_series_in_db.tmdb_id = 9999
|
||||||
|
mock_update.return_value = mock_series_in_db
|
||||||
|
|
||||||
|
response = await authenticated_client.put(
|
||||||
|
"/api/anime/test-anime",
|
||||||
|
json={"tmdb_id": 9999},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["tmdb_id"] == 9999
|
||||||
|
assert data["message"] == "Metadata updated successfully"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_tvdb_id_success(
|
||||||
|
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||||
|
):
|
||||||
|
"""Test successful tvdb_id update."""
|
||||||
|
with patch(
|
||||||
|
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_series_in_db,
|
||||||
|
), patch(
|
||||||
|
"src.server.api.anime.AnimeSeriesService.update",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_update:
|
||||||
|
mock_series_in_db.tvdb_id = 7777
|
||||||
|
mock_update.return_value = mock_series_in_db
|
||||||
|
|
||||||
|
response = await authenticated_client.put(
|
||||||
|
"/api/anime/test-anime",
|
||||||
|
json={"tvdb_id": 7777},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["tvdb_id"] == 7777
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_key_success(
|
||||||
|
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||||
|
):
|
||||||
|
"""Test successful key rename."""
|
||||||
|
with patch(
|
||||||
|
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_get:
|
||||||
|
# First call finds the series, second call checks uniqueness (returns None)
|
||||||
|
mock_get.side_effect = [mock_series_in_db, None]
|
||||||
|
|
||||||
|
mock_series_in_db.key = "new-anime-key"
|
||||||
|
with patch(
|
||||||
|
"src.server.api.anime.AnimeSeriesService.update",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_series_in_db,
|
||||||
|
):
|
||||||
|
response = await authenticated_client.put(
|
||||||
|
"/api/anime/test-anime",
|
||||||
|
json={"key": "new-anime-key"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["key"] == "new-anime-key"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_key_conflict_409(
|
||||||
|
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||||
|
):
|
||||||
|
"""Test key rename conflict returns 409."""
|
||||||
|
existing_series = MagicMock()
|
||||||
|
existing_series.key = "existing-key"
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_get:
|
||||||
|
# First call finds original series, second call finds conflict
|
||||||
|
mock_get.side_effect = [mock_series_in_db, existing_series]
|
||||||
|
|
||||||
|
response = await authenticated_client.put(
|
||||||
|
"/api/anime/test-anime",
|
||||||
|
json={"key": "existing-key"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert "already exists" in response.json()["detail"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_key_invalid_chars_422(
|
||||||
|
self, reset_auth, authenticated_client, override_db_dependency
|
||||||
|
):
|
||||||
|
"""Test key with invalid characters returns 422."""
|
||||||
|
response = await authenticated_client.put(
|
||||||
|
"/api/anime/test-anime",
|
||||||
|
json={"key": "Invalid Key With Spaces!"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_key_empty_422(
|
||||||
|
self, reset_auth, authenticated_client, override_db_dependency
|
||||||
|
):
|
||||||
|
"""Test empty key returns 422."""
|
||||||
|
response = await authenticated_client.put(
|
||||||
|
"/api/anime/test-anime",
|
||||||
|
json={"key": ""},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_unauthenticated_401(self, reset_auth, client):
|
||||||
|
"""Test unauthenticated access returns 401."""
|
||||||
|
response = await client.put(
|
||||||
|
"/api/anime/test-anime",
|
||||||
|
json={"tmdb_id": 1234},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_nonexistent_anime_404(
|
||||||
|
self, reset_auth, authenticated_client, override_db_dependency
|
||||||
|
):
|
||||||
|
"""Test update of non-existent anime returns 404."""
|
||||||
|
with patch(
|
||||||
|
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
response = await authenticated_client.put(
|
||||||
|
"/api/anime/nonexistent-key",
|
||||||
|
json={"tmdb_id": 1234},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_no_changes(
|
||||||
|
self, reset_auth, authenticated_client, override_db_dependency, mock_series_in_db
|
||||||
|
):
|
||||||
|
"""Test sending empty body returns no-op response."""
|
||||||
|
with patch(
|
||||||
|
"src.server.api.anime.AnimeSeriesService.get_by_key",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_series_in_db,
|
||||||
|
):
|
||||||
|
response = await authenticated_client.put(
|
||||||
|
"/api/anime/test-anime",
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["message"] == "No changes"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_negative_tmdb_id_422(
|
||||||
|
self, reset_auth, authenticated_client, override_db_dependency
|
||||||
|
):
|
||||||
|
"""Test negative TMDB ID returns 422."""
|
||||||
|
response = await authenticated_client.put(
|
||||||
|
"/api/anime/test-anime",
|
||||||
|
json={"tmdb_id": -5},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
@@ -207,3 +207,46 @@ async def test_tmdb_validation_endpoint_exists(authenticated_client):
|
|||||||
assert "message" in data
|
assert "message" in data
|
||||||
assert data["valid"] is False # Empty key should be invalid
|
assert data["valid"] is False # Empty key should be invalid
|
||||||
assert "required" in data["message"].lower()
|
assert "required" in data["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_config_with_anime_directory_starts_scheduler(
|
||||||
|
authenticated_client, mock_config_service
|
||||||
|
):
|
||||||
|
"""PUT /api/config with anime_directory syncs and starts scheduler."""
|
||||||
|
mock_scheduler = AsyncMock()
|
||||||
|
|
||||||
|
with patch("src.server.services.scheduler_service.get_scheduler_service") as mock_sched_fn:
|
||||||
|
mock_sched_fn.return_value = mock_scheduler
|
||||||
|
|
||||||
|
with patch("src.config.settings.settings") as mock_settings:
|
||||||
|
mock_settings.anime_directory = None
|
||||||
|
|
||||||
|
resp = await authenticated_client.put(
|
||||||
|
"/api/config",
|
||||||
|
json={"other": {"anime_directory": "/data/anime"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
mock_scheduler.ensure_started.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_config_without_anime_directory_does_not_start_scheduler(
|
||||||
|
authenticated_client, mock_config_service
|
||||||
|
):
|
||||||
|
"""PUT /api/config without new anime_directory does not call scheduler.ensure_started()."""
|
||||||
|
mock_scheduler = AsyncMock()
|
||||||
|
|
||||||
|
with patch("src.server.services.scheduler_service.get_scheduler_service") as mock_sched_fn:
|
||||||
|
mock_sched_fn.return_value = mock_scheduler
|
||||||
|
|
||||||
|
with patch("src.config.settings.settings") as mock_settings:
|
||||||
|
mock_settings.anime_directory = "/already/set"
|
||||||
|
|
||||||
|
resp = await authenticated_client.put(
|
||||||
|
"/api/config", json={"other": {}}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
mock_scheduler.ensure_started.assert_not_called()
|
||||||
|
|||||||
317
tests/api/test_nfo_diagnostics_repair.py
Normal file
317
tests/api/test_nfo_diagnostics_repair.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
"""Tests for NFO diagnostics and repair API endpoints."""
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from src.server.fastapi_app import app
|
||||||
|
from src.server.services.auth_service import auth_service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_auth():
|
||||||
|
"""Reset authentication state before each test."""
|
||||||
|
original_hash = auth_service._hash
|
||||||
|
auth_service._hash = None
|
||||||
|
auth_service._failed.clear()
|
||||||
|
yield
|
||||||
|
auth_service._hash = original_hash
|
||||||
|
auth_service._failed.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client():
|
||||||
|
"""Create an async test client."""
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def authenticated_client(client):
|
||||||
|
"""Create an authenticated test client with token."""
|
||||||
|
await client.post(
|
||||||
|
"/api/auth/setup",
|
||||||
|
json={"master_password": "TestPassword123!"}
|
||||||
|
)
|
||||||
|
response = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"password": "TestPassword123!"}
|
||||||
|
)
|
||||||
|
token = response.json()["access_token"]
|
||||||
|
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_series_app():
|
||||||
|
"""Create mock series app with one test series."""
|
||||||
|
app_mock = Mock()
|
||||||
|
serie = Mock()
|
||||||
|
serie.key = "test-anime"
|
||||||
|
serie.folder = "Test Anime (2024)"
|
||||||
|
serie.name = "Test Anime"
|
||||||
|
serie.ensure_folder_with_year = Mock(return_value="Test Anime (2024)")
|
||||||
|
|
||||||
|
list_manager = Mock()
|
||||||
|
list_manager.GetList = Mock(return_value=[serie])
|
||||||
|
app_mock.list = list_manager
|
||||||
|
|
||||||
|
return app_mock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_nfo_service():
|
||||||
|
"""Create mock NFO service."""
|
||||||
|
service = Mock()
|
||||||
|
service.check_nfo_exists = AsyncMock(return_value=False)
|
||||||
|
service.create_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo")
|
||||||
|
service.update_tvshow_nfo = AsyncMock(return_value="/path/to/tvshow.nfo")
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def override_dependencies(mock_series_app, mock_nfo_service):
|
||||||
|
"""Override dependencies for NFO tests."""
|
||||||
|
from src.server.api.nfo import get_nfo_service
|
||||||
|
from src.server.utils.dependencies import get_series_app
|
||||||
|
|
||||||
|
app.dependency_overrides[get_series_app] = lambda: mock_series_app
|
||||||
|
app.dependency_overrides[get_nfo_service] = lambda: mock_nfo_service
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
if get_series_app in app.dependency_overrides:
|
||||||
|
del app.dependency_overrides[get_series_app]
|
||||||
|
if get_nfo_service in app.dependency_overrides:
|
||||||
|
del app.dependency_overrides[get_nfo_service]
|
||||||
|
|
||||||
|
|
||||||
|
class TestNfoDiagnostics:
|
||||||
|
"""Tests for GET /api/nfo/{serie_key}/diagnostics."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diagnostics_complete_nfo(
|
||||||
|
self, authenticated_client, override_dependencies
|
||||||
|
):
|
||||||
|
"""Test diagnostics with complete NFO returns no missing tags."""
|
||||||
|
with patch(
|
||||||
|
"src.server.api.nfo.Path.exists", return_value=True
|
||||||
|
), patch(
|
||||||
|
"src.server.api.nfo.find_missing_tags", return_value=[]
|
||||||
|
):
|
||||||
|
response = await authenticated_client.get(
|
||||||
|
"/api/nfo/test-anime/diagnostics"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["has_nfo"] is True
|
||||||
|
assert data["missing_tags"] == []
|
||||||
|
assert len(data["required_tags"]) > 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diagnostics_missing_tags(
|
||||||
|
self, authenticated_client, override_dependencies
|
||||||
|
):
|
||||||
|
"""Test diagnostics with missing tags returns them."""
|
||||||
|
with patch(
|
||||||
|
"src.server.api.nfo.Path.exists", return_value=True
|
||||||
|
), patch(
|
||||||
|
"src.server.api.nfo.find_missing_tags",
|
||||||
|
return_value=["plot", "genre", "actor/name"],
|
||||||
|
):
|
||||||
|
response = await authenticated_client.get(
|
||||||
|
"/api/nfo/test-anime/diagnostics"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["has_nfo"] is True
|
||||||
|
assert "plot" in data["missing_tags"]
|
||||||
|
assert "genre" in data["missing_tags"]
|
||||||
|
assert len(data["missing_tags"]) == 3
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diagnostics_no_nfo_file(
|
||||||
|
self, authenticated_client, override_dependencies
|
||||||
|
):
|
||||||
|
"""Test diagnostics when no NFO exists returns all tags as missing."""
|
||||||
|
with patch("src.server.api.nfo.Path") as MockPath:
|
||||||
|
# Make nfo_path.exists() return False
|
||||||
|
mock_path_instance = Mock()
|
||||||
|
mock_path_instance.exists.return_value = False
|
||||||
|
mock_path_instance.__truediv__ = Mock(return_value=mock_path_instance)
|
||||||
|
MockPath.return_value = mock_path_instance
|
||||||
|
|
||||||
|
response = await authenticated_client.get(
|
||||||
|
"/api/nfo/test-anime/diagnostics"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["has_nfo"] is False
|
||||||
|
assert len(data["missing_tags"]) > 0
|
||||||
|
# All required tags should be listed as missing
|
||||||
|
assert data["missing_tags"] == data["required_tags"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diagnostics_nonexistent_series_404(
|
||||||
|
self, authenticated_client, override_dependencies, mock_series_app
|
||||||
|
):
|
||||||
|
"""Test diagnostics for non-existent series returns 404."""
|
||||||
|
# Override to return empty list
|
||||||
|
mock_series_app.list.GetList.return_value = []
|
||||||
|
|
||||||
|
response = await authenticated_client.get(
|
||||||
|
"/api/nfo/nonexistent-key/diagnostics"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diagnostics_unauthenticated_401(self, client):
|
||||||
|
"""Test diagnostics requires authentication."""
|
||||||
|
response = await client.get("/api/nfo/test-anime/diagnostics")
|
||||||
|
# May return 401 or 503 depending on NFO service availability
|
||||||
|
assert response.status_code in (401, 503)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNfoRepair:
|
||||||
|
"""Tests for POST /api/nfo/{serie_key}/repair."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_success(
|
||||||
|
self, authenticated_client, override_dependencies
|
||||||
|
):
|
||||||
|
"""Test successful NFO repair."""
|
||||||
|
with patch("src.server.api.nfo.Path") as MockPath:
|
||||||
|
mock_path = Mock()
|
||||||
|
mock_path.exists.return_value = True
|
||||||
|
mock_path.__truediv__ = Mock(return_value=mock_path)
|
||||||
|
MockPath.return_value = mock_path
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.api.nfo.find_missing_tags",
|
||||||
|
return_value=["plot", "genre"],
|
||||||
|
), patch(
|
||||||
|
"src.server.api.nfo.NfoRepairService"
|
||||||
|
) as MockRepairService:
|
||||||
|
mock_instance = Mock()
|
||||||
|
mock_instance.repair_series = AsyncMock(return_value=True)
|
||||||
|
MockRepairService.return_value = mock_instance
|
||||||
|
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/api/nfo/test-anime/repair", json={}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert "2" in data["message"] # "Fixed 2 missing tags"
|
||||||
|
assert "plot" in data["repaired_tags"]
|
||||||
|
assert "genre" in data["repaired_tags"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_already_complete(
|
||||||
|
self, authenticated_client, override_dependencies
|
||||||
|
):
|
||||||
|
"""Test repair when NFO is already complete."""
|
||||||
|
with patch("src.server.api.nfo.Path") as MockPath:
|
||||||
|
mock_path = Mock()
|
||||||
|
mock_path.exists.return_value = True
|
||||||
|
mock_path.__truediv__ = Mock(return_value=mock_path)
|
||||||
|
MockPath.return_value = mock_path
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.api.nfo.find_missing_tags", return_value=[]
|
||||||
|
), patch(
|
||||||
|
"src.server.api.nfo.NfoRepairService"
|
||||||
|
) as MockRepairService:
|
||||||
|
mock_instance = Mock()
|
||||||
|
mock_instance.repair_series = AsyncMock(return_value=False)
|
||||||
|
MockRepairService.return_value = mock_instance
|
||||||
|
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/api/nfo/test-anime/repair", json={}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert "already complete" in data["message"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_creates_new_nfo(
|
||||||
|
self, authenticated_client, override_dependencies, mock_nfo_service
|
||||||
|
):
|
||||||
|
"""Test repair when no NFO exists creates a new one."""
|
||||||
|
with patch("src.server.api.nfo.Path") as MockPath:
|
||||||
|
mock_path = Mock()
|
||||||
|
mock_path.exists.return_value = False
|
||||||
|
mock_path.__truediv__ = Mock(return_value=mock_path)
|
||||||
|
MockPath.return_value = mock_path
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.api.nfo.REQUIRED_TAGS",
|
||||||
|
{"./title": "title", "./plot": "plot"},
|
||||||
|
):
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/api/nfo/test-anime/repair", json={}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
mock_nfo_service.create_tvshow_nfo.assert_awaited_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_nonexistent_series_404(
|
||||||
|
self, authenticated_client, override_dependencies, mock_series_app
|
||||||
|
):
|
||||||
|
"""Test repair for non-existent series returns 404."""
|
||||||
|
mock_series_app.list.GetList.return_value = []
|
||||||
|
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/api/nfo/nonexistent-key/repair", json={}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_unauthenticated_401(self, client):
|
||||||
|
"""Test repair requires authentication."""
|
||||||
|
response = await client.post("/api/nfo/test-anime/repair", json={})
|
||||||
|
assert response.status_code in (401, 503)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_tmdb_api_failure(
|
||||||
|
self, authenticated_client, override_dependencies
|
||||||
|
):
|
||||||
|
"""Test repair handles TMDB API failure gracefully."""
|
||||||
|
from src.core.services.tmdb_client import TMDBAPIError
|
||||||
|
|
||||||
|
with patch("src.server.api.nfo.Path") as MockPath:
|
||||||
|
mock_path = Mock()
|
||||||
|
mock_path.exists.return_value = True
|
||||||
|
mock_path.__truediv__ = Mock(return_value=mock_path)
|
||||||
|
MockPath.return_value = mock_path
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.api.nfo.find_missing_tags",
|
||||||
|
return_value=["plot"],
|
||||||
|
), patch(
|
||||||
|
"src.server.api.nfo.NfoRepairService"
|
||||||
|
) as MockRepairService:
|
||||||
|
mock_instance = Mock()
|
||||||
|
mock_instance.repair_series = AsyncMock(
|
||||||
|
side_effect=TMDBAPIError("No TMDB ID found")
|
||||||
|
)
|
||||||
|
MockRepairService.return_value = mock_instance
|
||||||
|
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/api/nfo/test-anime/repair", json={}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "Cannot repair NFO" in response.json()["detail"]
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Pytest configuration and shared fixtures for all tests."""
|
"""Pytest configuration and shared fixtures for all tests."""
|
||||||
|
|
||||||
|
import logging
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -149,3 +150,44 @@ def mock_series_app_download(monkeypatch):
|
|||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_logging_state():
|
||||||
|
"""Reset logging handlers and propagate flags before and after each test.
|
||||||
|
|
||||||
|
Tests that call setup_logging() or logging.config.dictConfig() may leave
|
||||||
|
FileHandlers and propagate=False on various loggers. This fixture clears
|
||||||
|
handlers and resets propagate for all relevant loggers before/after tests.
|
||||||
|
"""
|
||||||
|
# All loggers that might have handlers or propagate changes from test setup
|
||||||
|
logger_names = (
|
||||||
|
"aniworld", "uvicorn", "uvicorn.access", "uvicorn.error",
|
||||||
|
"watchfiles.main"
|
||||||
|
)
|
||||||
|
|
||||||
|
def clear_logger_state(logger_name):
|
||||||
|
logger = logging.getLogger(logger_name)
|
||||||
|
for handler in logger.handlers[:]:
|
||||||
|
logger.removeHandler(handler)
|
||||||
|
handler.close()
|
||||||
|
# Reset propagate to default (True) for child loggers
|
||||||
|
# Root logger propagate is always True by default
|
||||||
|
if logger_name != "root":
|
||||||
|
logger.propagate = True
|
||||||
|
|
||||||
|
# Clear state BEFORE test
|
||||||
|
for name in logger_names:
|
||||||
|
clear_logger_state(name)
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Clear state AFTER test
|
||||||
|
for name in logger_names:
|
||||||
|
clear_logger_state(name)
|
||||||
|
|
||||||
|
# Also clear root handlers
|
||||||
|
root = logging.getLogger()
|
||||||
|
for handler in root.handlers[:]:
|
||||||
|
root.removeHandler(handler)
|
||||||
|
handler.close()
|
||||||
|
|
||||||
|
|||||||
115
tests/frontend/test_edit_modal.py
Normal file
115
tests/frontend/test_edit_modal.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""Frontend tests for the edit metadata modal HTML structure."""
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from src.server.fastapi_app import app
|
||||||
|
from src.server.services.auth_service import auth_service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_auth():
|
||||||
|
"""Reset authentication state before each test."""
|
||||||
|
original_hash = auth_service._hash
|
||||||
|
auth_service._hash = None
|
||||||
|
auth_service._failed.clear()
|
||||||
|
yield
|
||||||
|
auth_service._hash = original_hash
|
||||||
|
auth_service._failed.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client():
|
||||||
|
"""Create an async test client."""
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def authenticated_client(client):
|
||||||
|
"""Create authenticated client to access index page."""
|
||||||
|
await client.post(
|
||||||
|
"/api/auth/setup",
|
||||||
|
json={"master_password": "TestPassword123!"}
|
||||||
|
)
|
||||||
|
response = await client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"password": "TestPassword123!"}
|
||||||
|
)
|
||||||
|
token = response.json()["access_token"]
|
||||||
|
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||||
|
# Set cookie for page access
|
||||||
|
client.cookies.set("access_token", token)
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
class TestEditModalHtmlPresence:
|
||||||
|
"""Tests verifying edit modal HTML elements exist in index page."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_index_page_contains_edit_modal(self, authenticated_client):
|
||||||
|
"""Verify #edit-metadata-modal exists in rendered index page."""
|
||||||
|
response = await authenticated_client.get("/")
|
||||||
|
|
||||||
|
# Page may redirect or require different auth for HTML pages
|
||||||
|
if response.status_code == 200:
|
||||||
|
html = response.text
|
||||||
|
assert 'id="edit-metadata-modal"' in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_index_page_loads_context_menu_script(self, authenticated_client):
|
||||||
|
"""Verify context-menu.js script tag is present."""
|
||||||
|
response = await authenticated_client.get("/")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
html = response.text
|
||||||
|
assert "context-menu.js" in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_index_page_loads_edit_modal_script(self, authenticated_client):
|
||||||
|
"""Verify edit-modal.js script tag is present."""
|
||||||
|
response = await authenticated_client.get("/")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
html = response.text
|
||||||
|
assert "edit-modal.js" in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modal_form_fields_present(self, authenticated_client):
|
||||||
|
"""Verify key, tmdb_id, tvdb_id input fields exist in modal."""
|
||||||
|
response = await authenticated_client.get("/")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
html = response.text
|
||||||
|
assert 'id="edit-key"' in html
|
||||||
|
assert 'id="edit-tmdb-id"' in html
|
||||||
|
assert 'id="edit-tvdb-id"' in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nfo_repair_button_present(self, authenticated_client):
|
||||||
|
"""Verify repair NFO button exists in modal."""
|
||||||
|
response = await authenticated_client.get("/")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
html = response.text
|
||||||
|
assert 'id="btn-repair-nfo"' in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_button_present(self, authenticated_client):
|
||||||
|
"""Verify save button exists in modal."""
|
||||||
|
response = await authenticated_client.get("/")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
html = response.text
|
||||||
|
assert 'id="btn-save-metadata"' in html
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_modal_starts_hidden(self, authenticated_client):
|
||||||
|
"""Verify modal has hidden class by default."""
|
||||||
|
response = await authenticated_client.get("/")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
html = response.text
|
||||||
|
assert 'id="edit-metadata-modal" class="modal hidden"' in html
|
||||||
314
tests/integration/test_add_anime_nfo_content.py
Normal file
314
tests/integration/test_add_anime_nfo_content.py
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
"""Integration test: add an anime and verify NFO contains required information.
|
||||||
|
|
||||||
|
This test adds 'Sacrificial Princess And The King Of Beasts' and verifies
|
||||||
|
that the generated tvshow.nfo contains all required tags including plot,
|
||||||
|
outline, title, year, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
from src.core.services.nfo_service import NFOService
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mock TMDB data for "Sacrificial Princess And The King Of Beasts"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
MOCK_TMDB_DATA = {
|
||||||
|
"id": 222093,
|
||||||
|
"name": "Sacrificial Princess and the King of Beasts",
|
||||||
|
"original_name": "贄姫と獣の王",
|
||||||
|
"overview": (
|
||||||
|
"A girl is offered as a sacrifice to a beastly king, "
|
||||||
|
"but instead of being eaten, she becomes his bride."
|
||||||
|
),
|
||||||
|
"tagline": "A tale of love between a sacrifice and a beast king.",
|
||||||
|
"first_air_date": "2023-04-20",
|
||||||
|
"vote_average": 7.5,
|
||||||
|
"vote_count": 150,
|
||||||
|
"status": "Ended",
|
||||||
|
"episode_run_time": [24],
|
||||||
|
"genres": [
|
||||||
|
{"id": 16, "name": "Animation"},
|
||||||
|
{"id": 10749, "name": "Romance"},
|
||||||
|
],
|
||||||
|
"networks": [{"id": 1, "name": "TBS"}],
|
||||||
|
"origin_country": ["JP"],
|
||||||
|
"poster_path": "/poster.jpg",
|
||||||
|
"backdrop_path": "/backdrop.jpg",
|
||||||
|
"external_ids": {"imdb_id": "tt19896734", "tvdb_id": 421737},
|
||||||
|
"credits": {
|
||||||
|
"cast": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Test Actor",
|
||||||
|
"character": "Sariphi",
|
||||||
|
"profile_path": "/actor.jpg",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"images": {"logos": [{"file_path": "/logo.png"}]},
|
||||||
|
"seasons": [{"season_number": 1, "name": "Season 1"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
MOCK_CONTENT_RATINGS = {
|
||||||
|
"results": [
|
||||||
|
{"iso_3166_1": "DE", "rating": "12"},
|
||||||
|
{"iso_3166_1": "US", "rating": "TV-14"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Required XML tags that must exist and be non-empty after creation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
REQUIRED_SINGLE_TAGS = [
|
||||||
|
"title",
|
||||||
|
"originaltitle",
|
||||||
|
"sorttitle",
|
||||||
|
"year",
|
||||||
|
"plot",
|
||||||
|
"outline",
|
||||||
|
"runtime",
|
||||||
|
"premiered",
|
||||||
|
"status",
|
||||||
|
"tmdbid",
|
||||||
|
"imdbid",
|
||||||
|
"tvdbid",
|
||||||
|
"dateadded",
|
||||||
|
"watched",
|
||||||
|
"mpaa",
|
||||||
|
"tagline",
|
||||||
|
]
|
||||||
|
|
||||||
|
REQUIRED_MULTI_TAGS = [
|
||||||
|
"genre",
|
||||||
|
"studio",
|
||||||
|
"country",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anime_dir(tmp_path: Path) -> Path:
|
||||||
|
"""Temporary anime root directory."""
|
||||||
|
d = tmp_path / "anime"
|
||||||
|
d.mkdir()
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def nfo_service(anime_dir: Path) -> NFOService:
|
||||||
|
"""NFOService pointing at the temp directory."""
|
||||||
|
return NFOService(
|
||||||
|
tmdb_api_key="test_api_key",
|
||||||
|
anime_directory=str(anime_dir),
|
||||||
|
image_size="w500",
|
||||||
|
auto_create=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddAnimeNFOContent:
|
||||||
|
"""Test that adding an anime produces an NFO with required information."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_anime_nfo_contains_required_tags(
|
||||||
|
self,
|
||||||
|
nfo_service: NFOService,
|
||||||
|
anime_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Add 'Sacrificial Princess And The King Of Beasts' and verify NFO.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Create the series folder on disk.
|
||||||
|
2. Mock TMDB API responses.
|
||||||
|
3. Call create_tvshow_nfo to generate the NFO.
|
||||||
|
4. Parse the resulting XML and assert every required tag is present
|
||||||
|
and non-empty.
|
||||||
|
"""
|
||||||
|
series_key = "sacrificial-princess-and-the-king-of-beasts"
|
||||||
|
series_name = "Sacrificial Princess And The King Of Beasts"
|
||||||
|
series_folder = f"{series_name} (2023)"
|
||||||
|
|
||||||
|
# Step 1: Create series folder
|
||||||
|
series_path = anime_dir / series_folder
|
||||||
|
series_path.mkdir()
|
||||||
|
|
||||||
|
# Step 2: Mock TMDB API calls
|
||||||
|
with patch.object(
|
||||||
|
nfo_service.tmdb_client,
|
||||||
|
"search_tv_show",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_search, patch.object(
|
||||||
|
nfo_service.tmdb_client,
|
||||||
|
"get_tv_show_details",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_details, patch.object(
|
||||||
|
nfo_service.tmdb_client,
|
||||||
|
"get_tv_show_content_ratings",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_ratings, patch.object(
|
||||||
|
nfo_service.image_downloader,
|
||||||
|
"download_all_media",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_download:
|
||||||
|
|
||||||
|
mock_search.return_value = {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 222093,
|
||||||
|
"name": series_name,
|
||||||
|
"first_air_date": "2023-04-20",
|
||||||
|
"overview": (
|
||||||
|
"A girl is offered as a sacrifice to a beastly king..."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_details.return_value = MOCK_TMDB_DATA
|
||||||
|
mock_ratings.return_value = MOCK_CONTENT_RATINGS
|
||||||
|
mock_download.return_value = {
|
||||||
|
"poster": True,
|
||||||
|
"logo": True,
|
||||||
|
"fanart": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 3: Create NFO
|
||||||
|
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||||
|
serie_name=series_name,
|
||||||
|
serie_folder=series_folder,
|
||||||
|
year=2023,
|
||||||
|
download_poster=True,
|
||||||
|
download_logo=True,
|
||||||
|
download_fanart=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify NFO was created
|
||||||
|
assert nfo_path.exists(), f"NFO file not created at {nfo_path}"
|
||||||
|
assert nfo_path.name == "tvshow.nfo"
|
||||||
|
|
||||||
|
# Step 4: Parse NFO XML and verify required tags
|
||||||
|
nfo_content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
root = etree.fromstring(nfo_content.encode("utf-8"))
|
||||||
|
|
||||||
|
missing: list[str] = []
|
||||||
|
for tag in REQUIRED_SINGLE_TAGS:
|
||||||
|
elem = root.find(f".//{tag}")
|
||||||
|
if elem is None or not (elem.text or "").strip():
|
||||||
|
missing.append(tag)
|
||||||
|
|
||||||
|
for tag in REQUIRED_MULTI_TAGS:
|
||||||
|
elems = root.findall(f".//{tag}")
|
||||||
|
if not elems or not any((e.text or "").strip() for e in elems):
|
||||||
|
missing.append(tag)
|
||||||
|
|
||||||
|
# At least one actor must be present
|
||||||
|
actors = root.findall(".//actor/name")
|
||||||
|
if not actors or not any((a.text or "").strip() for a in actors):
|
||||||
|
missing.append("actor/name")
|
||||||
|
|
||||||
|
assert not missing, (
|
||||||
|
f"Missing or empty required tags in NFO for '{series_name}':\n "
|
||||||
|
+ "\n ".join(missing)
|
||||||
|
+ f"\n\nFull NFO content:\n{nfo_content}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify specific values for the requested anime
|
||||||
|
assert root.findtext(".//title") == "Sacrificial Princess and the King of Beasts"
|
||||||
|
assert root.findtext(".//year") == "2023"
|
||||||
|
assert root.findtext(".//status") == "Ended"
|
||||||
|
assert root.findtext(".//watched") == "false"
|
||||||
|
assert root.findtext(".//tmdbid") == "222093"
|
||||||
|
assert root.findtext(".//imdbid") == "tt19896734"
|
||||||
|
assert root.findtext(".//tvdbid") == "421737"
|
||||||
|
|
||||||
|
# Plot and outline must be non-trivial
|
||||||
|
plot = root.findtext(".//plot") or ""
|
||||||
|
outline = root.findtext(".//outline") or ""
|
||||||
|
assert len(plot) >= 10, f"plot too short: {plot!r}"
|
||||||
|
assert len(outline) >= 10, f"outline too short: {outline!r}"
|
||||||
|
|
||||||
|
# Verify multi-value fields
|
||||||
|
genres = [g.text for g in root.findall(".//genre") if g.text]
|
||||||
|
assert "Animation" in genres
|
||||||
|
assert "Romance" in genres
|
||||||
|
|
||||||
|
studios = [s.text for s in root.findall(".//studio") if s.text]
|
||||||
|
assert "TBS" in studios
|
||||||
|
|
||||||
|
countries = [c.text for c in root.findall(".//country") if c.text]
|
||||||
|
assert "JP" in countries
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_anime_nfo_has_plot_and_outline(
|
||||||
|
self,
|
||||||
|
nfo_service: NFOService,
|
||||||
|
anime_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Specifically verify that plot and outline tags are populated.
|
||||||
|
|
||||||
|
This is a focused regression test ensuring the NFO always contains
|
||||||
|
meaningful plot and outline data.
|
||||||
|
"""
|
||||||
|
series_name = "Sacrificial Princess And The King Of Beasts"
|
||||||
|
series_folder = f"{series_name} (2023)"
|
||||||
|
series_path = anime_dir / series_folder
|
||||||
|
series_path.mkdir()
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
nfo_service.tmdb_client,
|
||||||
|
"search_tv_show",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_search, patch.object(
|
||||||
|
nfo_service.tmdb_client,
|
||||||
|
"get_tv_show_details",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_details, patch.object(
|
||||||
|
nfo_service.tmdb_client,
|
||||||
|
"get_tv_show_content_ratings",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_ratings, patch.object(
|
||||||
|
nfo_service.image_downloader,
|
||||||
|
"download_all_media",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_download:
|
||||||
|
|
||||||
|
mock_search.return_value = {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 222093,
|
||||||
|
"name": series_name,
|
||||||
|
"first_air_date": "2023-04-20",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_details.return_value = MOCK_TMDB_DATA
|
||||||
|
mock_ratings.return_value = MOCK_CONTENT_RATINGS
|
||||||
|
mock_download.return_value = {"poster": False, "logo": False, "fanart": False}
|
||||||
|
|
||||||
|
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||||
|
serie_name=series_name,
|
||||||
|
serie_folder=series_folder,
|
||||||
|
year=2023,
|
||||||
|
download_poster=False,
|
||||||
|
download_logo=False,
|
||||||
|
download_fanart=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert nfo_path.exists()
|
||||||
|
root = etree.parse(str(nfo_path)).getroot()
|
||||||
|
|
||||||
|
plot_elem = root.find(".//plot")
|
||||||
|
outline_elem = root.find(".//outline")
|
||||||
|
|
||||||
|
assert plot_elem is not None, "<plot> tag missing from NFO"
|
||||||
|
assert outline_elem is not None, "<outline> tag missing from NFO"
|
||||||
|
|
||||||
|
plot_text = (plot_elem.text or "").strip()
|
||||||
|
outline_text = (outline_elem.text or "").strip()
|
||||||
|
|
||||||
|
assert plot_text, "<plot> tag is empty"
|
||||||
|
assert outline_text, "<outline> tag is empty"
|
||||||
|
assert "sacrifice" in plot_text.lower() or "beast" in plot_text.lower(), (
|
||||||
|
f"plot does not contain expected content: {plot_text!r}"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -111,17 +111,17 @@ class TestGetAllSeriesFromDataFiles:
|
|||||||
|
|
||||||
|
|
||||||
class TestSyncSeriesToDatabase:
|
class TestSyncSeriesToDatabase:
|
||||||
"""Test sync_series_from_data_files function from anime_service."""
|
"""Test sync_legacy_series_to_db function from anime_service."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sync_with_empty_directory(self):
|
async def test_sync_with_empty_directory(self):
|
||||||
"""Test sync with empty anime directory."""
|
"""Test sync with empty anime directory."""
|
||||||
from src.server.services.anime_service import sync_series_from_data_files
|
from src.server.services.anime_service import sync_legacy_series_to_db
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
with patch('src.core.SeriesApp.Loaders'), \
|
with patch('src.core.SeriesApp.Loaders'), \
|
||||||
patch('src.core.SeriesApp.SerieScanner'):
|
patch('src.core.SeriesApp.SerieScanner'):
|
||||||
count = await sync_series_from_data_files(tmp_dir)
|
count = await sync_legacy_series_to_db(tmp_dir)
|
||||||
|
|
||||||
assert count == 0
|
assert count == 0
|
||||||
# Function should complete successfully with no series
|
# Function should complete successfully with no series
|
||||||
@@ -134,7 +134,7 @@ class TestSyncSeriesToDatabase:
|
|||||||
from files and the sync function attempts to add them to the DB.
|
from files and the sync function attempts to add them to the DB.
|
||||||
The actual DB interaction is tested in test_add_to_db_creates_record.
|
The actual DB interaction is tested in test_add_to_db_creates_record.
|
||||||
"""
|
"""
|
||||||
from src.server.services.anime_service import sync_series_from_data_files
|
from src.server.services.anime_service import sync_legacy_series_to_db
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
# Create test data files
|
# Create test data files
|
||||||
@@ -160,7 +160,7 @@ class TestSyncSeriesToDatabase:
|
|||||||
patch('src.core.SeriesApp.SerieScanner'):
|
patch('src.core.SeriesApp.SerieScanner'):
|
||||||
# The function should return 0 because DB isn't available
|
# The function should return 0 because DB isn't available
|
||||||
# but should not crash
|
# but should not crash
|
||||||
count = await sync_series_from_data_files(tmp_dir)
|
count = await sync_legacy_series_to_db(tmp_dir)
|
||||||
|
|
||||||
# Since no real DB, it will fail gracefully
|
# Since no real DB, it will fail gracefully
|
||||||
# Function returns 0 when DB operations fail
|
# Function returns 0 when DB operations fail
|
||||||
@@ -170,7 +170,7 @@ class TestSyncSeriesToDatabase:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sync_handles_exceptions_gracefully(self):
|
async def test_sync_handles_exceptions_gracefully(self):
|
||||||
"""Test that sync handles exceptions without crashing."""
|
"""Test that sync handles exceptions without crashing."""
|
||||||
from src.server.services.anime_service import sync_series_from_data_files
|
from src.server.services.anime_service import sync_legacy_series_to_db
|
||||||
|
|
||||||
# Make SeriesApp raise an exception during initialization
|
# Make SeriesApp raise an exception during initialization
|
||||||
with patch('src.core.SeriesApp.Loaders'), \
|
with patch('src.core.SeriesApp.Loaders'), \
|
||||||
@@ -179,7 +179,7 @@ class TestSyncSeriesToDatabase:
|
|||||||
'src.core.SeriesApp.SerieList',
|
'src.core.SeriesApp.SerieList',
|
||||||
side_effect=Exception("Test error")
|
side_effect=Exception("Test error")
|
||||||
):
|
):
|
||||||
count = await sync_series_from_data_files("/fake/path")
|
count = await sync_legacy_series_to_db("/fake/path")
|
||||||
|
|
||||||
assert count == 0
|
assert count == 0
|
||||||
# Function should complete without crashing
|
# Function should complete without crashing
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class TestInitializationWorkflow:
|
|||||||
async def test_perform_initial_setup_with_mocked_dependencies(self):
|
async def test_perform_initial_setup_with_mocked_dependencies(self):
|
||||||
"""Test initial setup completes with minimal mocking."""
|
"""Test initial setup completes with minimal mocking."""
|
||||||
# Mock only the external dependencies
|
# Mock only the external dependencies
|
||||||
with patch('src.server.services.anime_service.sync_series_from_data_files') as mock_sync:
|
with patch('src.server.services.anime_service.sync_legacy_series_to_db') as mock_sync:
|
||||||
mock_sync.return_value = 0 # No series to sync
|
mock_sync.return_value = 0 # No series to sync
|
||||||
|
|
||||||
# Call the actual function
|
# Call the actual function
|
||||||
@@ -241,9 +241,9 @@ class TestModuleStructure:
|
|||||||
assert hasattr(initialization_service, 'settings')
|
assert hasattr(initialization_service, 'settings')
|
||||||
|
|
||||||
def test_sync_series_function_imported(self):
|
def test_sync_series_function_imported(self):
|
||||||
"""Test sync_series_from_data_files is imported."""
|
"""Test sync_legacy_series_to_db is imported."""
|
||||||
assert hasattr(initialization_service, 'sync_series_from_data_files')
|
assert hasattr(initialization_service, 'sync_legacy_series_to_db')
|
||||||
assert callable(initialization_service.sync_series_from_data_files)
|
assert callable(initialization_service.sync_legacy_series_to_db)
|
||||||
|
|
||||||
|
|
||||||
# Simpler integration tests that don't require complex mocking
|
# Simpler integration tests that don't require complex mocking
|
||||||
@@ -413,7 +413,7 @@ class TestInitialSetupWorkflow:
|
|||||||
async def test_initial_setup_already_completed(self):
|
async def test_initial_setup_already_completed(self):
|
||||||
"""Test initial setup when already completed."""
|
"""Test initial setup when already completed."""
|
||||||
with patch.object(initialization_service, '_check_initial_scan_status', return_value=True), \
|
with patch.object(initialization_service, '_check_initial_scan_status', return_value=True), \
|
||||||
patch('src.server.services.anime_service.sync_series_from_data_files'):
|
patch('src.server.services.anime_service.sync_legacy_series_to_db'):
|
||||||
|
|
||||||
result = await initialization_service.perform_initial_setup()
|
result = await initialization_service.perform_initial_setup()
|
||||||
|
|
||||||
@@ -425,7 +425,7 @@ class TestInitialSetupWorkflow:
|
|||||||
"""Test initial setup with no directory configured."""
|
"""Test initial setup with no directory configured."""
|
||||||
with patch.object(initialization_service, '_check_initial_scan_status', return_value=False), \
|
with patch.object(initialization_service, '_check_initial_scan_status', return_value=False), \
|
||||||
patch.object(initialization_service, '_validate_anime_directory', return_value=False), \
|
patch.object(initialization_service, '_validate_anime_directory', return_value=False), \
|
||||||
patch('src.server.services.anime_service.sync_series_from_data_files'):
|
patch('src.server.services.anime_service.sync_legacy_series_to_db'):
|
||||||
|
|
||||||
result = await initialization_service.perform_initial_setup()
|
result = await initialization_service.perform_initial_setup()
|
||||||
|
|
||||||
@@ -440,7 +440,7 @@ class TestInitialSetupWorkflow:
|
|||||||
patch.object(initialization_service, '_sync_anime_folders', return_value=5), \
|
patch.object(initialization_service, '_sync_anime_folders', return_value=5), \
|
||||||
patch.object(initialization_service, '_mark_initial_scan_completed'), \
|
patch.object(initialization_service, '_mark_initial_scan_completed'), \
|
||||||
patch.object(initialization_service, '_load_series_into_memory'), \
|
patch.object(initialization_service, '_load_series_into_memory'), \
|
||||||
patch('src.server.services.anime_service.sync_series_from_data_files'):
|
patch('src.server.services.anime_service.sync_legacy_series_to_db'):
|
||||||
|
|
||||||
mock_progress = AsyncMock()
|
mock_progress = AsyncMock()
|
||||||
result = await initialization_service.perform_initial_setup(mock_progress)
|
result = await initialization_service.perform_initial_setup(mock_progress)
|
||||||
@@ -456,7 +456,7 @@ class TestInitialSetupWorkflow:
|
|||||||
with patch.object(initialization_service, '_check_initial_scan_status', return_value=False), \
|
with patch.object(initialization_service, '_check_initial_scan_status', return_value=False), \
|
||||||
patch.object(initialization_service, '_validate_anime_directory', return_value=True), \
|
patch.object(initialization_service, '_validate_anime_directory', return_value=True), \
|
||||||
patch.object(initialization_service, '_sync_anime_folders', side_effect=OSError("Disk error")), \
|
patch.object(initialization_service, '_sync_anime_folders', side_effect=OSError("Disk error")), \
|
||||||
patch('src.server.services.anime_service.sync_series_from_data_files'):
|
patch('src.server.services.anime_service.sync_legacy_series_to_db'):
|
||||||
|
|
||||||
result = await initialization_service.perform_initial_setup()
|
result = await initialization_service.perform_initial_setup()
|
||||||
|
|
||||||
@@ -469,7 +469,7 @@ class TestInitialSetupWorkflow:
|
|||||||
with patch.object(initialization_service, '_check_initial_scan_status', return_value=False), \
|
with patch.object(initialization_service, '_check_initial_scan_status', return_value=False), \
|
||||||
patch.object(initialization_service, '_validate_anime_directory', return_value=True), \
|
patch.object(initialization_service, '_validate_anime_directory', return_value=True), \
|
||||||
patch.object(initialization_service, '_sync_anime_folders', side_effect=RuntimeError("DB error")), \
|
patch.object(initialization_service, '_sync_anime_folders', side_effect=RuntimeError("DB error")), \
|
||||||
patch('src.server.services.anime_service.sync_series_from_data_files'):
|
patch('src.server.services.anime_service.sync_legacy_series_to_db'):
|
||||||
|
|
||||||
result = await initialization_service.perform_initial_setup()
|
result = await initialization_service.perform_initial_setup()
|
||||||
|
|
||||||
|
|||||||
333
tests/integration/test_episode_download_sync.py
Normal file
333
tests/integration/test_episode_download_sync.py
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
"""Integration tests for episode download sync with data file updates.
|
||||||
|
|
||||||
|
Tests verify that when episodes are downloaded successfully:
|
||||||
|
- In-memory Serie.episodeDict is updated
|
||||||
|
- Deprecated data file is updated (if it exists)
|
||||||
|
- Missing episode list reflects the change immediately
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.entities.series import Serie
|
||||||
|
from src.core.SeriesApp import SeriesApp
|
||||||
|
from src.server.models.download import DownloadItem, DownloadPriority, DownloadStatus
|
||||||
|
from src.server.services.download_service import DownloadService
|
||||||
|
|
||||||
|
|
||||||
|
class TestEpisodeRemovedFromMissingListAfterDownload:
|
||||||
|
"""Verify episode no longer appears in missing list after download completes."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_dir(self):
|
||||||
|
"""Create temp directory for test data files."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yield Path(tmp)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_anime_service(self, temp_dir):
|
||||||
|
"""Create mock anime service with app."""
|
||||||
|
anime_service = MagicMock()
|
||||||
|
anime_service._directory = str(temp_dir)
|
||||||
|
|
||||||
|
# Create mock app withSerie with missing episodes
|
||||||
|
serie = Serie(
|
||||||
|
key="test-series",
|
||||||
|
name="Test Series",
|
||||||
|
site="https://example.com",
|
||||||
|
folder="Test Series",
|
||||||
|
episodeDict={1: [1, 2, 3]},
|
||||||
|
)
|
||||||
|
mock_app = MagicMock()
|
||||||
|
mock_app.list.keyDict = {"test-series": serie}
|
||||||
|
mock_app.list.GetMissingEpisode.return_value = [serie]
|
||||||
|
mock_app.series_list = [serie]
|
||||||
|
anime_service._app = mock_app
|
||||||
|
anime_service._cached_list_missing = MagicMock()
|
||||||
|
anime_service._broadcast_series_updated = AsyncMock()
|
||||||
|
|
||||||
|
return anime_service
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_download_service(self, mock_anime_service):
|
||||||
|
"""Create download service with mocked dependencies."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
service = DownloadService(
|
||||||
|
anime_service=mock_anime_service,
|
||||||
|
queue_repository=MagicMock(),
|
||||||
|
max_retries=3,
|
||||||
|
)
|
||||||
|
service._directory = tmp
|
||||||
|
yield service
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_episode_removed_from_missing_list_after_download(
|
||||||
|
self, mock_download_service, mock_anime_service
|
||||||
|
):
|
||||||
|
"""Verify episode no longer appears in missing list after download completes."""
|
||||||
|
serie = mock_anime_service._app.list.keyDict["test-series"]
|
||||||
|
|
||||||
|
# Verify episode starts in missing list
|
||||||
|
assert 2 in serie.episodeDict[1], "Episode should start in missing list"
|
||||||
|
|
||||||
|
# Simulate download completion by calling _remove_episode_from_memory
|
||||||
|
mock_download_service._remove_episode_from_memory("test-series", 1, 2)
|
||||||
|
|
||||||
|
# Episode should be removed from episodeDict
|
||||||
|
assert 2 not in serie.episodeDict[1], "Episode should be removed from missing list"
|
||||||
|
assert serie.episodeDict[1] == [1, 3]
|
||||||
|
|
||||||
|
# series_list should be refreshed
|
||||||
|
mock_anime_service._app.list.GetMissingEpisode.assert_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDownloadUpdatesInMemoryCache:
|
||||||
|
"""Verify in-memory Serie.episodeDict is updated after download."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_anime_service(self):
|
||||||
|
"""Create mock anime service with app."""
|
||||||
|
anime_service = MagicMock()
|
||||||
|
anime_service._directory = "/tmp/test"
|
||||||
|
|
||||||
|
# Create mock app with series having multiple seasons and episodes
|
||||||
|
serie = Serie(
|
||||||
|
key="multi-season-series",
|
||||||
|
name="Multi Season Series",
|
||||||
|
site="https://example.com",
|
||||||
|
folder="Multi Season Series",
|
||||||
|
episodeDict={
|
||||||
|
1: [1, 2, 3, 4, 5],
|
||||||
|
2: [1, 2, 3],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mock_app = MagicMock()
|
||||||
|
mock_app.list.keyDict = {"multi-season-series": serie}
|
||||||
|
mock_app.list.GetMissingEpisode.return_value = [serie]
|
||||||
|
mock_app.series_list = [serie]
|
||||||
|
anime_service._app = mock_app
|
||||||
|
anime_service._cached_list_missing = MagicMock()
|
||||||
|
anime_service._broadcast_series_updated = AsyncMock()
|
||||||
|
|
||||||
|
return anime_service
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_download_service(self, mock_anime_service):
|
||||||
|
"""Create download service with mocked dependencies."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
service = DownloadService(
|
||||||
|
anime_service=mock_anime_service,
|
||||||
|
queue_repository=MagicMock(),
|
||||||
|
max_retries=3,
|
||||||
|
)
|
||||||
|
service._directory = tmp
|
||||||
|
yield service
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_updates_in_memory_cache(
|
||||||
|
self, mock_download_service, mock_anime_service
|
||||||
|
):
|
||||||
|
"""Verify in-memory Serie.episodeDict is updated after download."""
|
||||||
|
# First reset to known state (remove the defaults first call might have set)
|
||||||
|
serie = mock_anime_service._app.list.keyDict["multi-season-series"]
|
||||||
|
|
||||||
|
# Put back episodes after the fixture setup
|
||||||
|
serie.episodeDict = {1: [1, 2, 3, 4, 5], 2: [1, 2, 3]}
|
||||||
|
|
||||||
|
# Verify preconditions
|
||||||
|
assert 1 in serie.episodeDict[1]
|
||||||
|
assert 3 in serie.episodeDict[2]
|
||||||
|
|
||||||
|
# Simulate downloading multiple episodes
|
||||||
|
mock_download_service._remove_episode_from_memory("multi-season-series", 1, 1)
|
||||||
|
mock_download_service._remove_episode_from_memory("multi-season-series", 1, 3)
|
||||||
|
mock_download_service._remove_episode_from_memory("multi-season-series", 2, 2)
|
||||||
|
|
||||||
|
# Verify episodes removed
|
||||||
|
assert 1 not in serie.episodeDict[1], "Episode 1 of season 1 should be removed"
|
||||||
|
assert 3 not in serie.episodeDict[1], "Episode 3 of season 1 should be removed"
|
||||||
|
assert 2 in serie.episodeDict[1], "Episode 2 of season 1 should remain"
|
||||||
|
assert 3 in serie.episodeDict[2], "Episode 3 of season 2 should remain"
|
||||||
|
assert 2 not in serie.episodeDict[2], "Episode 2 of season 2 should be removed"
|
||||||
|
|
||||||
|
# Verify seasons with no episodes are cleaned up
|
||||||
|
assert 2 in serie.episodeDict, "Season 2 should still exist (has episode 1, 3)"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_last_episode_removes_season(
|
||||||
|
self, mock_download_service, mock_anime_service
|
||||||
|
):
|
||||||
|
"""Verify that removing last episode in a season removes the season key."""
|
||||||
|
# Modify the series so season 1 only has episode 2 left
|
||||||
|
serie = mock_anime_service._app.list.keyDict["multi-season-series"]
|
||||||
|
# Reset and set to proper test state
|
||||||
|
serie.episodeDict = {1: [2], 2: [1, 2, 3]} # Season 1 only has episode 2
|
||||||
|
|
||||||
|
# Verify initial state
|
||||||
|
assert 2 in serie.episodeDict[1]
|
||||||
|
assert 2 in serie.episodeDict[2]
|
||||||
|
|
||||||
|
# Remove last episode of season 1 (episode 2)
|
||||||
|
mock_download_service._remove_episode_from_memory("multi-season-series", 1, 2)
|
||||||
|
|
||||||
|
# Season 1 should be completely removed
|
||||||
|
assert 1 not in serie.episodeDict, "Season 1 should be removed"
|
||||||
|
# Season 2 should still exist
|
||||||
|
assert 2 in serie.episodeDict, "Season 2 should still exist"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDataFileUpdatedAfterDownload:
|
||||||
|
"""Verify data file is updated after download (when it exists)."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_dir(self):
|
||||||
|
"""Create temp directory for test data files."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yield Path(tmp)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_anime_service(self, temp_dir):
|
||||||
|
"""Create mock anime service with app."""
|
||||||
|
anime_service = MagicMock()
|
||||||
|
anime_service._directory = str(temp_dir)
|
||||||
|
|
||||||
|
# Create series folder with data file
|
||||||
|
series_folder = temp_dir / "Test Series"
|
||||||
|
series_folder.mkdir()
|
||||||
|
data_path = series_folder / "data"
|
||||||
|
|
||||||
|
serie = Serie(
|
||||||
|
key="test-series-with-data",
|
||||||
|
name="Test Series",
|
||||||
|
site="https://example.com",
|
||||||
|
folder="Test Series",
|
||||||
|
episodeDict={1: [1, 2, 3]},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save data file to disk
|
||||||
|
import warnings
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore", DeprecationWarning)
|
||||||
|
serie.save_to_file(str(data_path))
|
||||||
|
|
||||||
|
# Update episodeDict to simulate in-progress download state
|
||||||
|
# (episodeDict still has all episodes; will be updated after download)
|
||||||
|
mock_app = MagicMock()
|
||||||
|
mock_app.list.keyDict = {"test-series-with-data": serie}
|
||||||
|
mock_app.list.GetMissingEpisode.return_value = [serie]
|
||||||
|
mock_app.series_list = [serie]
|
||||||
|
anime_service._app = mock_app
|
||||||
|
anime_service._cached_list_missing = MagicMock()
|
||||||
|
anime_service._broadcast_series_updated = AsyncMock()
|
||||||
|
|
||||||
|
return anime_service
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_download_service(self, mock_anime_service):
|
||||||
|
"""Create download service with mocked dependencies."""
|
||||||
|
service = DownloadService(
|
||||||
|
anime_service=mock_anime_service,
|
||||||
|
queue_repository=MagicMock(),
|
||||||
|
max_retries=3,
|
||||||
|
)
|
||||||
|
service._directory = str(mock_anime_service._directory)
|
||||||
|
yield service
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_data_file_updated_after_download(
|
||||||
|
self, mock_download_service, mock_anime_service, temp_dir
|
||||||
|
):
|
||||||
|
"""Verify data file is updated after download when data file exists."""
|
||||||
|
serie = mock_anime_service._app.list.keyDict["test-series-with-data"]
|
||||||
|
data_path = temp_dir / "Test Series" / "data"
|
||||||
|
|
||||||
|
# Verify data file exists before test
|
||||||
|
assert data_path.exists(), "Data file should exist before test"
|
||||||
|
|
||||||
|
# Read original data file
|
||||||
|
with open(data_path) as f:
|
||||||
|
original_data = json.load(f)
|
||||||
|
assert 2 in original_data["episodeDict"]["1"], "Episode should be in original data"
|
||||||
|
|
||||||
|
# Simulate download completion
|
||||||
|
mock_download_service._remove_episode_from_memory("test-series-with-data", 1, 2)
|
||||||
|
|
||||||
|
# Read updated data file
|
||||||
|
with open(data_path) as f:
|
||||||
|
updated_data = json.load(f)
|
||||||
|
|
||||||
|
# Verify episode 2 was removed from data file
|
||||||
|
assert 2 not in updated_data["episodeDict"]["1"], "Episode should be removed from data file"
|
||||||
|
assert updated_data["episodeDict"]["1"] == [1, 3]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDataFileNotRequiredForDownload:
|
||||||
|
"""Verify downloads work even when data file doesn't exist."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_dir(self):
|
||||||
|
"""Create temp directory without data files."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yield Path(tmp)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_anime_service(self, temp_dir):
|
||||||
|
"""Create mock anime service with app but no data file."""
|
||||||
|
anime_service = MagicMock()
|
||||||
|
anime_service._directory = str(temp_dir)
|
||||||
|
|
||||||
|
# Create series with NO data file on disk (only in memory)
|
||||||
|
serie = Serie(
|
||||||
|
key="memory-only-series",
|
||||||
|
name="Memory Only Series",
|
||||||
|
site="https://example.com",
|
||||||
|
folder="Memory Only Series",
|
||||||
|
episodeDict={1: [1, 2, 3]},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_app = MagicMock()
|
||||||
|
mock_app.list.keyDict = {"memory-only-series": serie}
|
||||||
|
mock_app.list.GetMissingEpisode.return_value = [serie]
|
||||||
|
mock_app.series_list = [serie]
|
||||||
|
anime_service._app = mock_app
|
||||||
|
anime_service._cached_list_missing = MagicMock()
|
||||||
|
anime_service._broadcast_series_updated = AsyncMock()
|
||||||
|
|
||||||
|
return anime_service
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_download_service(self, mock_anime_service):
|
||||||
|
"""Create download service with mocked dependencies."""
|
||||||
|
service = DownloadService(
|
||||||
|
anime_service=mock_anime_service,
|
||||||
|
queue_repository=MagicMock(),
|
||||||
|
max_retries=3,
|
||||||
|
)
|
||||||
|
service._directory = str(mock_anime_service._directory)
|
||||||
|
yield service
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_works_without_data_file(
|
||||||
|
self, mock_download_service, mock_anime_service
|
||||||
|
):
|
||||||
|
"""Verify downloads work even when no data file exists on disk."""
|
||||||
|
serie = mock_anime_service._app.list.keyDict["memory-only-series"]
|
||||||
|
data_path = Path(mock_anime_service._directory) / "Memory Only Series" / "data"
|
||||||
|
|
||||||
|
# Verify no data file exists
|
||||||
|
assert not data_path.exists(), "No data file should exist"
|
||||||
|
|
||||||
|
# Simulate download completion
|
||||||
|
# This should NOT raise even without data file
|
||||||
|
mock_download_service._remove_episode_from_memory("memory-only-series", 1, 2)
|
||||||
|
|
||||||
|
# Episode should be removed from in-memory state
|
||||||
|
assert 2 not in serie.episodeDict[1], "Episode should be removed from memory"
|
||||||
|
|
||||||
|
# Data file should still not exist (no file created)
|
||||||
|
assert not data_path.exists(), "No data file should be created"
|
||||||
109
tests/integration/test_folder_rename_startup.py
Normal file
109
tests/integration/test_folder_rename_startup.py
Normal 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()
|
||||||
335
tests/integration/test_legacy_migration.py
Normal file
335
tests/integration/test_legacy_migration.py
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
"""Integration tests for legacy key/data file migration.
|
||||||
|
|
||||||
|
Tests the one-time migration safety net that imports series from
|
||||||
|
legacy key and data files into the database.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.server.services.legacy_file_migration import (
|
||||||
|
_load_data_file,
|
||||||
|
_load_key_file,
|
||||||
|
migrate_series_from_files_to_db,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadLegacyFiles:
|
||||||
|
"""Test helper functions for loading legacy files."""
|
||||||
|
|
||||||
|
def test_load_data_file_valid_json(self):
|
||||||
|
"""Test loading a valid JSON data file."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
data_file = os.path.join(tmp_dir, "data")
|
||||||
|
test_data = {
|
||||||
|
"key": "test-anime",
|
||||||
|
"name": "Test Anime",
|
||||||
|
"site": "https://aniworld.to",
|
||||||
|
"folder": "Test Anime",
|
||||||
|
"episodeDict": {"1": [1, 2, 3]}
|
||||||
|
}
|
||||||
|
with open(data_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(test_data, f)
|
||||||
|
|
||||||
|
result = _load_data_file(data_file)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result["key"] == "test-anime"
|
||||||
|
assert result["name"] == "Test Anime"
|
||||||
|
# episodeDict keys should be converted to int
|
||||||
|
assert 1 in result["episodeDict"]
|
||||||
|
|
||||||
|
def test_load_data_file_invalid_json(self):
|
||||||
|
"""Test handling of corrupt JSON data file."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
data_file = os.path.join(tmp_dir, "data")
|
||||||
|
with open(data_file, "w", encoding="utf-8") as f:
|
||||||
|
f.write("this is not valid json {{{")
|
||||||
|
|
||||||
|
result = _load_data_file(data_file)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_load_data_file_not_dict(self):
|
||||||
|
"""Test handling of JSON file that is not a dict."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
data_file = os.path.join(tmp_dir, "data")
|
||||||
|
with open(data_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(["not", "a", "dict"], f)
|
||||||
|
|
||||||
|
result = _load_data_file(data_file)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_load_key_file_valid(self):
|
||||||
|
"""Test loading a key file with valid content."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
key_file = os.path.join(tmp_dir, "key")
|
||||||
|
with open(key_file, "w", encoding="utf-8") as f:
|
||||||
|
f.write("my-anime-key")
|
||||||
|
|
||||||
|
result = _load_key_file(key_file, "My Anime")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result["key"] == "my-anime-key"
|
||||||
|
assert result["name"] == "My Anime"
|
||||||
|
assert result["site"] == "https://aniworld.to"
|
||||||
|
assert result["episodeDict"] == {}
|
||||||
|
|
||||||
|
def test_load_key_file_empty(self):
|
||||||
|
"""Test handling of empty key file."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
key_file = os.path.join(tmp_dir, "key")
|
||||||
|
with open(key_file, "w", encoding="utf-8") as f:
|
||||||
|
f.write("")
|
||||||
|
|
||||||
|
result = _load_key_file(key_file, "My Anime")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMigrateLegacyFiles:
|
||||||
|
"""Test the main migration function with database."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migrate_series_from_files_to_db_no_files(self):
|
||||||
|
"""Test migration with empty directory returns 0."""
|
||||||
|
mock_db = AsyncMock()
|
||||||
|
mock_db.execute = AsyncMock()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
count = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migrate_data_file_to_db(self):
|
||||||
|
"""Test migration of a legacy data file."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
# Create a folder with a data file
|
||||||
|
anime_folder = os.path.join(tmp_dir, "Test Anime")
|
||||||
|
os.makedirs(anime_folder, exist_ok=True)
|
||||||
|
|
||||||
|
data_file = os.path.join(anime_folder, "data")
|
||||||
|
test_data = {
|
||||||
|
"key": "migrate-test-anime",
|
||||||
|
"name": "Migrate Test Anime",
|
||||||
|
"site": "https://aniworld.to",
|
||||||
|
"folder": "Test Anime",
|
||||||
|
"episodeDict": {"1": [1, 2]}
|
||||||
|
}
|
||||||
|
with open(data_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(test_data, f)
|
||||||
|
|
||||||
|
# Mock the DB session and services
|
||||||
|
mock_db = AsyncMock()
|
||||||
|
mock_series_service = AsyncMock()
|
||||||
|
mock_episode_service = AsyncMock()
|
||||||
|
|
||||||
|
# Mock get_by_key returning None (not in DB)
|
||||||
|
mock_series_service.get_by_key = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
# Mock AnimeSeriesService.create returning a mock with id=1
|
||||||
|
mock_created_series = MagicMock()
|
||||||
|
mock_created_series.id = 1
|
||||||
|
mock_series_service.create = AsyncMock(return_value=mock_created_series)
|
||||||
|
|
||||||
|
with patch.dict('sys.modules', {
|
||||||
|
'src.server.database.service': MagicMock(
|
||||||
|
AnimeSeriesService=mock_series_service,
|
||||||
|
EpisodeService=mock_episode_service
|
||||||
|
)
|
||||||
|
}):
|
||||||
|
count = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
||||||
|
assert count == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migrate_key_file_to_db(self):
|
||||||
|
"""Test migration of a legacy key file."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
# Create a folder with only a key file
|
||||||
|
anime_folder = os.path.join(tmp_dir, "Key Only Anime")
|
||||||
|
os.makedirs(anime_folder, exist_ok=True)
|
||||||
|
|
||||||
|
key_file = os.path.join(anime_folder, "key")
|
||||||
|
with open(key_file, "w", encoding="utf-8") as f:
|
||||||
|
f.write("key-only-anime")
|
||||||
|
|
||||||
|
# Mock the DB session and services
|
||||||
|
mock_db = AsyncMock()
|
||||||
|
mock_series_service = AsyncMock()
|
||||||
|
mock_episode_service = AsyncMock()
|
||||||
|
|
||||||
|
# Mock get_by_key returning None (not in DB)
|
||||||
|
mock_series_service.get_by_key = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
# Mock AnimeSeriesService.create returning a mock with id=1
|
||||||
|
mock_created_series = MagicMock()
|
||||||
|
mock_created_series.id = 1
|
||||||
|
mock_series_service.create = AsyncMock(return_value=mock_created_series)
|
||||||
|
|
||||||
|
with patch.dict('sys.modules', {
|
||||||
|
'src.server.database.service': MagicMock(
|
||||||
|
AnimeSeriesService=mock_series_service,
|
||||||
|
EpisodeService=mock_episode_service
|
||||||
|
)
|
||||||
|
}):
|
||||||
|
count = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
||||||
|
assert count == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_skips_already_migrated(self):
|
||||||
|
"""Test that migration skips series already in DB."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
# Create a folder with a data file
|
||||||
|
anime_folder = os.path.join(tmp_dir, "Already Migrated")
|
||||||
|
os.makedirs(anime_folder, exist_ok=True)
|
||||||
|
|
||||||
|
data_file = os.path.join(anime_folder, "data")
|
||||||
|
test_data = {
|
||||||
|
"key": "already-migrated",
|
||||||
|
"name": "Already Migrated",
|
||||||
|
"site": "https://aniworld.to",
|
||||||
|
"folder": "Already Migrated",
|
||||||
|
"episodeDict": {"1": [1]}
|
||||||
|
}
|
||||||
|
with open(data_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(test_data, f)
|
||||||
|
|
||||||
|
# Mock the DB session and services
|
||||||
|
mock_db = AsyncMock()
|
||||||
|
mock_series_service = AsyncMock()
|
||||||
|
mock_episode_service = AsyncMock()
|
||||||
|
|
||||||
|
# Mock get_by_key returning existing series (already migrated)
|
||||||
|
mock_existing_series = MagicMock()
|
||||||
|
mock_existing_series.name = "Modified Name"
|
||||||
|
mock_series_service.get_by_key = AsyncMock(return_value=mock_existing_series)
|
||||||
|
|
||||||
|
with patch.dict('sys.modules', {
|
||||||
|
'src.server.database.service': MagicMock(
|
||||||
|
AnimeSeriesService=mock_series_service,
|
||||||
|
EpisodeService=mock_episode_service
|
||||||
|
)
|
||||||
|
}):
|
||||||
|
count = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
||||||
|
assert count == 0 # No new series migrated
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_handles_corrupt_data_file(self):
|
||||||
|
"""Test that corrupt data files don't crash migration."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
# Create a folder with a corrupt data file
|
||||||
|
corrupt_folder = os.path.join(tmp_dir, "Corrupt Anime")
|
||||||
|
os.makedirs(corrupt_folder, exist_ok=True)
|
||||||
|
|
||||||
|
corrupt_file = os.path.join(corrupt_folder, "data")
|
||||||
|
with open(corrupt_file, "w", encoding="utf-8") as f:
|
||||||
|
f.write("not valid json {{{")
|
||||||
|
|
||||||
|
# Create a valid folder
|
||||||
|
valid_folder = os.path.join(tmp_dir, "Valid Anime")
|
||||||
|
os.makedirs(valid_folder, exist_ok=True)
|
||||||
|
|
||||||
|
valid_file = os.path.join(valid_folder, "data")
|
||||||
|
valid_data = {
|
||||||
|
"key": "valid-anime",
|
||||||
|
"name": "Valid Anime",
|
||||||
|
"site": "https://aniworld.to",
|
||||||
|
"folder": "Valid Anime",
|
||||||
|
"episodeDict": {"1": [1]}
|
||||||
|
}
|
||||||
|
with open(valid_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(valid_data, f)
|
||||||
|
|
||||||
|
# Mock the DB session and services
|
||||||
|
mock_db = AsyncMock()
|
||||||
|
mock_series_service = AsyncMock()
|
||||||
|
mock_episode_service = AsyncMock()
|
||||||
|
|
||||||
|
# Mock get_by_key returning None (not in DB)
|
||||||
|
mock_series_service.get_by_key = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
# Mock AnimeSeriesService.create returning a mock with id=1
|
||||||
|
mock_created_series = MagicMock()
|
||||||
|
mock_created_series.id = 1
|
||||||
|
mock_series_service.create = AsyncMock(return_value=mock_created_series)
|
||||||
|
|
||||||
|
with patch.dict('sys.modules', {
|
||||||
|
'src.server.database.service': MagicMock(
|
||||||
|
AnimeSeriesService=mock_series_service,
|
||||||
|
EpisodeService=mock_episode_service
|
||||||
|
)
|
||||||
|
}):
|
||||||
|
# Migration should succeed despite corrupt file
|
||||||
|
count = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
||||||
|
assert count == 1 # Only the valid one
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_idempotent(self):
|
||||||
|
"""Test that running migration twice doesn't change DB state."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
# Create a folder with a data file
|
||||||
|
anime_folder = os.path.join(tmp_dir, "Idempotent Test")
|
||||||
|
os.makedirs(anime_folder, exist_ok=True)
|
||||||
|
|
||||||
|
data_file = os.path.join(anime_folder, "data")
|
||||||
|
test_data = {
|
||||||
|
"key": "idempotent-test",
|
||||||
|
"name": "Idempotent Test",
|
||||||
|
"site": "https://aniworld.to",
|
||||||
|
"folder": "Idempotent Test",
|
||||||
|
"episodeDict": {"1": [1, 2]}
|
||||||
|
}
|
||||||
|
with open(data_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(test_data, f)
|
||||||
|
|
||||||
|
# Mock the DB session and services
|
||||||
|
mock_db = AsyncMock()
|
||||||
|
mock_series_service = AsyncMock()
|
||||||
|
mock_episode_service = AsyncMock()
|
||||||
|
|
||||||
|
# First call returns None (not in DB), second call returns the series
|
||||||
|
mock_existing_series = MagicMock()
|
||||||
|
mock_existing_series.id = 1
|
||||||
|
mock_series_service.get_by_key = AsyncMock(side_effect=[None, mock_existing_series])
|
||||||
|
|
||||||
|
# Mock AnimeSeriesService.create returning a mock with id=1
|
||||||
|
mock_created_series = MagicMock()
|
||||||
|
mock_created_series.id = 1
|
||||||
|
mock_series_service.create = AsyncMock(return_value=mock_created_series)
|
||||||
|
|
||||||
|
with patch.dict('sys.modules', {
|
||||||
|
'src.server.database.service': MagicMock(
|
||||||
|
AnimeSeriesService=mock_series_service,
|
||||||
|
EpisodeService=mock_episode_service
|
||||||
|
)
|
||||||
|
}):
|
||||||
|
# First migration
|
||||||
|
count1 = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
||||||
|
assert count1 == 1
|
||||||
|
|
||||||
|
# Second migration
|
||||||
|
count2 = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
||||||
|
assert count2 == 0 # Already migrated
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_skips_folders_without_files(self):
|
||||||
|
"""Test that folders without key/data files are skipped."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
# Create an empty folder (no key or data file)
|
||||||
|
empty_folder = os.path.join(tmp_dir, "Empty Folder")
|
||||||
|
os.makedirs(empty_folder, exist_ok=True)
|
||||||
|
|
||||||
|
# Create a folder with only a video file
|
||||||
|
video_folder = os.path.join(tmp_dir, "Video Folder")
|
||||||
|
os.makedirs(video_folder, exist_ok=True)
|
||||||
|
with open(os.path.join(video_folder, "episode1.mp4"), "w") as f:
|
||||||
|
f.write("fake video content")
|
||||||
|
|
||||||
|
mock_db = AsyncMock()
|
||||||
|
|
||||||
|
count = await migrate_series_from_files_to_db(tmp_dir, mock_db)
|
||||||
|
assert count == 0
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
294
tests/integration/test_poster_check_startup.py
Normal file
294
tests/integration/test_poster_check_startup.py
Normal 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}"
|
||||||
|
)
|
||||||
429
tests/integration/test_sacrificial_princess_nfo.py
Normal file
429
tests/integration/test_sacrificial_princess_nfo.py
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
"""Integration test: add 'Sacrificial Princess And The King Of Beasts' and verify NFO completeness.
|
||||||
|
|
||||||
|
Simulates the production scenario where this anime is added and validates
|
||||||
|
that the generated tvshow.nfo contains plot, outline, and all other required
|
||||||
|
information. Also tests the repair path for an incomplete NFO.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
from src.core.services.nfo_repair_service import (
|
||||||
|
NfoRepairService,
|
||||||
|
_read_tmdb_id,
|
||||||
|
find_missing_tags,
|
||||||
|
nfo_needs_repair,
|
||||||
|
)
|
||||||
|
from src.core.services.nfo_service import NFOService
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TMDB mock data matching production responses for this anime
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SERIES_KEY = "sacrificial-princess-and-the-king-of-beasts"
|
||||||
|
SERIES_NAME = "Sacrificial Princess And The King Of Beasts"
|
||||||
|
SERIES_FOLDER = "Sacrificial Princess And The King Of Beasts (2023)"
|
||||||
|
TMDB_ID = 222093
|
||||||
|
|
||||||
|
MOCK_TMDB_DETAILS = {
|
||||||
|
"id": TMDB_ID,
|
||||||
|
"name": "Sacrificial Princess and the King of Beasts",
|
||||||
|
"original_name": "贄姫と獣の王",
|
||||||
|
"overview": (
|
||||||
|
"On the outskirts of the Demon King's realm lies a small village of "
|
||||||
|
"humans who offer a sacrifice to the beast king every year. Sariphi, "
|
||||||
|
"the latest sacrificial girl, expects to be devoured — but instead "
|
||||||
|
"her fearless nature catches the king's attention and she becomes "
|
||||||
|
"his unlikely companion."
|
||||||
|
),
|
||||||
|
"tagline": "A tale of love between a sacrifice and a beast king.",
|
||||||
|
"first_air_date": "2023-04-20",
|
||||||
|
"last_air_date": "2023-09-28",
|
||||||
|
"vote_average": 7.5,
|
||||||
|
"vote_count": 150,
|
||||||
|
"status": "Ended",
|
||||||
|
"episode_run_time": [24],
|
||||||
|
"number_of_seasons": 1,
|
||||||
|
"number_of_episodes": 24,
|
||||||
|
"genres": [
|
||||||
|
{"id": 16, "name": "Animation"},
|
||||||
|
{"id": 10749, "name": "Romance"},
|
||||||
|
{"id": 10765, "name": "Sci-Fi & Fantasy"},
|
||||||
|
],
|
||||||
|
"networks": [{"id": 160, "name": "TBS"}],
|
||||||
|
"production_companies": [{"id": 291, "name": "J.C.Staff"}],
|
||||||
|
"origin_country": ["JP"],
|
||||||
|
"poster_path": "/sacrificial_poster.jpg",
|
||||||
|
"backdrop_path": "/sacrificial_backdrop.jpg",
|
||||||
|
"external_ids": {"imdb_id": "tt19896734", "tvdb_id": 421737},
|
||||||
|
"credits": {
|
||||||
|
"cast": [
|
||||||
|
{
|
||||||
|
"id": 2072089,
|
||||||
|
"name": "Kana Hanazawa",
|
||||||
|
"character": "Sariphi",
|
||||||
|
"profile_path": "/hanazawa.jpg",
|
||||||
|
"order": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1254783,
|
||||||
|
"name": "Satoshi Hino",
|
||||||
|
"character": "Leonhart",
|
||||||
|
"profile_path": "/hino.jpg",
|
||||||
|
"order": 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"images": {"logos": [{"file_path": "/sacrificial_logo.png"}]},
|
||||||
|
"seasons": [
|
||||||
|
{"season_number": 0, "name": "Specials"},
|
||||||
|
{"season_number": 1, "name": "Season 1"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
MOCK_CONTENT_RATINGS = {
|
||||||
|
"results": [
|
||||||
|
{"iso_3166_1": "DE", "rating": "12"},
|
||||||
|
{"iso_3166_1": "US", "rating": "TV-14"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
MOCK_SEARCH_RESULTS = {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": TMDB_ID,
|
||||||
|
"name": "Sacrificial Princess and the King of Beasts",
|
||||||
|
"first_air_date": "2023-04-20",
|
||||||
|
"overview": (
|
||||||
|
"On the outskirts of the Demon King's realm lies a small village "
|
||||||
|
"of humans who offer a sacrifice to the beast king every year."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tags that MUST be present and non-empty in a complete NFO
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
REQUIRED_TAGS = [
|
||||||
|
"title",
|
||||||
|
"originaltitle",
|
||||||
|
"year",
|
||||||
|
"plot",
|
||||||
|
"outline",
|
||||||
|
"runtime",
|
||||||
|
"premiered",
|
||||||
|
"status",
|
||||||
|
"tmdbid",
|
||||||
|
"imdbid",
|
||||||
|
"genre",
|
||||||
|
"studio",
|
||||||
|
"country",
|
||||||
|
"watched",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anime_dir(tmp_path: Path) -> Path:
|
||||||
|
"""Temporary anime directory."""
|
||||||
|
d = tmp_path / "anime"
|
||||||
|
d.mkdir()
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def nfo_service(anime_dir: Path) -> NFOService:
|
||||||
|
"""NFOService configured for the temp directory."""
|
||||||
|
return NFOService(
|
||||||
|
tmdb_api_key="test_api_key",
|
||||||
|
anime_directory=str(anime_dir),
|
||||||
|
image_size="w500",
|
||||||
|
auto_create=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_tmdb_calls(nfo_service: NFOService):
|
||||||
|
"""Context manager that patches all TMDB calls with mock data."""
|
||||||
|
return _PatchContext(nfo_service)
|
||||||
|
|
||||||
|
|
||||||
|
class _PatchContext:
|
||||||
|
"""Helper to patch TMDB calls on an NFOService instance."""
|
||||||
|
|
||||||
|
def __init__(self, svc: NFOService):
|
||||||
|
self._svc = svc
|
||||||
|
self._patches = []
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
p1 = patch.object(
|
||||||
|
self._svc.tmdb_client, "search_tv_show", new_callable=AsyncMock
|
||||||
|
)
|
||||||
|
p2 = patch.object(
|
||||||
|
self._svc.tmdb_client, "get_tv_show_details", new_callable=AsyncMock
|
||||||
|
)
|
||||||
|
p3 = patch.object(
|
||||||
|
self._svc.tmdb_client, "get_tv_show_content_ratings", new_callable=AsyncMock
|
||||||
|
)
|
||||||
|
p4 = patch.object(
|
||||||
|
self._svc.image_downloader, "download_all_media", new_callable=AsyncMock
|
||||||
|
)
|
||||||
|
p5 = patch.object(
|
||||||
|
self._svc.tmdb_client, "_ensure_session", new_callable=AsyncMock
|
||||||
|
)
|
||||||
|
p6 = patch.object(
|
||||||
|
self._svc.tmdb_client, "close", new_callable=AsyncMock
|
||||||
|
)
|
||||||
|
|
||||||
|
self._patches = [p1, p2, p3, p4, p5, p6]
|
||||||
|
mocks = [p.start() for p in self._patches]
|
||||||
|
|
||||||
|
mocks[0].return_value = MOCK_SEARCH_RESULTS
|
||||||
|
mocks[1].return_value = MOCK_TMDB_DETAILS
|
||||||
|
mocks[2].return_value = MOCK_CONTENT_RATINGS
|
||||||
|
mocks[3].return_value = {"poster": True, "logo": True, "fanart": True}
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
for p in self._patches:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSacrificialPrincessNFO:
|
||||||
|
"""Tests for 'Sacrificial Princess And The King Of Beasts' NFO generation."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_anime_creates_complete_nfo(
|
||||||
|
self, nfo_service: NFOService, anime_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Adding the anime produces an NFO with all required tags filled."""
|
||||||
|
series_path = anime_dir / SERIES_FOLDER
|
||||||
|
series_path.mkdir()
|
||||||
|
|
||||||
|
with _PatchContext(nfo_service):
|
||||||
|
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||||
|
serie_name=SERIES_NAME,
|
||||||
|
serie_folder=SERIES_FOLDER,
|
||||||
|
year=2023,
|
||||||
|
download_poster=True,
|
||||||
|
download_logo=True,
|
||||||
|
download_fanart=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert nfo_path.exists(), f"NFO not created at {nfo_path}"
|
||||||
|
|
||||||
|
root = etree.parse(str(nfo_path)).getroot()
|
||||||
|
missing = []
|
||||||
|
for tag in REQUIRED_TAGS:
|
||||||
|
elems = root.findall(f".//{tag}")
|
||||||
|
if not elems or not any((e.text or "").strip() for e in elems):
|
||||||
|
missing.append(tag)
|
||||||
|
|
||||||
|
# Actor check
|
||||||
|
actors = root.findall(".//actor/name")
|
||||||
|
if not actors or not any((a.text or "").strip() for a in actors):
|
||||||
|
missing.append("actor/name")
|
||||||
|
|
||||||
|
assert not missing, (
|
||||||
|
f"Missing or empty tags in NFO for '{SERIES_NAME}':\n"
|
||||||
|
f" {', '.join(missing)}\n\n"
|
||||||
|
f"NFO content:\n{nfo_path.read_text(encoding='utf-8')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nfo_plot_and_outline_are_meaningful(
|
||||||
|
self, nfo_service: NFOService, anime_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Plot and outline must contain substantial descriptive text."""
|
||||||
|
series_path = anime_dir / SERIES_FOLDER
|
||||||
|
series_path.mkdir()
|
||||||
|
|
||||||
|
with _PatchContext(nfo_service):
|
||||||
|
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||||
|
serie_name=SERIES_NAME,
|
||||||
|
serie_folder=SERIES_FOLDER,
|
||||||
|
year=2023,
|
||||||
|
download_poster=False,
|
||||||
|
download_logo=False,
|
||||||
|
download_fanart=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
root = etree.parse(str(nfo_path)).getroot()
|
||||||
|
|
||||||
|
plot = (root.findtext(".//plot") or "").strip()
|
||||||
|
outline = (root.findtext(".//outline") or "").strip()
|
||||||
|
|
||||||
|
assert len(plot) >= 20, f"Plot too short ({len(plot)} chars): {plot!r}"
|
||||||
|
assert len(outline) >= 20, f"Outline too short ({len(outline)} chars): {outline!r}"
|
||||||
|
|
||||||
|
# Should mention relevant keywords from the series
|
||||||
|
combined = (plot + outline).lower()
|
||||||
|
assert any(
|
||||||
|
kw in combined for kw in ("sacrifice", "beast", "king", "sariphi")
|
||||||
|
), f"Plot/outline missing expected content:\n plot={plot!r}\n outline={outline!r}"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nfo_specific_values(
|
||||||
|
self, nfo_service: NFOService, anime_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""Verify specific metadata values match the anime."""
|
||||||
|
series_path = anime_dir / SERIES_FOLDER
|
||||||
|
series_path.mkdir()
|
||||||
|
|
||||||
|
with _PatchContext(nfo_service):
|
||||||
|
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||||
|
serie_name=SERIES_NAME,
|
||||||
|
serie_folder=SERIES_FOLDER,
|
||||||
|
year=2023,
|
||||||
|
download_poster=False,
|
||||||
|
download_logo=False,
|
||||||
|
download_fanart=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
root = etree.parse(str(nfo_path)).getroot()
|
||||||
|
|
||||||
|
assert root.findtext(".//year") == "2023"
|
||||||
|
assert root.findtext(".//status") == "Ended"
|
||||||
|
assert root.findtext(".//tmdbid") == str(TMDB_ID)
|
||||||
|
assert root.findtext(".//imdbid") == "tt19896734"
|
||||||
|
assert root.findtext(".//watched") == "false"
|
||||||
|
assert root.findtext(".//premiered") == "2023-04-20"
|
||||||
|
|
||||||
|
genres = [g.text for g in root.findall(".//genre") if g.text]
|
||||||
|
assert "Animation" in genres
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_incomplete_nfo_detected_as_needing_repair(
|
||||||
|
self, anime_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""An NFO with only a <title> tag is detected as incomplete."""
|
||||||
|
series_path = anime_dir / SERIES_FOLDER
|
||||||
|
series_path.mkdir()
|
||||||
|
nfo_path = series_path / "tvshow.nfo"
|
||||||
|
|
||||||
|
# Simulate production state: minimal NFO with only title
|
||||||
|
nfo_path.write_text(
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
"<tvshow>\n"
|
||||||
|
f" <title>{SERIES_NAME}</title>\n"
|
||||||
|
"</tvshow>\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert nfo_needs_repair(nfo_path) is True
|
||||||
|
|
||||||
|
missing = find_missing_tags(nfo_path)
|
||||||
|
# All these should be detected as missing
|
||||||
|
for tag_label in ["plot", "year", "runtime", "premiered", "genre", "studio"]:
|
||||||
|
assert tag_label in missing, f"'{tag_label}' not detected as missing"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_fixes_incomplete_nfo(
|
||||||
|
self, nfo_service: NFOService, anime_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""NfoRepairService re-fetches and creates a complete NFO from an incomplete one."""
|
||||||
|
series_path = anime_dir / SERIES_FOLDER
|
||||||
|
series_path.mkdir()
|
||||||
|
nfo_path = series_path / "tvshow.nfo"
|
||||||
|
|
||||||
|
# Write an incomplete NFO with a tmdbid so update_tvshow_nfo can work
|
||||||
|
nfo_path.write_text(
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
"<tvshow>\n"
|
||||||
|
f" <title>{SERIES_NAME}</title>\n"
|
||||||
|
f" <tmdbid>{TMDB_ID}</tmdbid>\n"
|
||||||
|
"</tvshow>\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert nfo_needs_repair(nfo_path) is True
|
||||||
|
|
||||||
|
# Patch TMDB calls for the update path
|
||||||
|
with patch.object(
|
||||||
|
nfo_service.tmdb_client, "_ensure_session", new_callable=AsyncMock
|
||||||
|
), patch.object(
|
||||||
|
nfo_service.tmdb_client, "get_tv_show_details", new_callable=AsyncMock
|
||||||
|
) as mock_details, patch.object(
|
||||||
|
nfo_service.tmdb_client, "get_tv_show_content_ratings", new_callable=AsyncMock
|
||||||
|
) as mock_ratings, patch.object(
|
||||||
|
nfo_service.tmdb_client, "close", new_callable=AsyncMock
|
||||||
|
):
|
||||||
|
mock_details.return_value = MOCK_TMDB_DETAILS
|
||||||
|
mock_ratings.return_value = MOCK_CONTENT_RATINGS
|
||||||
|
|
||||||
|
repair_service = NfoRepairService(nfo_service)
|
||||||
|
repaired = await repair_service.repair_series(series_path, SERIES_FOLDER)
|
||||||
|
|
||||||
|
assert repaired is True
|
||||||
|
|
||||||
|
# After repair, NFO should be complete
|
||||||
|
assert nfo_needs_repair(nfo_path) is False
|
||||||
|
|
||||||
|
# Verify content
|
||||||
|
root = etree.parse(str(nfo_path)).getroot()
|
||||||
|
plot = (root.findtext(".//plot") or "").strip()
|
||||||
|
assert len(plot) >= 20, f"Plot still incomplete after repair: {plot!r}"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_recreates_nfo_without_tmdb_id(
|
||||||
|
self, nfo_service: NFOService, anime_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""If the NFO has no <tmdbid>, repair falls back to create_tvshow_nfo."""
|
||||||
|
series_path = anime_dir / SERIES_FOLDER
|
||||||
|
series_path.mkdir()
|
||||||
|
nfo_path = series_path / "tvshow.nfo"
|
||||||
|
|
||||||
|
# Simulate the production worst-case: only a title, no TMDB ID
|
||||||
|
nfo_path.write_text(
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
"<tvshow>\n"
|
||||||
|
f" <title>{SERIES_NAME}</title>\n"
|
||||||
|
"</tvshow>\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _read_tmdb_id(nfo_path) is None
|
||||||
|
assert nfo_needs_repair(nfo_path) is True
|
||||||
|
|
||||||
|
with _PatchContext(nfo_service):
|
||||||
|
repair_service = NfoRepairService(nfo_service)
|
||||||
|
repaired = await repair_service.repair_series(series_path, SERIES_FOLDER)
|
||||||
|
|
||||||
|
assert repaired is True
|
||||||
|
assert nfo_path.exists()
|
||||||
|
assert nfo_needs_repair(nfo_path) is False
|
||||||
|
|
||||||
|
root = etree.parse(str(nfo_path)).getroot()
|
||||||
|
plot = (root.findtext(".//plot") or "").strip()
|
||||||
|
assert len(plot) >= 20, f"Plot incomplete after recreate: {plot!r}"
|
||||||
|
assert root.findtext(".//tmdbid") == str(TMDB_ID)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_complete_nfo_not_repaired(
|
||||||
|
self, nfo_service: NFOService, anime_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""A complete NFO should not trigger a repair."""
|
||||||
|
series_path = anime_dir / SERIES_FOLDER
|
||||||
|
series_path.mkdir()
|
||||||
|
|
||||||
|
# First create a complete NFO
|
||||||
|
with _PatchContext(nfo_service):
|
||||||
|
await nfo_service.create_tvshow_nfo(
|
||||||
|
serie_name=SERIES_NAME,
|
||||||
|
serie_folder=SERIES_FOLDER,
|
||||||
|
year=2023,
|
||||||
|
download_poster=False,
|
||||||
|
download_logo=False,
|
||||||
|
download_fanart=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
nfo_path = series_path / "tvshow.nfo"
|
||||||
|
assert nfo_path.exists()
|
||||||
|
assert nfo_needs_repair(nfo_path) is False
|
||||||
|
|
||||||
|
# Repair should be skipped
|
||||||
|
repair_service = NfoRepairService(nfo_service)
|
||||||
|
repaired = await repair_service.repair_series(series_path, SERIES_FOLDER)
|
||||||
|
assert repaired is False
|
||||||
@@ -495,6 +495,20 @@ class TestNameWithYearProperty:
|
|||||||
assert "(2013)" in sanitized
|
assert "(2013)" in sanitized
|
||||||
assert "Attack on Titan" in sanitized
|
assert "Attack on Titan" in sanitized
|
||||||
|
|
||||||
|
def test_name_with_year_does_not_duplicate(self):
|
||||||
|
"""Test that name_with_year doesn't duplicate year."""
|
||||||
|
serie = Serie(
|
||||||
|
key="eighty-six",
|
||||||
|
name="86 Eighty Six (2021)",
|
||||||
|
site="aniworld.to",
|
||||||
|
folder="86 Eighty Six (2021)",
|
||||||
|
episodeDict={},
|
||||||
|
year=2021
|
||||||
|
)
|
||||||
|
|
||||||
|
assert serie.name_with_year == "86 Eighty Six (2021)"
|
||||||
|
assert serie.name_with_year.count("(2021)") == 1
|
||||||
|
|
||||||
|
|
||||||
class TestEnsureFolderWithYear:
|
class TestEnsureFolderWithYear:
|
||||||
"""Test Serie.ensure_folder_with_year method."""
|
"""Test Serie.ensure_folder_with_year method."""
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class TestDownloadQueueStress:
|
|||||||
def mock_anime_service(self):
|
def mock_anime_service(self):
|
||||||
"""Create mock AnimeService."""
|
"""Create mock AnimeService."""
|
||||||
service = MagicMock(spec=AnimeService)
|
service = MagicMock(spec=AnimeService)
|
||||||
|
service._directory = "/tmp/test_anime"
|
||||||
service.download = AsyncMock(return_value=True)
|
service.download = AsyncMock(return_value=True)
|
||||||
return service
|
return service
|
||||||
|
|
||||||
@@ -172,6 +173,7 @@ class TestDownloadMemoryUsage:
|
|||||||
def mock_anime_service(self):
|
def mock_anime_service(self):
|
||||||
"""Create mock AnimeService."""
|
"""Create mock AnimeService."""
|
||||||
service = MagicMock(spec=AnimeService)
|
service = MagicMock(spec=AnimeService)
|
||||||
|
service._directory = "/tmp/test_anime"
|
||||||
service.download = AsyncMock(return_value=True)
|
service.download = AsyncMock(return_value=True)
|
||||||
return service
|
return service
|
||||||
|
|
||||||
@@ -180,6 +182,7 @@ class TestDownloadMemoryUsage:
|
|||||||
"""Create download service with mock repository."""
|
"""Create download service with mock repository."""
|
||||||
from tests.unit.test_download_service import MockQueueRepository
|
from tests.unit.test_download_service import MockQueueRepository
|
||||||
mock_repo = MockQueueRepository()
|
mock_repo = MockQueueRepository()
|
||||||
|
mock_anime_service._directory = "/tmp/test_anime"
|
||||||
service = DownloadService(
|
service = DownloadService(
|
||||||
anime_service=mock_anime_service,
|
anime_service=mock_anime_service,
|
||||||
max_retries=3,
|
max_retries=3,
|
||||||
@@ -223,6 +226,7 @@ class TestDownloadConcurrency:
|
|||||||
def mock_anime_service(self):
|
def mock_anime_service(self):
|
||||||
"""Create mock AnimeService with slow downloads."""
|
"""Create mock AnimeService with slow downloads."""
|
||||||
service = MagicMock(spec=AnimeService)
|
service = MagicMock(spec=AnimeService)
|
||||||
|
service._directory = "/tmp/test_anime"
|
||||||
|
|
||||||
async def slow_download(*args, **kwargs):
|
async def slow_download(*args, **kwargs):
|
||||||
# Simulate slow download
|
# Simulate slow download
|
||||||
@@ -314,6 +318,7 @@ class TestDownloadErrorHandling:
|
|||||||
def mock_failing_anime_service(self):
|
def mock_failing_anime_service(self):
|
||||||
"""Create mock AnimeService that fails downloads."""
|
"""Create mock AnimeService that fails downloads."""
|
||||||
service = MagicMock(spec=AnimeService)
|
service = MagicMock(spec=AnimeService)
|
||||||
|
service._directory = "/tmp/test_anime"
|
||||||
service.download = AsyncMock(
|
service.download = AsyncMock(
|
||||||
side_effect=Exception("Download failed")
|
side_effect=Exception("Download failed")
|
||||||
)
|
)
|
||||||
@@ -337,6 +342,7 @@ class TestDownloadErrorHandling:
|
|||||||
def mock_anime_service(self):
|
def mock_anime_service(self):
|
||||||
"""Create mock AnimeService."""
|
"""Create mock AnimeService."""
|
||||||
service = MagicMock(spec=AnimeService)
|
service = MagicMock(spec=AnimeService)
|
||||||
|
service._directory = "/tmp/test_anime"
|
||||||
service.download = AsyncMock(return_value=True)
|
service.download = AsyncMock(return_value=True)
|
||||||
return service
|
return service
|
||||||
|
|
||||||
@@ -345,6 +351,7 @@ class TestDownloadErrorHandling:
|
|||||||
"""Create download service with mock repository."""
|
"""Create download service with mock repository."""
|
||||||
from tests.unit.test_download_service import MockQueueRepository
|
from tests.unit.test_download_service import MockQueueRepository
|
||||||
mock_repo = MockQueueRepository()
|
mock_repo = MockQueueRepository()
|
||||||
|
mock_anime_service._directory = "/tmp/test_anime"
|
||||||
service = DownloadService(
|
service = DownloadService(
|
||||||
anime_service=mock_anime_service,
|
anime_service=mock_anime_service,
|
||||||
max_retries=3,
|
max_retries=3,
|
||||||
|
|||||||
@@ -321,9 +321,9 @@ class TestTMDBAPIBatchingOptimization:
|
|||||||
nfo_service=mock_nfo_service
|
nfo_service=mock_nfo_service
|
||||||
)
|
)
|
||||||
|
|
||||||
# One should fail due to rate limit
|
# Rate limit triggers fallback to minimal NFO, still counts as success
|
||||||
assert result.successful == num_series - 1
|
assert result.successful == num_series
|
||||||
assert result.failed == 1
|
assert result.failed == 0
|
||||||
|
|
||||||
print(f"\nRate limit test: {result.successful} success, {result.failed} failed")
|
print(f"\nRate limit test: {result.successful} success, {result.failed} failed")
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user