Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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 +1 @@
|
|||||||
v0.0.1
|
v1.2.2
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
101
Docker/push.sh
101
Docker/push.sh
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
log "Pushing ${APP_IMAGE}:${TAG}"
|
push_app() {
|
||||||
podman push "${APP_IMAGE}:${TAG}"
|
log "Pushing ${APP_IMAGE}:${TAG}"
|
||||||
|
"${ENGINE}" push "${APP_IMAGE}:${TAG}"
|
||||||
|
}
|
||||||
|
|
||||||
log "Pushing ${VPN_IMAGE}:${TAG}"
|
push_vpn() {
|
||||||
podman push "${VPN_IMAGE}:${TAG}"
|
log "Pushing ${VPN_IMAGE}:${TAG}"
|
||||||
|
"${ENGINE}" push "${VPN_IMAGE}:${TAG}"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${TARGET}" in
|
||||||
|
app) push_app ;;
|
||||||
|
vpn) push_vpn ;;
|
||||||
|
all) push_app; push_vpn ;;
|
||||||
|
esac
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Summary
|
# Summary
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo ""
|
echo ""
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
echo " ✅ Push complete!"
|
echo " Push complete!"
|
||||||
echo ""
|
echo ""
|
||||||
echo " Images:"
|
echo " Images:"
|
||||||
echo " ${APP_IMAGE}:${TAG}"
|
case "${TARGET}" in
|
||||||
echo " ${VPN_IMAGE}:${TAG}"
|
app) echo " ${APP_IMAGE}:${TAG}" ;;
|
||||||
|
vpn) echo " ${VPN_IMAGE}:${TAG}" ;;
|
||||||
|
all) echo " ${APP_IMAGE}:${TAG}"; echo " ${VPN_IMAGE}:${TAG}" ;;
|
||||||
|
esac
|
||||||
echo ""
|
echo ""
|
||||||
echo " Deploy on server:"
|
echo " Deploy on server:"
|
||||||
echo " podman login ${REGISTRY}"
|
echo " ${ENGINE} login ${REGISTRY}"
|
||||||
echo " podman-compose -f podman-compose.prod.yml pull"
|
echo " ${ENGINE} compose -f Docker/podman-compose.prod.yml pull"
|
||||||
echo " podman-compose -f podman-compose.prod.yml up -d"
|
echo " ${ENGINE} compose -f Docker/podman-compose.prod.yml up -d"
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
129
Docker/release.sh
Normal file
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
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -130,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
|
||||||
|
|||||||
216
docs/DATABASE.md
216
docs/DATABASE.md
@@ -83,17 +83,23 @@ 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) |
|
||||||
| `created_at` | DATETIME | NOT NULL, DEFAULT NOW | Record creation timestamp |
|
| `year` | INTEGER | NULLABLE | Release year of the series |
|
||||||
| `updated_at` | DATETIME | NOT NULL, ON UPDATE NOW | Last update timestamp |
|
| `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 |
|
||||||
|
| `updated_at` | DATETIME | NOT NULL, ON UPDATE NOW | Last update timestamp |
|
||||||
|
|
||||||
**Identifier Convention:**
|
**Identifier Convention:**
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -600,6 +637,36 @@ Every poster check action is logged:
|
|||||||
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
|
||||||
|
|||||||
@@ -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.");
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aniworld-web",
|
"name": "aniworld-web",
|
||||||
"version": "0.0.1",
|
"version": "1.2.2",
|
||||||
"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()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -166,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, db_lookup=db_lookup
|
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
|
||||||
@@ -445,9 +448,12 @@ 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."""
|
||||||
logger.debug(
|
# Throttle progress logging to avoid spam
|
||||||
"download_progress_handler called with: %s", progress_info
|
status = progress_info.get("status", "")
|
||||||
)
|
if status in ("downloading", "finished"):
|
||||||
|
logger.debug(
|
||||||
|
"download_progress_handler called with: %s", progress_info
|
||||||
|
)
|
||||||
|
|
||||||
downloaded = progress_info.get('downloaded_bytes', 0)
|
downloaded = progress_info.get('downloaded_bytes', 0)
|
||||||
total_bytes = (
|
total_bytes = (
|
||||||
|
|||||||
@@ -1,320 +1,531 @@
|
|||||||
"""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:
|
|
||||||
This module is part of the core domain layer and has no database
|
Note:
|
||||||
dependencies. All database operations are handled by the service layer.
|
This module is part of the core domain layer. Database operations
|
||||||
"""
|
are handled by the service layer via add_to_db().
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
|
||||||
|
from __future__ import annotations
|
||||||
import logging
|
|
||||||
import os
|
import logging
|
||||||
import warnings
|
import os
|
||||||
from json import JSONDecodeError
|
import warnings
|
||||||
from typing import Dict, Iterable, List, Optional
|
from json import JSONDecodeError
|
||||||
|
from typing import Dict, Iterable, List, Optional
|
||||||
from src.core.entities.series import Serie
|
|
||||||
|
from src.config.settings import settings
|
||||||
logger = logging.getLogger(__name__)
|
from src.core.entities.series import Serie
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
class SerieList:
|
|
||||||
"""
|
|
||||||
Represents the collection of cached series stored on disk.
|
class SerieList:
|
||||||
|
"""
|
||||||
Series are identified by their unique 'key' (provider identifier).
|
Represents the collection of cached series stored on disk.
|
||||||
The 'folder' is metadata only and not used for lookups.
|
|
||||||
|
Series are identified by their unique 'key' (provider identifier).
|
||||||
This class manages in-memory series data loaded from filesystem.
|
The 'folder' is metadata only and not used for lookups.
|
||||||
It has no database dependencies - all persistence is handled by
|
|
||||||
the service layer.
|
This class manages in-memory series data loaded from filesystem.
|
||||||
|
It has no database dependencies - all persistence is handled by
|
||||||
Example:
|
the service layer.
|
||||||
# File-based mode
|
|
||||||
serie_list = SerieList("/path/to/anime")
|
Example:
|
||||||
series = serie_list.get_all()
|
# File-based mode
|
||||||
|
serie_list = SerieList("/path/to/anime")
|
||||||
Attributes:
|
series = serie_list.get_all()
|
||||||
directory: Path to the anime directory
|
|
||||||
keyDict: Internal dictionary mapping serie.key to Serie objects
|
Attributes:
|
||||||
"""
|
directory: Path to the anime directory
|
||||||
|
keyDict: Internal dictionary mapping serie.key to Serie objects
|
||||||
def __init__(
|
"""
|
||||||
self,
|
|
||||||
base_path: str,
|
def __init__(
|
||||||
skip_load: bool = False
|
self,
|
||||||
) -> None:
|
base_path: str,
|
||||||
"""Initialize the SerieList.
|
skip_load: bool = False
|
||||||
|
) -> None:
|
||||||
Args:
|
"""Initialize the SerieList.
|
||||||
base_path: Path to the anime directory
|
|
||||||
skip_load: If True, skip automatic loading of series from files.
|
Args:
|
||||||
Useful when planning to load from database instead.
|
base_path: Path to the anime directory
|
||||||
"""
|
skip_load: If True, skip automatic loading of series from files.
|
||||||
self.directory: str = base_path
|
Useful when planning to load from database instead.
|
||||||
# Internal storage using serie.key as the dictionary key
|
"""
|
||||||
self.keyDict: Dict[str, Serie] = {}
|
self.directory: str = base_path
|
||||||
|
# Internal storage using serie.key as the dictionary key
|
||||||
# Only auto-load from files if not skipping
|
self.keyDict: Dict[str, Serie] = {}
|
||||||
if not skip_load:
|
|
||||||
self.load_series()
|
# Only auto-load from files if not skipping
|
||||||
|
if not skip_load:
|
||||||
def add(self, serie: Serie, use_sanitized_folder: bool = True) -> str:
|
self.load_series()
|
||||||
"""
|
|
||||||
Persist a new series if it is not already present (file-based mode).
|
def add(self, serie: Serie, use_sanitized_folder: bool = True) -> str:
|
||||||
|
"""
|
||||||
Uses serie.key for identification. Creates the filesystem folder
|
Persist a new series if it is not already present (file-based mode).
|
||||||
using either the sanitized display name (default) or the existing
|
|
||||||
folder property.
|
Uses serie.key for identification. Creates the filesystem folder
|
||||||
|
using either the sanitized display name (default) or the existing
|
||||||
Args:
|
folder property.
|
||||||
serie: The Serie instance to add
|
|
||||||
use_sanitized_folder: If True (default), use serie.sanitized_folder
|
Args:
|
||||||
for the filesystem folder name based on display name.
|
serie: The Serie instance to add
|
||||||
If False, use serie.folder as-is for backward compatibility.
|
use_sanitized_folder: If True (default), use serie.sanitized_folder
|
||||||
|
for the filesystem folder name based on display name.
|
||||||
Returns:
|
If False, use serie.folder as-is for backward compatibility.
|
||||||
str: The folder path that was created/used
|
|
||||||
|
Returns:
|
||||||
Note:
|
str: The folder path that was created/used
|
||||||
This method creates data files on disk. For database storage,
|
|
||||||
use add_to_db() instead.
|
Note:
|
||||||
"""
|
This method creates data files on disk. For database storage,
|
||||||
if self.contains(serie.key):
|
use add_to_db() instead.
|
||||||
# Return existing folder path
|
"""
|
||||||
existing = self.keyDict[serie.key]
|
if self.contains(serie.key):
|
||||||
return os.path.join(self.directory, existing.folder)
|
# Return existing folder path
|
||||||
|
existing = self.keyDict[serie.key]
|
||||||
# Determine folder name to use
|
return os.path.join(self.directory, existing.folder)
|
||||||
if use_sanitized_folder:
|
|
||||||
folder_name = serie.sanitized_folder
|
# Determine folder name to use
|
||||||
# Update the serie's folder property to match what we create
|
if use_sanitized_folder:
|
||||||
serie.folder = folder_name
|
folder_name = serie.sanitized_folder
|
||||||
else:
|
# Update the serie's folder property to match what we create
|
||||||
folder_name = serie.folder
|
serie.folder = folder_name
|
||||||
|
else:
|
||||||
data_path = os.path.join(self.directory, folder_name, "data")
|
folder_name = serie.folder
|
||||||
anime_path = os.path.join(self.directory, folder_name)
|
|
||||||
os.makedirs(anime_path, exist_ok=True)
|
data_path = os.path.join(self.directory, folder_name, "data")
|
||||||
if not os.path.isfile(data_path):
|
anime_path = os.path.join(self.directory, folder_name)
|
||||||
serie.save_to_file(data_path)
|
os.makedirs(anime_path, exist_ok=True)
|
||||||
# Store by key, not folder
|
if not os.path.isfile(data_path):
|
||||||
self.keyDict[serie.key] = serie
|
serie.save_to_file(data_path)
|
||||||
|
# Store by key, not folder
|
||||||
return anime_path
|
self.keyDict[serie.key] = serie
|
||||||
|
|
||||||
def contains(self, key: str) -> bool:
|
return anime_path
|
||||||
"""
|
|
||||||
Return True when a series identified by ``key`` already exists.
|
async def add_to_db(self, serie: Serie) -> bool:
|
||||||
|
"""Persist a new series to the database.
|
||||||
Args:
|
|
||||||
key: The unique provider identifier for the series
|
Creates the filesystem folder using serie.folder, then persists
|
||||||
|
the series metadata to the database.
|
||||||
Returns:
|
|
||||||
True if the series exists in the collection
|
Args:
|
||||||
"""
|
serie: The Serie instance to add
|
||||||
return key in self.keyDict
|
|
||||||
|
Returns:
|
||||||
def load_series(self) -> None:
|
True if successful, False otherwise
|
||||||
"""Populate the in-memory map with metadata discovered on disk."""
|
"""
|
||||||
|
try:
|
||||||
logger.info("Scanning anime folders in %s", self.directory)
|
from src.server.database.connection import get_async_session_factory
|
||||||
try:
|
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||||
entries: Iterable[str] = os.listdir(self.directory)
|
|
||||||
except OSError as error:
|
folder_name = serie.folder
|
||||||
logger.error(
|
anime_path = os.path.join(self.directory, folder_name)
|
||||||
"Unable to scan directory %s: %s",
|
os.makedirs(anime_path, exist_ok=True)
|
||||||
self.directory,
|
|
||||||
error,
|
session_factory = get_async_session_factory()
|
||||||
)
|
db = session_factory()
|
||||||
return
|
try:
|
||||||
|
existing = await AnimeSeriesService.get_by_key(db, serie.key)
|
||||||
nfo_stats = {"total": 0, "with_nfo": 0, "without_nfo": 0}
|
if existing:
|
||||||
media_stats = {
|
logger.debug(
|
||||||
"with_poster": 0,
|
"Series '%s' (key=%s) already exists in DB, skipping",
|
||||||
"without_poster": 0,
|
serie.name, serie.key
|
||||||
"with_logo": 0,
|
)
|
||||||
"without_logo": 0,
|
return True
|
||||||
"with_fanart": 0,
|
|
||||||
"without_fanart": 0
|
anime_series = await AnimeSeriesService.create(
|
||||||
}
|
db=db,
|
||||||
|
key=serie.key,
|
||||||
for anime_folder in entries:
|
name=serie.name,
|
||||||
anime_path = os.path.join(self.directory, anime_folder, "data")
|
site=serie.site,
|
||||||
if os.path.isfile(anime_path):
|
folder=folder_name,
|
||||||
logger.debug("Found data file for folder %s", anime_folder)
|
year=serie.year
|
||||||
serie = self._load_data(anime_folder, anime_path)
|
)
|
||||||
|
for season, eps in serie.episodeDict.items():
|
||||||
if serie:
|
for ep in eps:
|
||||||
nfo_stats["total"] += 1
|
await EpisodeService.create(
|
||||||
# Check for NFO file
|
db=db,
|
||||||
nfo_file_path = os.path.join(
|
series_id=anime_series.id,
|
||||||
self.directory, anime_folder, "tvshow.nfo"
|
season=season,
|
||||||
)
|
episode_number=ep
|
||||||
if os.path.isfile(nfo_file_path):
|
)
|
||||||
serie.nfo_path = nfo_file_path
|
await db.commit()
|
||||||
nfo_stats["with_nfo"] += 1
|
self.keyDict[serie.key] = serie
|
||||||
else:
|
logger.info(
|
||||||
nfo_stats["without_nfo"] += 1
|
"Persisted series '%s' to database",
|
||||||
logger.debug(
|
serie.name
|
||||||
"Series '%s' (key: %s) is missing tvshow.nfo",
|
)
|
||||||
serie.name,
|
return True
|
||||||
serie.key
|
except Exception as e:
|
||||||
)
|
await db.rollback()
|
||||||
|
logger.error(
|
||||||
# Check for media files
|
"Failed to persist series '%s' to DB: %s",
|
||||||
folder_path = os.path.join(self.directory, anime_folder)
|
serie.key, e, exc_info=True
|
||||||
|
)
|
||||||
poster_path = os.path.join(folder_path, "poster.jpg")
|
return False
|
||||||
if os.path.isfile(poster_path):
|
finally:
|
||||||
media_stats["with_poster"] += 1
|
await db.close()
|
||||||
else:
|
except Exception as e:
|
||||||
media_stats["without_poster"] += 1
|
logger.error(
|
||||||
logger.debug(
|
"Could not add series '%s' to DB (DB unavailable?): %s",
|
||||||
"Series '%s' (key: %s) is missing poster.jpg",
|
serie.key, e
|
||||||
serie.name,
|
)
|
||||||
serie.key
|
return False
|
||||||
)
|
|
||||||
|
def contains(self, key: str) -> bool:
|
||||||
logo_path = os.path.join(folder_path, "logo.png")
|
"""
|
||||||
if os.path.isfile(logo_path):
|
Return True when a series identified by ``key`` already exists.
|
||||||
media_stats["with_logo"] += 1
|
|
||||||
else:
|
Args:
|
||||||
media_stats["without_logo"] += 1
|
key: The unique provider identifier for the series
|
||||||
logger.debug(
|
|
||||||
"Series '%s' (key: %s) is missing logo.png",
|
Returns:
|
||||||
serie.name,
|
True if the series exists in the collection
|
||||||
serie.key
|
"""
|
||||||
)
|
return key in self.keyDict
|
||||||
|
|
||||||
fanart_path = os.path.join(folder_path, "fanart.jpg")
|
def load_series(self) -> None:
|
||||||
if os.path.isfile(fanart_path):
|
"""Populate the in-memory map with metadata discovered on disk."""
|
||||||
media_stats["with_fanart"] += 1
|
|
||||||
else:
|
logger.info("Scanning anime folders in %s", self.directory)
|
||||||
media_stats["without_fanart"] += 1
|
try:
|
||||||
logger.debug(
|
entries: Iterable[str] = os.listdir(self.directory)
|
||||||
"Series '%s' (key: %s) is missing fanart.jpg",
|
except OSError as error:
|
||||||
serie.name,
|
logger.error(
|
||||||
serie.key
|
"Unable to scan directory %s: %s",
|
||||||
)
|
self.directory,
|
||||||
|
error,
|
||||||
continue
|
)
|
||||||
|
return
|
||||||
logger.warning(
|
|
||||||
"Skipping folder %s because no metadata file was found",
|
nfo_stats = {"total": 0, "with_nfo": 0, "without_nfo": 0}
|
||||||
anime_folder,
|
media_stats = {
|
||||||
)
|
"with_poster": 0,
|
||||||
|
"without_poster": 0,
|
||||||
# Log summary statistics
|
"with_logo": 0,
|
||||||
if nfo_stats["total"] > 0:
|
"without_logo": 0,
|
||||||
logger.info(
|
"with_fanart": 0,
|
||||||
"NFO scan complete: %d series total, %d with NFO, %d without NFO",
|
"without_fanart": 0
|
||||||
nfo_stats["total"],
|
}
|
||||||
nfo_stats["with_nfo"],
|
|
||||||
nfo_stats["without_nfo"]
|
for anime_folder in entries:
|
||||||
)
|
if settings.should_ignore_folder(anime_folder):
|
||||||
logger.info(
|
logger.debug("Skipping ignored folder: %s", anime_folder)
|
||||||
"Media scan complete: Poster (%d/%d), Logo (%d/%d), Fanart (%d/%d)",
|
continue
|
||||||
media_stats["with_poster"],
|
anime_path = os.path.join(self.directory, anime_folder, "data")
|
||||||
nfo_stats["total"],
|
if os.path.isfile(anime_path):
|
||||||
media_stats["with_logo"],
|
logger.debug("Found data file for folder %s", anime_folder)
|
||||||
nfo_stats["total"],
|
serie = self._load_data(anime_folder, anime_path)
|
||||||
media_stats["with_fanart"],
|
|
||||||
nfo_stats["total"]
|
if serie:
|
||||||
)
|
nfo_stats["total"] += 1
|
||||||
|
# Check for NFO file
|
||||||
def _load_data(self, anime_folder: str, data_path: str) -> Optional[Serie]:
|
nfo_file_path = os.path.join(
|
||||||
"""
|
self.directory, anime_folder, "tvshow.nfo"
|
||||||
Load a single series metadata file into the in-memory collection.
|
)
|
||||||
|
if os.path.isfile(nfo_file_path):
|
||||||
Args:
|
serie.nfo_path = nfo_file_path
|
||||||
anime_folder: The folder name (for logging only)
|
nfo_stats["with_nfo"] += 1
|
||||||
data_path: Path to the metadata file
|
else:
|
||||||
|
nfo_stats["without_nfo"] += 1
|
||||||
Returns:
|
logger.debug(
|
||||||
Serie: The loaded Serie object, or None if loading failed
|
"Series '%s' (key: %s) is missing tvshow.nfo",
|
||||||
"""
|
serie.name,
|
||||||
try:
|
serie.key
|
||||||
serie = Serie.load_from_file(data_path)
|
)
|
||||||
# Store by key, not folder
|
|
||||||
self.keyDict[serie.key] = serie
|
# Check for media files
|
||||||
logger.debug(
|
folder_path = os.path.join(self.directory, anime_folder)
|
||||||
"Successfully loaded metadata for %s (key: %s)",
|
|
||||||
anime_folder,
|
poster_path = os.path.join(folder_path, "poster.jpg")
|
||||||
serie.key
|
if os.path.isfile(poster_path):
|
||||||
)
|
media_stats["with_poster"] += 1
|
||||||
return serie
|
else:
|
||||||
except (OSError, JSONDecodeError, KeyError, ValueError) as error:
|
media_stats["without_poster"] += 1
|
||||||
logger.error(
|
logger.debug(
|
||||||
"Failed to load metadata for folder %s from %s: %s",
|
"Series '%s' (key: %s) is missing poster.jpg",
|
||||||
anime_folder,
|
serie.name,
|
||||||
data_path,
|
serie.key
|
||||||
error,
|
)
|
||||||
)
|
|
||||||
return None
|
logo_path = os.path.join(folder_path, "logo.png")
|
||||||
|
if os.path.isfile(logo_path):
|
||||||
def GetMissingEpisode(self) -> List[Serie]:
|
media_stats["with_logo"] += 1
|
||||||
"""Return all series that still contain missing episodes."""
|
else:
|
||||||
return [
|
media_stats["without_logo"] += 1
|
||||||
serie
|
logger.debug(
|
||||||
for serie in self.keyDict.values()
|
"Series '%s' (key: %s) is missing logo.png",
|
||||||
if serie.episodeDict
|
serie.name,
|
||||||
]
|
serie.key
|
||||||
|
)
|
||||||
def get_missing_episodes(self) -> List[Serie]:
|
|
||||||
"""PEP8-friendly alias for :meth:`GetMissingEpisode`."""
|
fanart_path = os.path.join(folder_path, "fanart.jpg")
|
||||||
return self.GetMissingEpisode()
|
if os.path.isfile(fanart_path):
|
||||||
|
media_stats["with_fanart"] += 1
|
||||||
def GetList(self) -> List[Serie]:
|
else:
|
||||||
"""Return all series instances stored in the list."""
|
media_stats["without_fanart"] += 1
|
||||||
return list(self.keyDict.values())
|
logger.debug(
|
||||||
|
"Series '%s' (key: %s) is missing fanart.jpg",
|
||||||
def get_all(self) -> List[Serie]:
|
serie.name,
|
||||||
"""PEP8-friendly alias for :meth:`GetList`."""
|
serie.key
|
||||||
return self.GetList()
|
)
|
||||||
|
|
||||||
def get_by_key(self, key: str) -> Optional[Serie]:
|
continue
|
||||||
"""
|
|
||||||
Get a series by its unique provider key.
|
logger.warning(
|
||||||
|
"Skipping folder %s because no metadata file was found",
|
||||||
This is the primary method for series lookup.
|
anime_folder,
|
||||||
|
)
|
||||||
Args:
|
|
||||||
key: The unique provider identifier (e.g., "attack-on-titan")
|
# Log summary statistics
|
||||||
|
if nfo_stats["total"] > 0:
|
||||||
Returns:
|
logger.info(
|
||||||
The Serie instance if found, None otherwise
|
"NFO scan complete: %d series total, %d with NFO, %d without NFO",
|
||||||
"""
|
nfo_stats["total"],
|
||||||
return self.keyDict.get(key)
|
nfo_stats["with_nfo"],
|
||||||
|
nfo_stats["without_nfo"]
|
||||||
def get_by_folder(self, folder: str) -> Optional[Serie]:
|
)
|
||||||
"""
|
logger.info(
|
||||||
Get a series by its folder name.
|
"Media scan complete: Poster (%d/%d), Logo (%d/%d), Fanart (%d/%d)",
|
||||||
|
media_stats["with_poster"],
|
||||||
.. deprecated:: 2.0.0
|
nfo_stats["total"],
|
||||||
Use :meth:`get_by_key` instead. Folder-based lookups will be
|
media_stats["with_logo"],
|
||||||
removed in version 3.0.0. The `folder` field is metadata only
|
nfo_stats["total"],
|
||||||
and should not be used for identification.
|
media_stats["with_fanart"],
|
||||||
|
nfo_stats["total"]
|
||||||
This method is provided for backward compatibility only.
|
)
|
||||||
Prefer using get_by_key() for new code.
|
|
||||||
|
def _load_data(self, anime_folder: str, data_path: str) -> Optional[Serie]:
|
||||||
Args:
|
"""
|
||||||
folder: The filesystem folder name (e.g., "Attack on Titan (2013)")
|
Load a single series metadata file into the in-memory collection.
|
||||||
|
|
||||||
Returns:
|
Args:
|
||||||
The Serie instance if found, None otherwise
|
anime_folder: The folder name (for logging only)
|
||||||
"""
|
data_path: Path to the metadata file
|
||||||
warnings.warn(
|
|
||||||
"get_by_folder() is deprecated and will be removed in v3.0.0. "
|
Returns:
|
||||||
"Use get_by_key() instead. The 'folder' field is metadata only.",
|
Serie: The loaded Serie object, or None if loading failed
|
||||||
DeprecationWarning,
|
"""
|
||||||
stacklevel=2
|
try:
|
||||||
)
|
serie = Serie.load_from_file(data_path)
|
||||||
for serie in self.keyDict.values():
|
# Store by key, not folder
|
||||||
if serie.folder == folder:
|
self.keyDict[serie.key] = serie
|
||||||
return serie
|
logger.debug(
|
||||||
return None
|
"Successfully loaded metadata for %s (key: %s)",
|
||||||
|
anime_folder,
|
||||||
|
serie.key
|
||||||
|
)
|
||||||
|
return serie
|
||||||
|
except (OSError, JSONDecodeError, KeyError, ValueError) as error:
|
||||||
|
logger.error(
|
||||||
|
"Failed to load metadata for folder %s from %s: %s",
|
||||||
|
anime_folder,
|
||||||
|
data_path,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def GetMissingEpisode(self) -> List[Serie]:
|
||||||
|
"""Return all series that still contain missing episodes."""
|
||||||
|
return [
|
||||||
|
serie
|
||||||
|
for serie in self.keyDict.values()
|
||||||
|
if serie.episodeDict
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_missing_episodes(self) -> List[Serie]:
|
||||||
|
"""PEP8-friendly alias for :meth:`GetMissingEpisode`."""
|
||||||
|
return self.GetMissingEpisode()
|
||||||
|
|
||||||
|
def GetList(self) -> List[Serie]:
|
||||||
|
"""Return all series instances stored in the list."""
|
||||||
|
return list(self.keyDict.values())
|
||||||
|
|
||||||
|
def get_all(self) -> List[Serie]:
|
||||||
|
"""PEP8-friendly alias for :meth:`GetList`."""
|
||||||
|
return self.GetList()
|
||||||
|
|
||||||
|
def get_by_key(self, key: str) -> Optional[Serie]:
|
||||||
|
"""
|
||||||
|
Get a series by its unique provider key.
|
||||||
|
|
||||||
|
This is the primary method for series lookup.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The unique provider identifier (e.g., "attack-on-titan")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The Serie instance if found, None otherwise
|
||||||
|
"""
|
||||||
|
return self.keyDict.get(key)
|
||||||
|
|
||||||
|
def get_by_folder(self, folder: str) -> Optional[Serie]:
|
||||||
|
"""
|
||||||
|
Get a series by its folder name.
|
||||||
|
|
||||||
|
.. deprecated:: 2.0.0
|
||||||
|
Use :meth:`get_by_key` instead. Folder-based lookups will be
|
||||||
|
removed in version 3.0.0. The `folder` field is metadata only
|
||||||
|
and should not be used for identification.
|
||||||
|
|
||||||
|
This method is provided for backward compatibility only.
|
||||||
|
Prefer using get_by_key() for new code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder: The filesystem folder name (e.g., "Attack on Titan (2013)")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The Serie instance if found, None otherwise
|
||||||
|
"""
|
||||||
|
warnings.warn(
|
||||||
|
"get_by_folder() is deprecated and will be removed in v3.0.0. "
|
||||||
|
"Use get_by_key() instead. The 'folder' field is metadata only.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2
|
||||||
|
)
|
||||||
|
for serie in self.keyDict.values():
|
||||||
|
if serie.folder == folder:
|
||||||
|
return serie
|
||||||
|
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:
|
||||||
logger.warning(
|
delay = self._calculate_delay(attempt)
|
||||||
"Download error on attempt %d, retrying...",
|
logger.warning(
|
||||||
attempt + 1,
|
"Network error on attempt %d/%d, retrying in %.1fs: %s",
|
||||||
)
|
attempt + 1, self.max_retries, delay, exc
|
||||||
continue
|
)
|
||||||
|
import time
|
||||||
|
time.sleep(delay)
|
||||||
|
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,
|
||||||
@@ -259,7 +406,12 @@ class AniworldLoader(Loader):
|
|||||||
language: str = "German Dub"
|
language: str = "German Dub"
|
||||||
) -> 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)
|
season, episode, key, language
|
||||||
link, header = self._get_direct_link_from_provider(
|
)
|
||||||
|
if not candidate_providers:
|
||||||
|
logger.error(
|
||||||
|
"No providers advertised for S%02dE%03d (%s) in %s",
|
||||||
season, episode, key, language
|
season, episode, key, language
|
||||||
)
|
)
|
||||||
logger.debug("Direct link obtained from provider")
|
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("Download failed: temp file not found at %s", temp_path)
|
|
||||||
self.clear_cache()
|
|
||||||
return False
|
|
||||||
except BrokenPipeError as e:
|
|
||||||
logger.error(
|
logger.error(
|
||||||
"Broken pipe error with provider %s: %s. "
|
"Download failed: temp file not found at %s", temp_path
|
||||||
"This usually means the stream connection was closed.",
|
)
|
||||||
provider, e
|
except DownloadCancelled:
|
||||||
|
logger.info("Download cancelled by user")
|
||||||
|
_cleanup_temp_file(temp_path)
|
||||||
|
self.clear_cache()
|
||||||
|
return False
|
||||||
|
except BrokenPipeError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Broken pipe error with provider %s: %s",
|
||||||
|
provider_name, exc
|
||||||
)
|
)
|
||||||
_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),
|
||||||
)
|
)
|
||||||
|
|
||||||
await self._nfo_service.update_tvshow_nfo(
|
try:
|
||||||
series_name,
|
await self._nfo_service.update_tvshow_nfo(
|
||||||
download_media=False,
|
series_name,
|
||||||
)
|
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,63 +163,89 @@ 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_id = tv_show["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"]
|
||||||
|
|
||||||
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
|
||||||
details = await self.tmdb_client.get_tv_show_details(
|
# Skip if we already fetched details via nfo_override
|
||||||
tv_id,
|
if search_source != "nfo_override":
|
||||||
append_to_response="credits,external_ids,images"
|
details = await self.tmdb_client.get_tv_show_details(
|
||||||
)
|
tv_id,
|
||||||
|
append_to_response="credits,external_ids,images"
|
||||||
|
)
|
||||||
|
|
||||||
# Get content ratings for FSK
|
# Get content ratings for FSK
|
||||||
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tv_id)
|
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tv_id)
|
||||||
|
|
||||||
# Enrich with fallback languages for empty overview/tagline
|
# Enrich with fallback languages for empty overview/tagline
|
||||||
# Pass search result overview as last resort fallback
|
# Pass search result overview as last resort fallback
|
||||||
search_overview = tv_show.get("overview") or None
|
search_overview = tv_show.get("overview") or None
|
||||||
if not search_overview:
|
if not search_overview:
|
||||||
try:
|
try:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"No overview in German search result, trying en-US search fallback for: %s",
|
"No overview in German search result, trying en-US search fallback for: %s",
|
||||||
search_name,
|
search_name,
|
||||||
)
|
|
||||||
en_search_results = await self.tmdb_client.search_tv_show(
|
|
||||||
search_name,
|
|
||||||
language="en-US",
|
|
||||||
)
|
|
||||||
if en_search_results.get("results"):
|
|
||||||
en_match = self._find_best_match(
|
|
||||||
en_search_results["results"], search_name, year
|
|
||||||
)
|
)
|
||||||
search_overview = en_match.get("overview") or None
|
en_search_results = await self.tmdb_client.search_tv_show(
|
||||||
if search_overview:
|
search_name,
|
||||||
logger.info(
|
language="en-US",
|
||||||
"Using en-US search overview fallback for %s",
|
)
|
||||||
search_name,
|
if en_search_results.get("results"):
|
||||||
|
en_match = self._find_best_match(
|
||||||
|
en_search_results["results"], search_name, year
|
||||||
)
|
)
|
||||||
except (TMDBAPIError, Exception) as exc:
|
search_overview = en_match.get("overview") or None
|
||||||
logger.warning(
|
if search_overview:
|
||||||
"Failed en-US search fallback for overview: %s",
|
logger.info(
|
||||||
exc,
|
"Using en-US search overview fallback for %s",
|
||||||
)
|
search_name,
|
||||||
|
)
|
||||||
|
except (TMDBAPIError, Exception) as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed en-US search fallback for overview: %s",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
details = await self._enrich_details_with_fallback(
|
details = await self._enrich_details_with_fallback(
|
||||||
details, search_overview=search_overview
|
details, search_overview=search_overview
|
||||||
)
|
)
|
||||||
|
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(
|
||||||
@@ -412,6 +455,62 @@ class NFOService:
|
|||||||
logger.error("Error parsing NFO file %s: %s", nfo_path, e)
|
logger.error("Error parsing NFO file %s: %s", nfo_path, e)
|
||||||
|
|
||||||
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,
|
||||||
@@ -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,22 +198,49 @@ class SeriesManagerService:
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"Creating NFO for '{serie_name}' ({serie_folder})"
|
f"Creating NFO for '{serie_name}' ({serie_folder})"
|
||||||
)
|
)
|
||||||
await self.nfo_service.create_tvshow_nfo(
|
try:
|
||||||
serie_name=serie_name,
|
await self.nfo_service.create_tvshow_nfo(
|
||||||
serie_folder=serie_folder,
|
serie_name=serie_name,
|
||||||
year=year,
|
serie_folder=serie_folder,
|
||||||
download_poster=self.download_poster,
|
year=year,
|
||||||
download_logo=self.download_logo,
|
download_poster=self.download_poster,
|
||||||
download_fanart=self.download_fanart
|
download_logo=self.download_logo,
|
||||||
)
|
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:
|
||||||
logger.error("TMDB API error processing '%s': %s", serie_name, 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)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Unexpected error processing NFO for '{serie_name}': {e}",
|
f"Unexpected error processing NFO for '{serie_name}': {e}",
|
||||||
|
|||||||
@@ -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,58 +118,100 @@ 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
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
# Rate limiting: ensure we don't exceed ~35 requests/second
|
||||||
try:
|
async with self._rate_limit_lock:
|
||||||
# Re-ensure session before each attempt in case it was closed
|
now = time.monotonic()
|
||||||
await self._ensure_session()
|
# Remove timestamps older than 1 second
|
||||||
|
self._request_timestamps = [
|
||||||
if self.session is None:
|
ts for ts in self._request_timestamps if now - ts < 1.0
|
||||||
raise TMDBAPIError("Session is not available")
|
]
|
||||||
|
if len(self._request_timestamps) >= self._max_requests_per_second:
|
||||||
logger.debug("TMDB API request: %s (attempt %s)", endpoint, attempt + 1)
|
sleep_time = 1.0 - (now - self._request_timestamps[0])
|
||||||
async with self.session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
|
if sleep_time > 0:
|
||||||
if resp.status == 401:
|
logger.debug("Rate throttling: waiting %.2fs", sleep_time)
|
||||||
raise TMDBAPIError("Invalid TMDB API key")
|
await asyncio.sleep(sleep_time)
|
||||||
elif resp.status == 404:
|
self._request_timestamps.append(time.monotonic())
|
||||||
raise TMDBAPIError(f"Resource not found: {endpoint}")
|
|
||||||
elif resp.status == 429:
|
async with self._semaphore:
|
||||||
# Rate limit - wait longer
|
for attempt in range(max_retries):
|
||||||
retry_after = int(resp.headers.get('Retry-After', delay * 2))
|
try:
|
||||||
logger.warning("Rate limited, waiting %ss", retry_after)
|
# Re-ensure session before each attempt in case it was closed
|
||||||
await asyncio.sleep(retry_after)
|
|
||||||
continue
|
|
||||||
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = await resp.json()
|
|
||||||
self._cache[cache_key] = data
|
|
||||||
return data
|
|
||||||
|
|
||||||
except asyncio.TimeoutError as e:
|
|
||||||
last_error = e
|
|
||||||
if attempt < max_retries - 1:
|
|
||||||
logger.warning("Request timeout (attempt %s), retrying in %ss", attempt + 1, delay)
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
delay *= 2
|
|
||||||
else:
|
|
||||||
logger.error("Request timed out after %s attempts", max_retries)
|
|
||||||
|
|
||||||
except (aiohttp.ClientError, AttributeError) as e:
|
|
||||||
last_error = e
|
|
||||||
# If connector/session was closed, try to recreate it
|
|
||||||
if "Connector is closed" in str(e) or isinstance(e, AttributeError):
|
|
||||||
logger.warning("Session issue detected, recreating session: %s", e)
|
|
||||||
self.session = None
|
|
||||||
await self._ensure_session()
|
await self._ensure_session()
|
||||||
|
|
||||||
if attempt < max_retries - 1:
|
if self.session is None:
|
||||||
logger.warning("Request failed (attempt %s): %s, retrying in %ss", attempt + 1, e, delay)
|
raise TMDBAPIError("Session is not available")
|
||||||
await asyncio.sleep(delay)
|
|
||||||
delay *= 2
|
logger.debug("TMDB API request: %s (attempt %s)", endpoint, attempt + 1)
|
||||||
else:
|
async with self.session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
|
||||||
logger.error("Request failed after %s attempts: %s", max_retries, e)
|
if resp.status == 401:
|
||||||
|
raise TMDBAPIError("Invalid TMDB API key")
|
||||||
|
elif resp.status == 404:
|
||||||
|
raise TMDBAPIError(f"Resource not found: {endpoint}")
|
||||||
|
elif resp.status == 429:
|
||||||
|
# Rate limit - wait longer with exponential backoff
|
||||||
|
retry_after = int(resp.headers.get('Retry-After', max(delay * 2, 2)))
|
||||||
|
logger.warning("Rate limited, waiting %ss", retry_after)
|
||||||
|
await asyncio.sleep(retry_after)
|
||||||
|
continue
|
||||||
|
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = await resp.json()
|
||||||
|
self._cache[cache_key] = data
|
||||||
|
# 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
|
||||||
|
|
||||||
|
except asyncio.TimeoutError as e:
|
||||||
|
last_error = e
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
logger.warning("Request timeout (attempt %s), retrying in %ss", attempt + 1, delay)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
delay *= 2
|
||||||
|
else:
|
||||||
|
logger.error("Request timed out after %s attempts", max_retries)
|
||||||
|
|
||||||
|
except (aiohttp.ClientError, AttributeError) as e:
|
||||||
|
last_error = e
|
||||||
|
# If connector/session was closed, try to recreate it
|
||||||
|
if "Connector is closed" in str(e) or isinstance(e, AttributeError):
|
||||||
|
logger.warning(
|
||||||
|
"Session issue detected, recreating session: %s",
|
||||||
|
e,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
self.session = None
|
||||||
|
await self._ensure_session()
|
||||||
|
|
||||||
|
# DNS / host-unreachable errors are not transient — abort immediately
|
||||||
|
error_str = str(e)
|
||||||
|
if "name resolution" in error_str.lower() or (
|
||||||
|
isinstance(e, aiohttp.ClientConnectorError) and
|
||||||
|
"Cannot connect to host" in error_str
|
||||||
|
):
|
||||||
|
logger.error("Non-transient connection error, aborting retries: %s", e)
|
||||||
|
raise TMDBAPIError(f"Request failed after {attempt + 1} attempts: {e}") from e
|
||||||
|
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
logger.warning("Request failed (attempt %s): %s, retrying in %ss", attempt + 1, e, delay)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
delay *= 2
|
||||||
|
else:
|
||||||
|
logger.error("Request failed after %s attempts: %s", max_retries, e)
|
||||||
|
|
||||||
raise TMDBAPIError(f"Request failed after {max_retries} attempts: {last_error}")
|
raise TMDBAPIError(f"Request failed after {max_retries} attempts: {last_error}")
|
||||||
|
|
||||||
@@ -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,
|
||||||
@@ -309,8 +387,38 @@ class TMDBClient:
|
|||||||
await self.session.close()
|
await self.session.close()
|
||||||
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,14 @@
|
|||||||
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, field_validator
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.core.entities.series import Serie
|
from src.config.settings import settings
|
||||||
|
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,
|
||||||
@@ -25,6 +27,9 @@ from src.server.utils.dependencies import (
|
|||||||
)
|
)
|
||||||
from src.server.utils.filesystem import sanitize_folder_name
|
from src.server.utils.filesystem import sanitize_folder_name
|
||||||
from src.server.utils.validators import validate_filter_value, validate_search_query
|
from src.server.utils.validators import validate_filter_value, validate_search_query
|
||||||
|
from src.server.services.folder_rename_service import (
|
||||||
|
_scan_for_pre_existing_duplicates,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -70,6 +75,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.
|
||||||
|
|
||||||
@@ -133,6 +232,14 @@ class AnimeSummary(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="ISO timestamp when NFO was last updated"
|
description="ISO timestamp when NFO was last updated"
|
||||||
)
|
)
|
||||||
|
loading_status: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Current loading status (e.g., 'completed', 'failed', 'in_progress')"
|
||||||
|
)
|
||||||
|
loading_error: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Error message if loading failed (e.g., 'key cannot be None or empty')"
|
||||||
|
)
|
||||||
tmdb_id: Optional[int] = Field(
|
tmdb_id: Optional[int] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="The Movie Database (TMDB) ID"
|
description="The Movie Database (TMDB) ID"
|
||||||
@@ -331,6 +438,8 @@ async def list_anime(
|
|||||||
nfo_updated_at=series_dict.get("nfo_updated_at"),
|
nfo_updated_at=series_dict.get("nfo_updated_at"),
|
||||||
tmdb_id=series_dict.get("tmdb_id"),
|
tmdb_id=series_dict.get("tmdb_id"),
|
||||||
tvdb_id=series_dict.get("tvdb_id"),
|
tvdb_id=series_dict.get("tvdb_id"),
|
||||||
|
loading_status=series_dict.get("loading_status"),
|
||||||
|
loading_error=series_dict.get("loading_error"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -730,7 +839,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
|
||||||
|
|
||||||
@@ -1081,6 +1194,346 @@ async def get_anime(
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
class ManualKeyUpdate(BaseModel):
|
||||||
|
"""Request model for manually updating a series key."""
|
||||||
|
|
||||||
|
key: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=2,
|
||||||
|
description="New URL-safe key for the series (alphanumeric, hyphens, underscores)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{anime_key}/manual-key", response_model=dict)
|
||||||
|
async def update_series_manual_key(
|
||||||
|
anime_key: str,
|
||||||
|
update_data: ManualKeyUpdate,
|
||||||
|
db: AsyncSession = Depends(get_optional_database_session),
|
||||||
|
series_app: Any = Depends(get_series_app),
|
||||||
|
) -> dict:
|
||||||
|
"""Manually update the key for a series.
|
||||||
|
|
||||||
|
This endpoint allows users to supply a key for folders that failed
|
||||||
|
automatic key generation (e.g., non-Latin characters, special symbols).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_key: Current series key
|
||||||
|
update_data: New key to assign
|
||||||
|
db: Database session
|
||||||
|
series_app: SeriesApp instance for in-memory updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated series info with new key
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If validation fails or series not found
|
||||||
|
"""
|
||||||
|
new_key = update_data.key.strip()
|
||||||
|
|
||||||
|
# Validate the new key format
|
||||||
|
if not is_valid_key(new_key):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid key format. Key must be URL-safe (alphanumeric, hyphens, underscores only)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find the series - check DB first
|
||||||
|
series_db = None
|
||||||
|
if db:
|
||||||
|
series_db = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||||
|
|
||||||
|
# Also check in-memory list if series_app available
|
||||||
|
found_in_memory = None
|
||||||
|
if series_app and hasattr(series_app, "list"):
|
||||||
|
for serie in series_app.list.GetList():
|
||||||
|
if getattr(serie, "key", None) == anime_key:
|
||||||
|
found_in_memory = serie
|
||||||
|
break
|
||||||
|
|
||||||
|
if not series_db and not found_in_memory:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series with key '{anime_key}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if new key is already in use
|
||||||
|
existing_keys = set()
|
||||||
|
if db:
|
||||||
|
all_series = await AnimeSeriesService.get_all(db)
|
||||||
|
existing_keys = {s.key for s in all_series if s.key != anime_key}
|
||||||
|
if series_app and hasattr(series_app, "list"):
|
||||||
|
for serie in series_app.list.GetList():
|
||||||
|
key = getattr(serie, "key", None)
|
||||||
|
if key and key != anime_key:
|
||||||
|
existing_keys.add(key)
|
||||||
|
|
||||||
|
if new_key in existing_keys:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Key '{new_key}' is already in use by another series"
|
||||||
|
)
|
||||||
|
|
||||||
|
old_key = anime_key
|
||||||
|
|
||||||
|
# Update in database if found
|
||||||
|
if series_db:
|
||||||
|
from src.server.database.connection import get_db
|
||||||
|
async with get_db() as session:
|
||||||
|
await AnimeSeriesService.update(
|
||||||
|
session,
|
||||||
|
series_db.id,
|
||||||
|
key=new_key,
|
||||||
|
loading_error=None # Clear error on successful key update
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Update in-memory cache
|
||||||
|
if found_in_memory:
|
||||||
|
try:
|
||||||
|
found_in_memory.key = new_key
|
||||||
|
logger.info(
|
||||||
|
"Updated in-memory key for series: %s -> %s",
|
||||||
|
old_key,
|
||||||
|
new_key
|
||||||
|
)
|
||||||
|
except ValueError as ve:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(ve)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Manual key update successful: %s -> %s",
|
||||||
|
old_key,
|
||||||
|
new_key
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"old_key": old_key,
|
||||||
|
"new_key": new_key,
|
||||||
|
"message": f"Key updated from '{old_key}' to '{new_key}'"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataIdsUpdate(BaseModel):
|
||||||
|
"""Request model for manually updating TMDB and TVDB IDs."""
|
||||||
|
|
||||||
|
tmdb_id: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="TMDB ID (positive integer, or null to clear)"
|
||||||
|
)
|
||||||
|
tvdb_id: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="TVDB ID (positive integer, or null to clear)"
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("tmdb_id", "tvdb_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_positive_or_null(cls, v):
|
||||||
|
if v is not None and v <= 0:
|
||||||
|
raise ValueError("ID must be a positive integer or null")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{anime_key}/metadata-ids", response_model=dict)
|
||||||
|
async def update_series_metadata_ids(
|
||||||
|
anime_key: str,
|
||||||
|
update_data: MetadataIdsUpdate,
|
||||||
|
db: AsyncSession = Depends(get_optional_database_session),
|
||||||
|
series_app: Any = Depends(get_series_app),
|
||||||
|
) -> dict:
|
||||||
|
"""Manually update TMDB and TVDB IDs for a series.
|
||||||
|
|
||||||
|
This endpoint allows users to supply missing metadata IDs for series
|
||||||
|
that failed automatic TMDB lookup. After updating IDs, it triggers
|
||||||
|
a background NFO re-generation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_key: Series key
|
||||||
|
update_data: TMDB and TVDB IDs to set
|
||||||
|
db: Database session
|
||||||
|
series_app: SeriesApp instance for in-memory updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated series info with new IDs
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If validation fails or series not found
|
||||||
|
"""
|
||||||
|
if update_data.tmdb_id is None and update_data.tvdb_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="At least one of tmdb_id or tvdb_id must be provided"
|
||||||
|
)
|
||||||
|
|
||||||
|
series_db = None
|
||||||
|
if db:
|
||||||
|
series_db = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||||
|
|
||||||
|
if not series_db:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series with key '{anime_key}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
update_fields = {}
|
||||||
|
if update_data.tmdb_id is not None:
|
||||||
|
update_fields["tmdb_id"] = update_data.tmdb_id
|
||||||
|
if update_data.tvdb_id is not None:
|
||||||
|
update_fields["tvdb_id"] = update_data.tvdb_id
|
||||||
|
|
||||||
|
if db:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
update_fields["nfo_updated_at"] = datetime.now(timezone.utc)
|
||||||
|
update_fields["has_nfo"] = True
|
||||||
|
|
||||||
|
await AnimeSeriesService.update(
|
||||||
|
db,
|
||||||
|
series_db.id,
|
||||||
|
**update_fields
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Update in-memory cache if available
|
||||||
|
if series_app and hasattr(series_app, "list"):
|
||||||
|
for serie in series_app.list.GetList():
|
||||||
|
if getattr(serie, "key", None) == anime_key:
|
||||||
|
if update_data.tmdb_id is not None:
|
||||||
|
serie.tmdb_id = update_data.tmdb_id
|
||||||
|
if update_data.tvdb_id is not None:
|
||||||
|
serie.tvdb_id = update_data.tvdb_id
|
||||||
|
break
|
||||||
|
|
||||||
|
# Trigger background NFO re-generation
|
||||||
|
background_loader = None
|
||||||
|
try:
|
||||||
|
background_loader = await get_background_loader_service()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
nfo_queued = False
|
||||||
|
if background_loader and db:
|
||||||
|
try:
|
||||||
|
from src.server.database.connection import get_db_session
|
||||||
|
async with get_db_session() as bg_db:
|
||||||
|
series_for_bg = await AnimeSeriesService.get_by_key(bg_db, anime_key)
|
||||||
|
if series_for_bg:
|
||||||
|
await background_loader.load_series_nfo(
|
||||||
|
series_for_bg.key,
|
||||||
|
series_for_bg.folder,
|
||||||
|
series_for_bg.name,
|
||||||
|
force_refresh=True
|
||||||
|
)
|
||||||
|
nfo_queued = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to queue NFO refresh for '%s': %s", anime_key, str(e))
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Metadata IDs updated for '%s': tmdb_id=%s, tvdb_id=%s, NFO_queued=%s",
|
||||||
|
anime_key,
|
||||||
|
update_data.tmdb_id,
|
||||||
|
update_data.tvdb_id,
|
||||||
|
nfo_queued
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"key": anime_key,
|
||||||
|
"tmdb_id": update_data.tmdb_id,
|
||||||
|
"tvdb_id": update_data.tvdb_id,
|
||||||
|
"nfo_refresh_queued": nfo_queued,
|
||||||
|
"message": "Metadata IDs updated. NFO refresh queued." if nfo_queued
|
||||||
|
else "Metadata IDs updated."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{anime_key}/refresh-nfo", response_model=dict)
|
||||||
|
async def refresh_series_nfo(
|
||||||
|
anime_key: str,
|
||||||
|
db: AsyncSession = Depends(get_optional_database_session),
|
||||||
|
series_app: Any = Depends(get_series_app),
|
||||||
|
) -> dict:
|
||||||
|
"""Force NFO re-generation for a series using current IDs.
|
||||||
|
|
||||||
|
This endpoint triggers a background NFO re-generation using the
|
||||||
|
existing TMDB/TVDB IDs (or creating minimal NFO if no IDs exist).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
anime_key: Series key
|
||||||
|
db: Database session
|
||||||
|
series_app: SeriesApp instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Status of NFO refresh operation
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If series not found
|
||||||
|
"""
|
||||||
|
if db:
|
||||||
|
series_db = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||||
|
|
||||||
|
if not db or not series_db:
|
||||||
|
# Check in-memory
|
||||||
|
found = None
|
||||||
|
if series_app and hasattr(series_app, "list"):
|
||||||
|
for serie in series_app.list.GetList():
|
||||||
|
if getattr(serie, "key", None) == anime_key:
|
||||||
|
found = serie
|
||||||
|
break
|
||||||
|
if not found:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series with key '{anime_key}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
background_loader = None
|
||||||
|
try:
|
||||||
|
background_loader = await get_background_loader_service()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not background_loader:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Background loader service not available"
|
||||||
|
)
|
||||||
|
|
||||||
|
series_for_bg = None
|
||||||
|
if db:
|
||||||
|
async with get_db_session() as bg_db:
|
||||||
|
series_for_bg = await AnimeSeriesService.get_by_key(bg_db, anime_key)
|
||||||
|
|
||||||
|
if not series_for_bg:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Series with key '{anime_key}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await background_loader.load_series_nfo(
|
||||||
|
series_for_bg.key,
|
||||||
|
series_for_bg.folder,
|
||||||
|
series_for_bg.name,
|
||||||
|
force_refresh=True
|
||||||
|
)
|
||||||
|
nfo_queued = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to queue NFO refresh for '%s': %s", anime_key, str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to queue NFO refresh: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("NFO refresh queued for '%s'", anime_key)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"key": anime_key,
|
||||||
|
"message": "NFO refresh queued"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Maximum allowed input size for security
|
# Maximum allowed input size for security
|
||||||
MAX_INPUT_LENGTH = 100000 # 100KB
|
MAX_INPUT_LENGTH = 100000 # 100KB
|
||||||
|
|
||||||
|
|||||||
@@ -163,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
|
||||||
@@ -26,6 +26,9 @@ class HealthStatus(BaseModel):
|
|||||||
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):
|
||||||
@@ -171,29 +174,90 @@ 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.
|
||||||
"""
|
"""
|
||||||
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),
|
||||||
|
|||||||
@@ -144,6 +144,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 +450,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}",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -169,6 +169,26 @@ class AnimeSeriesService:
|
|||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
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,
|
||||||
@@ -541,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.
|
||||||
|
|
||||||
@@ -548,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
|
||||||
@@ -557,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())
|
||||||
@@ -622,11 +649,11 @@ class EpisodeService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def delete(db: AsyncSession, episode_id: int) -> bool:
|
async def delete(db: AsyncSession, episode_id: int) -> bool:
|
||||||
"""Delete episode.
|
"""Delete episode.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
episode_id: Episode primary key
|
episode_id: Episode primary key
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if deleted, False if not found
|
True if deleted, False if not found
|
||||||
"""
|
"""
|
||||||
@@ -635,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,
|
||||||
@@ -748,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.
|
||||||
|
|
||||||
@@ -757,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
|
||||||
@@ -766,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
|
||||||
|
|
||||||
@@ -799,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
|
||||||
@@ -873,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).
|
||||||
@@ -297,19 +397,6 @@ 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)
|
||||||
else:
|
else:
|
||||||
@@ -317,6 +404,22 @@ async def lifespan(_application: FastAPI):
|
|||||||
"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
|
||||||
@@ -329,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
|
||||||
|
|||||||
@@ -44,6 +44,18 @@ class SchedulerConfig(BaseModel):
|
|||||||
description="Run folder maintenance (NFO repair, folder renaming, "
|
description="Run folder maintenance (NFO repair, folder renaming, "
|
||||||
"poster checks) during the scheduled run.",
|
"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
|
||||||
@@ -69,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."""
|
||||||
@@ -171,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"
|
||||||
)
|
)
|
||||||
@@ -209,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:
|
||||||
@@ -225,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):
|
||||||
|
|||||||
@@ -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,7 +528,23 @@ 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":
|
||||||
@@ -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
|
||||||
@@ -1486,6 +1580,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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -144,7 +144,13 @@ class ConfigService:
|
|||||||
# Save configuration with version
|
# Save configuration with version
|
||||||
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,18 +1154,23 @@ class DownloadService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if retried_ids:
|
if retried_ids:
|
||||||
# Notify via progress service
|
# Notify via progress service if available
|
||||||
queue_status = await self.get_queue_status()
|
try:
|
||||||
await self._progress_service.update_progress(
|
queue_status = await self.get_queue_status()
|
||||||
progress_id="download_queue",
|
await self._progress_service.update_progress(
|
||||||
message=f"Retried {len(retried_ids)} failed items",
|
progress_id="download_queue",
|
||||||
metadata={
|
message=f"Retried {len(retried_ids)} failed items",
|
||||||
"action": "items_retried",
|
metadata={
|
||||||
"retried_ids": retried_ids,
|
"action": "items_retried",
|
||||||
"queue_status": queue_status.model_dump(mode="json"),
|
"retried_ids": retried_ids,
|
||||||
},
|
"queue_status": queue_status.model_dump(mode="json"),
|
||||||
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,17 +1310,35 @@ 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))
|
||||||
|
|
||||||
logger.error(
|
# Check if max retries exceeded - move to dead-letter
|
||||||
"Download failed: item_id=%s, error=%s, retry_count=%d",
|
if item.retry_count >= self._max_retries:
|
||||||
item.id,
|
await self._set_status_in_database(
|
||||||
str(e),
|
item.id, DownloadStatus.PERMANENTLY_FAILED.value
|
||||||
item.retry_count,
|
)
|
||||||
)
|
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(
|
||||||
|
"Download failed: item_id=%s, error=%s, retry_count=%d",
|
||||||
|
item.id,
|
||||||
|
str(e),
|
||||||
|
item.retry_count,
|
||||||
|
)
|
||||||
# Note: Failure is already broadcast by AnimeService
|
# Note: Failure is already broadcast by AnimeService
|
||||||
# via ProgressService when SeriesApp fires failed event
|
# via ProgressService when SeriesApp fires failed event
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ reflect the new paths.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
@@ -34,6 +35,141 @@ logger = logging.getLogger(__name__)
|
|||||||
INVALID_PATH_CHARS = '<>:"/\\|?*\x00'
|
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]]:
|
def _parse_nfo_title_and_year(nfo_path: Path) -> Tuple[Optional[str], Optional[str]]:
|
||||||
"""Parse a tvshow.nfo and return (title, year) text values.
|
"""Parse a tvshow.nfo and return (title, year) text values.
|
||||||
|
|
||||||
@@ -66,6 +202,9 @@ def _parse_nfo_title_and_year(nfo_path: Path) -> Tuple[Optional[str], Optional[s
|
|||||||
def _compute_expected_folder_name(title: str, year: str) -> str:
|
def _compute_expected_folder_name(title: str, year: str) -> str:
|
||||||
"""Compute the expected folder name from title and year.
|
"""Compute the expected folder name from title and year.
|
||||||
|
|
||||||
|
Removes any existing year suffixes (e.g., "(2021)") before adding the
|
||||||
|
canonical one to prevent duplication across multiple folder rename runs.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
title: Series title from NFO.
|
title: Series title from NFO.
|
||||||
year: Release year from NFO.
|
year: Release year from NFO.
|
||||||
@@ -73,7 +212,15 @@ def _compute_expected_folder_name(title: str, year: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
Sanitised folder name in the format ``"{title} ({year})"``.
|
Sanitised folder name in the format ``"{title} ({year})"``.
|
||||||
"""
|
"""
|
||||||
raw_name = f"{title} ({year})"
|
import re
|
||||||
|
|
||||||
|
# Remove all trailing year suffixes to prevent duplication.
|
||||||
|
# This handles cases where the title already contains one or more years.
|
||||||
|
# Regex pattern: matches one or more " (YYYY)" at the end of the string
|
||||||
|
clean_title = re.sub(r'(\s*\(\d{4}\))+\s*$', '', title).strip()
|
||||||
|
|
||||||
|
year_suffix = f" ({year})"
|
||||||
|
raw_name = f"{clean_title}{year_suffix}"
|
||||||
return sanitize_folder_name(raw_name)
|
return sanitize_folder_name(raw_name)
|
||||||
|
|
||||||
|
|
||||||
@@ -104,6 +251,136 @@ def _is_series_being_downloaded(series_folder: str) -> bool:
|
|||||||
return True
|
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(
|
async def _update_database_paths(
|
||||||
old_folder: str,
|
old_folder: str,
|
||||||
new_folder: str,
|
new_folder: str,
|
||||||
@@ -200,7 +477,7 @@ async def _update_database_paths(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def validate_and_rename_series_folders() -> Dict[str, int]:
|
async def validate_and_rename_series_folders(dry_run: bool = False) -> Dict[str, int]:
|
||||||
"""Validate and rename series folders to match NFO metadata.
|
"""Validate and rename series folders to match NFO metadata.
|
||||||
|
|
||||||
Iterates over every subfolder in ``settings.anime_directory`` that
|
Iterates over every subfolder in ``settings.anime_directory`` that
|
||||||
@@ -215,6 +492,10 @@ async def validate_and_rename_series_folders() -> Dict[str, int]:
|
|||||||
Skips folders where title or year is missing/empty. Logs every
|
Skips folders where title or year is missing/empty. Logs every
|
||||||
rename action.
|
rename action.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dry_run: If True, simulate rename operations without actually
|
||||||
|
moving folders or updating the database.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with counts:
|
Dictionary with counts:
|
||||||
- ``"scanned"``: total folders scanned
|
- ``"scanned"``: total folders scanned
|
||||||
@@ -233,8 +514,33 @@ async def validate_and_rename_series_folders() -> Dict[str, int]:
|
|||||||
)
|
)
|
||||||
return {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}
|
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}
|
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()):
|
for series_dir in sorted(anime_dir.iterdir()):
|
||||||
if not series_dir.is_dir():
|
if not series_dir.is_dir():
|
||||||
continue
|
continue
|
||||||
@@ -274,6 +580,15 @@ async def validate_and_rename_series_folders() -> Dict[str, int]:
|
|||||||
|
|
||||||
expected_path = anime_dir / expected_name
|
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
|
# Check for duplicate target
|
||||||
if expected_path.exists():
|
if expected_path.exists():
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -281,7 +596,55 @@ async def validate_and_rename_series_folders() -> Dict[str, int]:
|
|||||||
current_name,
|
current_name,
|
||||||
expected_name,
|
expected_name,
|
||||||
)
|
)
|
||||||
stats["errors"] += 1
|
# 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
|
continue
|
||||||
|
|
||||||
# Check path length limits
|
# Check path length limits
|
||||||
@@ -294,7 +657,17 @@ async def validate_and_rename_series_folders() -> Dict[str, int]:
|
|||||||
stats["errors"] += 1
|
stats["errors"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
logger.info(
|
||||||
|
"[DRY-RUN] Would rename folder: '%s' → '%s'",
|
||||||
|
current_name,
|
||||||
|
expected_name,
|
||||||
|
)
|
||||||
|
stats["renamed"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
old_path = series_dir
|
||||||
series_dir.rename(expected_path)
|
series_dir.rename(expected_path)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Renamed folder: '%s' → '%s'", current_name, expected_name
|
"Renamed folder: '%s' → '%s'", current_name, expected_name
|
||||||
@@ -304,6 +677,12 @@ async def validate_and_rename_series_folders() -> Dict[str, int]:
|
|||||||
# Update database records
|
# Update database records
|
||||||
await _update_database_paths(current_name, expected_name, anime_dir)
|
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:
|
except PermissionError as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Permission denied renaming '%s' → '%s': %s",
|
"Permission denied renaming '%s' → '%s': %s",
|
||||||
|
|||||||
@@ -28,6 +28,36 @@ _POSTER_DOWNLOAD_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
|
|||||||
_NFO_REPAIR_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(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:
|
async def _repair_one_series(series_dir: Path, series_name: str) -> None:
|
||||||
"""Repair a single series NFO in isolation.
|
"""Repair a single series NFO in isolation.
|
||||||
|
|
||||||
@@ -63,12 +93,13 @@ async def _repair_one_series(series_dir: Path, series_name: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def perform_nfo_repair_scan(background_loader=None) -> None:
|
async def perform_nfo_repair_scan(background_loader=None) -> None:
|
||||||
"""Scan all series folders and repair incomplete tvshow.nfo files.
|
"""Scan all series folders, repair incomplete and create missing NFO files.
|
||||||
|
|
||||||
Called from ``FolderScanService.run_folder_scan()`` during the scheduled
|
Called from ``FolderScanService.run_folder_scan()`` during the scheduled
|
||||||
daily folder scan (not on every startup). Checks each subfolder of
|
daily folder scan (not on every startup). Checks each subfolder of
|
||||||
``settings.anime_directory`` for a ``tvshow.nfo`` and calls
|
``settings.anime_directory`` for a ``tvshow.nfo``:
|
||||||
``_repair_one_series`` for every file with absent or empty required tags.
|
- 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` /
|
Each repair task creates its own isolated :class:`NFOService` /
|
||||||
:class:`TMDBClient` so concurrent tasks never share an ``aiohttp``
|
:class:`TMDBClient` so concurrent tasks never share an ``aiohttp``
|
||||||
@@ -97,26 +128,33 @@ async def perform_nfo_repair_scan(background_loader=None) -> None:
|
|||||||
|
|
||||||
queued = 0
|
queued = 0
|
||||||
total = 0
|
total = 0
|
||||||
|
missing_nfo_count = 0
|
||||||
for series_dir in sorted(anime_dir.iterdir()):
|
for series_dir in sorted(anime_dir.iterdir()):
|
||||||
if not series_dir.is_dir():
|
if not series_dir.is_dir():
|
||||||
continue
|
continue
|
||||||
nfo_path = series_dir / "tvshow.nfo"
|
nfo_path = series_dir / "tvshow.nfo"
|
||||||
|
series_name = series_dir.name
|
||||||
if not nfo_path.exists():
|
if not nfo_path.exists():
|
||||||
|
# Create minimal NFO for series without one
|
||||||
|
missing_nfo_count += 1
|
||||||
|
asyncio.create_task(
|
||||||
|
_create_missing_nfo(series_dir, series_name),
|
||||||
|
name=f"nfo_create:{series_name}",
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
total += 1
|
total += 1
|
||||||
series_name = series_dir.name
|
|
||||||
if nfo_needs_repair(nfo_path):
|
if nfo_needs_repair(nfo_path):
|
||||||
queued += 1
|
queued += 1
|
||||||
# Each task creates its own NFOService so connectors are isolated.
|
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
_repair_one_series(series_dir, series_name),
|
_repair_one_series(series_dir, series_name),
|
||||||
name=f"nfo_repair:{series_name}",
|
name=f"nfo_repair:{series_name}",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"NFO repair scan complete: %d of %d series queued for repair",
|
"NFO repair scan complete: %d of %d series queued for repair, %d missing NFOs queued for creation",
|
||||||
queued,
|
queued,
|
||||||
total,
|
total,
|
||||||
|
missing_nfo_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -181,18 +328,19 @@ async def _validate_anime_directory(progress_service=None) -> bool:
|
|||||||
|
|
||||||
async def perform_initial_setup(progress_service=None):
|
async def perform_initial_setup(progress_service=None):
|
||||||
"""Perform initial setup including series sync and scan completion marking.
|
"""Perform initial setup including series sync and scan completion marking.
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if initialization was performed, False if skipped
|
bool: True if initialization was performed, False if skipped
|
||||||
"""
|
"""
|
||||||
@@ -225,17 +373,30 @@ 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()
|
||||||
|
|
||||||
# Load series into memory from database
|
# Load series into memory from database
|
||||||
await _load_series_into_memory(progress_service)
|
await _load_series_into_memory(progress_service)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except (OSError, RuntimeError, ValueError) as e:
|
except (OSError, RuntimeError, ValueError) as e:
|
||||||
logger.warning("Failed to perform initial setup: %s", e)
|
logger.warning("Failed to perform initial setup: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -350,6 +347,110 @@ class QueueRepository:
|
|||||||
finally:
|
finally:
|
||||||
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,
|
||||||
|
|||||||
@@ -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,12 +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,
|
||||||
folder_scan=config.folder_scan_enabled,
|
config.folder_scan_enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not self._scheduler or not self._scheduler.running:
|
if not self._scheduler or not self._scheduler.running:
|
||||||
@@ -166,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:
|
||||||
@@ -235,13 +296,74 @@ 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 ( # noqa: PLC0415
|
||||||
|
get_db_session,
|
||||||
|
)
|
||||||
|
from src.server.database.system_settings_service import ( # noqa: PLC0415
|
||||||
|
SystemSettingsService,
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
@@ -252,16 +374,30 @@ class SchedulerService:
|
|||||||
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()
|
||||||
|
|
||||||
@@ -291,26 +427,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")
|
||||||
@@ -318,18 +459,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",
|
||||||
@@ -346,8 +489,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(
|
||||||
@@ -366,10 +509,11 @@ class SchedulerService:
|
|||||||
|
|
||||||
folder_scan_service = FolderScanService()
|
folder_scan_service = FolderScanService()
|
||||||
await folder_scan_service.run_folder_scan()
|
await folder_scan_service.run_folder_scan()
|
||||||
|
logger.info("Folder scan completed successfully")
|
||||||
except Exception as fs_exc: # pylint: disable=broad-exception-caught
|
except Exception as fs_exc: # pylint: disable=broad-exception-caught
|
||||||
logger.error(
|
logger.error(
|
||||||
"Folder scan failed",
|
"Folder scan failed: %s",
|
||||||
error=str(fs_exc),
|
fs_exc,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
await self._broadcast(
|
await self._broadcast(
|
||||||
@@ -379,7 +523,7 @@ class SchedulerService:
|
|||||||
logger.debug("Folder scan is disabled — skipping")
|
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()},
|
||||||
@@ -387,6 +531,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)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -400,7 +565,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -442,6 +442,23 @@ class AniWorldApp {
|
|||||||
this.hideConfigModal();
|
this.hideConfigModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Edit key modal
|
||||||
|
document.getElementById('close-edit-key').addEventListener('click', () => {
|
||||||
|
this.hideEditKeyModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('cancel-edit-key').addEventListener('click', () => {
|
||||||
|
this.hideEditKeyModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('#edit-key-modal .modal-overlay').addEventListener('click', () => {
|
||||||
|
this.hideEditKeyModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('save-edit-key').addEventListener('click', () => {
|
||||||
|
this.saveManualKey();
|
||||||
|
});
|
||||||
|
|
||||||
// Scheduler configuration
|
// Scheduler configuration
|
||||||
document.getElementById('scheduled-rescan-enabled').addEventListener('change', () => {
|
document.getElementById('scheduled-rescan-enabled').addEventListener('change', () => {
|
||||||
this.toggleSchedulerTimeInput();
|
this.toggleSchedulerTimeInput();
|
||||||
@@ -1547,6 +1564,72 @@ class AniWorldApp {
|
|||||||
document.getElementById('config-modal').classList.add('hidden');
|
document.getElementById('config-modal').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showEditKeyModal(key, folder) {
|
||||||
|
this._currentEditKey = key;
|
||||||
|
document.getElementById('edit-key-folder').textContent = folder;
|
||||||
|
document.getElementById('edit-key-input').value = '';
|
||||||
|
document.getElementById('edit-key-error').classList.add('hidden');
|
||||||
|
document.getElementById('edit-key-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
hideEditKeyModal() {
|
||||||
|
document.getElementById('edit-key-modal').classList.add('hidden');
|
||||||
|
this._currentEditKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveManualKey() {
|
||||||
|
const oldKey = this._currentEditKey;
|
||||||
|
const newKey = document.getElementById('edit-key-input').value.trim();
|
||||||
|
const errorEl = document.getElementById('edit-key-error');
|
||||||
|
|
||||||
|
if (!newKey || newKey.length < 2) {
|
||||||
|
errorEl.textContent = 'Key must be at least 2 characters';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate key format (URL-safe)
|
||||||
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(newKey)) {
|
||||||
|
errorEl.textContent = 'Key must be URL-safe (alphanumeric, hyphens, underscores only)';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.makeAuthenticatedRequest(
|
||||||
|
`/api/anime/${encodeURIComponent(oldKey)}/manual-key`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key: newKey })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
errorEl.textContent = 'Failed to update key';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
this.hideEditKeyModal();
|
||||||
|
this.showToast(`Key updated: ${oldKey} → ${newKey}`, 'success');
|
||||||
|
// Reload series list
|
||||||
|
if (typeof AniWorld.SeriesManager !== 'undefined') {
|
||||||
|
AniWorld.SeriesManager.loadSeries();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await response.json().catch(() => ({ detail: 'Update failed' }));
|
||||||
|
errorEl.textContent = data.detail || 'Failed to update key';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving manual key:', err);
|
||||||
|
errorEl.textContent = 'Error updating key';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async loadSchedulerConfig() {
|
async loadSchedulerConfig() {
|
||||||
try {
|
try {
|
||||||
const response = await this.makeAuthenticatedRequest('/api/scheduler/config');
|
const response = await this.makeAuthenticatedRequest('/api/scheduler/config');
|
||||||
@@ -2344,4 +2427,336 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Global functions for inline event handlers
|
// Global functions for inline event handlers
|
||||||
window.app = null;
|
window.app = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the edit key modal
|
||||||
|
* @param {string} currentKey - The current series key
|
||||||
|
* @param {string} folderName - The folder name
|
||||||
|
*/
|
||||||
|
function showEditKeyModal(currentKey, folderName) {
|
||||||
|
const modal = document.getElementById('edit-key-modal');
|
||||||
|
const overlay = document.getElementById('edit-key-overlay');
|
||||||
|
const folderSpan = document.getElementById('edit-key-folder');
|
||||||
|
const keyInput = document.getElementById('edit-key-input');
|
||||||
|
const errorSpan = document.getElementById('edit-key-error');
|
||||||
|
const saveBtn = document.getElementById('edit-key-save');
|
||||||
|
|
||||||
|
if (!modal || !overlay || !folderSpan || !keyInput) {
|
||||||
|
console.error('Edit key modal elements not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
folderSpan.textContent = folderName;
|
||||||
|
keyInput.value = currentKey;
|
||||||
|
keyInput.dataset.originalKey = currentKey;
|
||||||
|
errorSpan.textContent = '';
|
||||||
|
errorSpan.style.display = 'none';
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
keyInput.focus();
|
||||||
|
keyInput.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the edit key modal
|
||||||
|
*/
|
||||||
|
function hideEditKeyModal() {
|
||||||
|
const modal = document.getElementById('edit-key-modal');
|
||||||
|
const overlay = document.getElementById('edit-key-overlay');
|
||||||
|
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
if (overlay) {
|
||||||
|
overlay.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the manual key for a series
|
||||||
|
* @param {string} oldKey - The original key
|
||||||
|
* @param {string} newKey - The new key to set
|
||||||
|
*/
|
||||||
|
async function saveManualKey(oldKey, newKey) {
|
||||||
|
const errorSpan = document.getElementById('edit-key-error');
|
||||||
|
const saveBtn = document.getElementById('edit-key-save');
|
||||||
|
|
||||||
|
if (!errorSpan || !saveBtn) {
|
||||||
|
console.error('Edit key modal elements not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorSpan.textContent = '';
|
||||||
|
errorSpan.style.display = 'none';
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/anime/${encodeURIComponent(oldKey)}/manual-key`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ key: newKey }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
errorSpan.textContent = data.detail || 'Failed to update key';
|
||||||
|
errorSpan.style.display = 'block';
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success - hide modal and reload
|
||||||
|
hideEditKeyModal();
|
||||||
|
showToast('Key updated successfully', 'success');
|
||||||
|
|
||||||
|
// Reload series list
|
||||||
|
if (window.app && window.app.loadSeries) {
|
||||||
|
window.app.loadSeries();
|
||||||
|
} else if (typeof loadSeries === 'function') {
|
||||||
|
loadSeries();
|
||||||
|
} else {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving manual key:', error);
|
||||||
|
errorSpan.textContent = 'Network error: ' + error.message;
|
||||||
|
errorSpan.style.display = 'block';
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current metadata edit state
|
||||||
|
let _currentEditMetadataKey = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the edit metadata IDs modal
|
||||||
|
* @param {string} key - The series key
|
||||||
|
* @param {string} name - The series name
|
||||||
|
* @param {number|null} currentTmdbId - Current TMDB ID
|
||||||
|
* @param {number|null} currentTvdbId - Current TVDB ID
|
||||||
|
*/
|
||||||
|
function showEditMetadataModal(key, name, currentTmdbId, currentTvdbId) {
|
||||||
|
const modal = document.getElementById('edit-metadata-modal');
|
||||||
|
const overlay = modal ? modal.querySelector('.modal-overlay') : null;
|
||||||
|
const nameSpan = document.getElementById('edit-metadata-series-name');
|
||||||
|
const tmdbInput = document.getElementById('edit-metadata-tmdb');
|
||||||
|
const tvdbInput = document.getElementById('edit-metadata-tvdb');
|
||||||
|
const errorSpan = document.getElementById('edit-metadata-error');
|
||||||
|
const saveBtn = document.getElementById('save-edit-metadata');
|
||||||
|
const cancelBtn = document.getElementById('cancel-edit-metadata');
|
||||||
|
|
||||||
|
if (!modal || !nameSpan || !tmdbInput || !tvdbInput) {
|
||||||
|
console.error('Edit metadata modal elements not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store current key
|
||||||
|
_currentEditMetadataKey = key;
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
nameSpan.textContent = name;
|
||||||
|
tmdbInput.value = currentTmdbId || '';
|
||||||
|
tvdbInput.value = currentTvdbId || '';
|
||||||
|
if (errorSpan) {
|
||||||
|
errorSpan.textContent = '';
|
||||||
|
errorSpan.classList.add('hidden');
|
||||||
|
}
|
||||||
|
if (saveBtn) saveBtn.disabled = false;
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
if (overlay) overlay.classList.remove('hidden');
|
||||||
|
tmdbInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the edit metadata modal
|
||||||
|
*/
|
||||||
|
function hideEditMetadataModal() {
|
||||||
|
const modal = document.getElementById('edit-metadata-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
_currentEditMetadataKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save metadata IDs for a series
|
||||||
|
* @param {string} key - The series key
|
||||||
|
* @param {number|null} tmdbId - TMDB ID (null to clear)
|
||||||
|
* @param {number|null} tvdbId - TVDB ID (null to clear)
|
||||||
|
*/
|
||||||
|
async function saveMetadataIds(key, tmdbId, tvdbId) {
|
||||||
|
const errorSpan = document.getElementById('edit-metadata-error');
|
||||||
|
const saveBtn = document.getElementById('save-edit-metadata');
|
||||||
|
|
||||||
|
if (!errorSpan || !saveBtn) {
|
||||||
|
console.error('Edit metadata modal elements not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorSpan.textContent = '';
|
||||||
|
errorSpan.classList.add('hidden');
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = {};
|
||||||
|
if (tmdbId !== '') body.tmdb_id = parseInt(tmdbId, 10) || null;
|
||||||
|
if (tvdbId !== '') body.tvdb_id = parseInt(tvdbId, 10) || null;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/anime/${encodeURIComponent(key)}/metadata-ids`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
errorSpan.textContent = data.detail || 'Failed to update metadata IDs';
|
||||||
|
errorSpan.classList.remove('hidden');
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success - hide modal and show toast
|
||||||
|
hideEditMetadataModal();
|
||||||
|
showToast('Metadata IDs updated. NFO refresh queued.', 'success');
|
||||||
|
|
||||||
|
// Reload series list to reflect changes
|
||||||
|
if (window.app && window.app.loadSeries) {
|
||||||
|
window.app.loadSeries();
|
||||||
|
} else if (typeof loadSeries === 'function') {
|
||||||
|
loadSeries();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving metadata IDs:', error);
|
||||||
|
errorSpan.textContent = 'Network error: ' + error.message;
|
||||||
|
errorSpan.classList.remove('hidden');
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh NFO for a series
|
||||||
|
* @param {string} key - The series key
|
||||||
|
*/
|
||||||
|
async function refreshSeriesNfo(key) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/anime/${encodeURIComponent(key)}/refresh-nfo`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
showToast('Failed to refresh NFO: ' + (data.detail || 'Unknown error'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('NFO refresh queued', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing NFO:', error);
|
||||||
|
showToast('Network error: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind edit metadata modal events if modal exists
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const modal = document.getElementById('edit-metadata-modal');
|
||||||
|
const overlay = modal ? modal.querySelector('.modal-overlay') : null;
|
||||||
|
const cancelBtn = document.getElementById('cancel-edit-metadata');
|
||||||
|
const saveBtn = document.getElementById('save-edit-metadata');
|
||||||
|
const tmdbInput = document.getElementById('edit-metadata-tmdb');
|
||||||
|
const tvdbInput = document.getElementById('edit-metadata-tvdb');
|
||||||
|
|
||||||
|
if (cancelBtn) {
|
||||||
|
cancelBtn.addEventListener('click', hideEditMetadataModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlay) {
|
||||||
|
overlay.addEventListener('click', hideEditMetadataModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveBtn && tmdbInput && tvdbInput) {
|
||||||
|
saveBtn.addEventListener('click', () => {
|
||||||
|
if (_currentEditMetadataKey) {
|
||||||
|
saveMetadataIds(
|
||||||
|
_currentEditMetadataKey,
|
||||||
|
tmdbInput.value,
|
||||||
|
tvdbInput.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tmdbInput) {
|
||||||
|
tmdbInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
saveBtn.click();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
hideEditMetadataModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tvdbInput) {
|
||||||
|
tvdbInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
saveBtn.click();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
hideEditMetadataModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bind edit key modal events if modal exists
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const modal = document.getElementById('edit-key-modal');
|
||||||
|
const overlay = document.getElementById('edit-key-overlay');
|
||||||
|
const cancelBtn = document.getElementById('edit-key-cancel');
|
||||||
|
const saveBtn = document.getElementById('edit-key-save');
|
||||||
|
const keyInput = document.getElementById('edit-key-input');
|
||||||
|
|
||||||
|
if (cancelBtn) {
|
||||||
|
cancelBtn.addEventListener('click', hideEditKeyModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlay) {
|
||||||
|
overlay.addEventListener('click', hideEditKeyModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveBtn && keyInput) {
|
||||||
|
saveBtn.addEventListener('click', () => {
|
||||||
|
const originalKey = keyInput.dataset.originalKey;
|
||||||
|
const newKey = keyInput.value.trim();
|
||||||
|
if (newKey && newKey !== originalKey) {
|
||||||
|
saveManualKey(originalKey, newKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyInput) {
|
||||||
|
keyInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
saveBtn.click();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
hideEditKeyModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -40,6 +40,31 @@ AniWorld.SeriesManager = (function() {
|
|||||||
if (sortBtn) {
|
if (sortBtn) {
|
||||||
sortBtn.addEventListener('click', toggleAlphabeticalSort);
|
sortBtn.addEventListener('click', toggleAlphabeticalSort);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Event delegation for dynamically created edit-key buttons
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const editKeyBtn = e.target.closest('.edit-key-btn');
|
||||||
|
if (editKeyBtn) {
|
||||||
|
e.preventDefault();
|
||||||
|
const key = editKeyBtn.dataset.key;
|
||||||
|
const folder = editKeyBtn.dataset.folder;
|
||||||
|
if (window.showEditKeyModal) {
|
||||||
|
window.showEditKeyModal(key, folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editMetadataBtn = e.target.closest('.edit-metadata-btn');
|
||||||
|
if (editMetadataBtn) {
|
||||||
|
e.preventDefault();
|
||||||
|
const key = editMetadataBtn.dataset.key;
|
||||||
|
const name = editMetadataBtn.dataset.name;
|
||||||
|
const tmdbId = editMetadataBtn.dataset.tmdbId || null;
|
||||||
|
const tvdbId = editMetadataBtn.dataset.tvdbId || null;
|
||||||
|
if (window.showEditMetadataModal) {
|
||||||
|
window.showEditMetadataModal(key, name, tmdbId, tvdbId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -203,6 +228,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 +269,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;
|
||||||
@@ -328,7 +368,8 @@ AniWorld.SeriesManager = (function() {
|
|||||||
const canBeSelected = hasMissingEpisodes;
|
const canBeSelected = hasMissingEpisodes;
|
||||||
const hasNfo = serie.has_nfo || false;
|
const hasNfo = serie.has_nfo || false;
|
||||||
const isLoading = serie.loading_status && serie.loading_status !== 'completed' && serie.loading_status !== 'failed';
|
const isLoading = serie.loading_status && serie.loading_status !== 'completed' && serie.loading_status !== 'failed';
|
||||||
|
const hasKeyError = serie.loading_error && serie.loading_error.includes('key cannot be None or empty');
|
||||||
|
|
||||||
// Debug logging for troubleshooting
|
// Debug logging for troubleshooting
|
||||||
if (serie.key === 'so-im-a-spider-so-what') {
|
if (serie.key === 'so-im-a-spider-so-what') {
|
||||||
console.log('[createSerieCard] Spider series:', {
|
console.log('[createSerieCard] Spider series:', {
|
||||||
@@ -341,6 +382,12 @@ AniWorld.SeriesManager = (function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const editKeyBtn = hasKeyError
|
||||||
|
? '<button class="btn btn-icon edit-key-btn" title="Fix key error" data-key="' + serie.key + '" data-folder="' + serie.folder + '"><i class="fas fa-key"></i></button>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const editMetadataBtn = '<button class="btn btn-icon edit-metadata-btn" title="Edit Metadata IDs" data-key="' + serie.key + '" data-name="' + AniWorld.UI.escapeHtml(serie.name) + '" data-tmdb-id="' + (serie.tmdb_id || '') + '" data-tvdb-id="' + (serie.tvdb_id || '') + '"><i class="fas fa-database"></i></button>';
|
||||||
|
|
||||||
return '<div class="series-card ' + (isSelected ? 'selected' : '') + ' ' +
|
return '<div class="series-card ' + (isSelected ? 'selected' : '') + ' ' +
|
||||||
(hasMissingEpisodes ? 'has-missing' : 'complete') + ' ' +
|
(hasMissingEpisodes ? 'has-missing' : 'complete') + ' ' +
|
||||||
(isLoading ? 'loading' : '') + '" ' +
|
(isLoading ? 'loading' : '') + '" ' +
|
||||||
@@ -353,9 +400,12 @@ AniWorld.SeriesManager = (function() {
|
|||||||
'<div class="series-folder">' + AniWorld.UI.escapeHtml(serie.folder) + '</div>' +
|
'<div class="series-folder">' + AniWorld.UI.escapeHtml(serie.folder) + '</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="series-status">' +
|
'<div class="series-status">' +
|
||||||
|
(hasKeyError ? '<i class="fas fa-exclamation-triangle key-error-badge" title="Key error: ' + serie.loading_error + '"></i>' : '') +
|
||||||
(hasMissingEpisodes ? '' : '<i class="fas fa-check-circle status-complete" title="Complete"></i>') +
|
(hasMissingEpisodes ? '' : '<i class="fas fa-check-circle status-complete" title="Complete"></i>') +
|
||||||
(hasNfo ? '<i class="fas fa-file-alt nfo-badge nfo-exists" title="NFO metadata available"></i>' :
|
(hasNfo ? '<i class="fas fa-file-alt nfo-badge nfo-exists" title="NFO metadata available"></i>' :
|
||||||
'<i class="fas fa-file-alt nfo-badge nfo-missing" title="No NFO metadata"></i>') +
|
'<i class="fas fa-file-alt nfo-badge nfo-missing" title="No NFO metadata"></i>') +
|
||||||
|
editMetadataBtn +
|
||||||
|
editKeyBtn +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="series-stats">' +
|
'<div class="series-stats">' +
|
||||||
|
|||||||
@@ -640,6 +640,88 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Key Modal -->
|
||||||
|
<div id="edit-key-modal" class="modal hidden">
|
||||||
|
<div class="modal-overlay"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 data-text="edit-key-title">Edit Series Key</h3>
|
||||||
|
<button id="close-edit-key" class="btn btn-icon">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="config-item">
|
||||||
|
<label data-text="current-folder">Folder Name:</label>
|
||||||
|
<span id="edit-key-folder" class="config-value"></span>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<label for="edit-key-input" data-text="new-key">New Key:</label>
|
||||||
|
<input type="text" id="edit-key-input" class="input-field"
|
||||||
|
placeholder="e.g., attack-on-titan" minlength="2">
|
||||||
|
<small class="config-hint" data-text="key-format-hint">
|
||||||
|
URL-safe key (alphanumeric, hyphens, underscores)
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div id="edit-key-error" class="config-error hidden"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="save-edit-key" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save"></i>
|
||||||
|
<span data-text="save">Save</span>
|
||||||
|
</button>
|
||||||
|
<button id="cancel-edit-key" class="btn btn-secondary">
|
||||||
|
<span data-text="cancel">Cancel</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Metadata IDs Modal -->
|
||||||
|
<div id="edit-metadata-modal" class="modal hidden">
|
||||||
|
<div class="modal-overlay"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 data-text="edit-metadata-title">Edit Metadata IDs</h3>
|
||||||
|
<button id="close-edit-metadata" class="btn btn-icon">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="config-item">
|
||||||
|
<label data-text="series-name">Series:</label>
|
||||||
|
<span id="edit-metadata-series-name" class="config-value"></span>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<label for="edit-metadata-tmdb" data-text="tmdb-id">TMDB ID:</label>
|
||||||
|
<input type="number" id="edit-metadata-tmdb" class="input-field"
|
||||||
|
placeholder="e.g., 12345" min="1" step="1">
|
||||||
|
<small class="config-hint" data-text="tmdb-id-hint">
|
||||||
|
Leave blank to clear. Find IDs at themoviedb.org
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<label for="edit-metadata-tvdb" data-text="tvdb-id">TVDB ID:</label>
|
||||||
|
<input type="number" id="edit-metadata-tvdb" class="input-field"
|
||||||
|
placeholder="e.g., 67890" min="1" step="1">
|
||||||
|
<small class="config-hint" data-text="tvdb-id-hint">
|
||||||
|
Leave blank to clear. Find IDs at thetvdb.com
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div id="edit-metadata-error" class="config-error hidden"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="save-edit-metadata" class="btn btn-primary">
|
||||||
|
<i class="fas fa-save"></i>
|
||||||
|
<span data-text="save-refresh">Save & Refresh NFO</span>
|
||||||
|
</button>
|
||||||
|
<button id="cancel-edit-metadata" class="btn btn-secondary">
|
||||||
|
<span data-text="cancel">Cancel</span>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
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}"
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
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
|
||||||
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")
|
||||||
|
|
||||||
|
|||||||
@@ -392,23 +392,33 @@ class TestAddSeriesWithEpisodes:
|
|||||||
nfo_created_at=datetime(2024, 1, 1, 12, 0, 0),
|
nfo_created_at=datetime(2024, 1, 1, 12, 0, 0),
|
||||||
nfo_updated_at=datetime(2024, 1, 2, 12, 0, 0)
|
nfo_updated_at=datetime(2024, 1, 2, 12, 0, 0)
|
||||||
)
|
)
|
||||||
|
mock_db_series.id = 1
|
||||||
|
|
||||||
# Create service with mocked WebSocket
|
# Create service with mocked WebSocket
|
||||||
anime_service = AnimeService(mock_series_app)
|
anime_service = AnimeService(mock_series_app)
|
||||||
mock_websocket = AsyncMock()
|
mock_websocket = AsyncMock()
|
||||||
anime_service._websocket_service = mock_websocket
|
anime_service._websocket_service = mock_websocket
|
||||||
|
|
||||||
# Mock database session and service
|
# Mock database session and service
|
||||||
mock_db_session = AsyncMock()
|
mock_db_session = AsyncMock()
|
||||||
mock_db_session.__aenter__ = AsyncMock(return_value=mock_db_session)
|
mock_db_session.__aenter__ = AsyncMock(return_value=mock_db_session)
|
||||||
mock_db_session.__aexit__ = AsyncMock()
|
mock_db_session.__aexit__ = AsyncMock()
|
||||||
|
|
||||||
|
# Mock episodes that match the in-memory episodeDict
|
||||||
|
mock_episodes = [
|
||||||
|
MagicMock(season=1, episode_number=1),
|
||||||
|
MagicMock(season=1, episode_number=2),
|
||||||
|
MagicMock(season=1, episode_number=3),
|
||||||
|
]
|
||||||
|
|
||||||
with patch('src.server.database.connection.get_db_session', return_value=mock_db_session):
|
with patch('src.server.database.connection.get_db_session', return_value=mock_db_session):
|
||||||
with patch('src.server.database.service.AnimeSeriesService') as MockAnimeSeriesService:
|
with patch('src.server.database.service.AnimeSeriesService') as MockAnimeSeriesService:
|
||||||
MockAnimeSeriesService.get_by_key = AsyncMock(return_value=mock_db_series)
|
MockAnimeSeriesService.get_by_key = AsyncMock(return_value=mock_db_series)
|
||||||
|
with patch('src.server.database.service.EpisodeService') as MockEpisodeService:
|
||||||
# Act
|
MockEpisodeService.get_by_series = AsyncMock(return_value=mock_episodes)
|
||||||
await anime_service._broadcast_series_updated(key)
|
|
||||||
|
# Act
|
||||||
|
await anime_service._broadcast_series_updated(key)
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
mock_websocket.broadcast.assert_called_once()
|
mock_websocket.broadcast.assert_called_once()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import pytest
|
|||||||
from src.server.services.anime_service import (
|
from src.server.services.anime_service import (
|
||||||
AnimeService,
|
AnimeService,
|
||||||
AnimeServiceError,
|
AnimeServiceError,
|
||||||
sync_series_from_data_files,
|
sync_legacy_series_to_db,
|
||||||
)
|
)
|
||||||
from src.server.services.progress_service import ProgressService
|
from src.server.services.progress_service import ProgressService
|
||||||
|
|
||||||
@@ -1303,7 +1303,7 @@ class TestGetNFOStatisticsSelfManaged:
|
|||||||
|
|
||||||
|
|
||||||
class TestSyncSeriesFromDataFiles:
|
class TestSyncSeriesFromDataFiles:
|
||||||
"""Test module-level sync_series_from_data_files function."""
|
"""Test module-level sync_legacy_series_to_db function."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sync_adds_new_series(self, tmp_path):
|
async def test_sync_adds_new_series(self, tmp_path):
|
||||||
@@ -1343,7 +1343,7 @@ class TestSyncSeriesFromDataFiles:
|
|||||||
]
|
]
|
||||||
MockApp.return_value = mock_app_instance
|
MockApp.return_value = mock_app_instance
|
||||||
|
|
||||||
count = await sync_series_from_data_files(str(tmp_path))
|
count = await sync_legacy_series_to_db(str(tmp_path))
|
||||||
|
|
||||||
assert count == 1
|
assert count == 1
|
||||||
mock_create.assert_called_once()
|
mock_create.assert_called_once()
|
||||||
@@ -1382,7 +1382,7 @@ class TestSyncSeriesFromDataFiles:
|
|||||||
]
|
]
|
||||||
MockApp.return_value = mock_app_instance
|
MockApp.return_value = mock_app_instance
|
||||||
|
|
||||||
count = await sync_series_from_data_files(str(tmp_path))
|
count = await sync_legacy_series_to_db(str(tmp_path))
|
||||||
|
|
||||||
assert count == 0
|
assert count == 0
|
||||||
mock_create.assert_not_called()
|
mock_create.assert_not_called()
|
||||||
@@ -1397,7 +1397,7 @@ class TestSyncSeriesFromDataFiles:
|
|||||||
mock_app_instance.get_all_series_from_data_files.return_value = []
|
mock_app_instance.get_all_series_from_data_files.return_value = []
|
||||||
MockApp.return_value = mock_app_instance
|
MockApp.return_value = mock_app_instance
|
||||||
|
|
||||||
count = await sync_series_from_data_files(str(tmp_path))
|
count = await sync_legacy_series_to_db(str(tmp_path))
|
||||||
|
|
||||||
assert count == 0
|
assert count == 0
|
||||||
|
|
||||||
@@ -1436,7 +1436,7 @@ class TestSyncSeriesFromDataFiles:
|
|||||||
]
|
]
|
||||||
MockApp.return_value = mock_app_instance
|
MockApp.return_value = mock_app_instance
|
||||||
|
|
||||||
count = await sync_series_from_data_files(str(tmp_path))
|
count = await sync_legacy_series_to_db(str(tmp_path))
|
||||||
|
|
||||||
assert count == 1
|
assert count == 1
|
||||||
# The name should have been set to folder
|
# The name should have been set to folder
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"""Unit tests for aniworld_provider.py - Anime catalog scraping, episode listing, streaming link extraction."""
|
"""Unit tests for aniworld_provider.py - Anime catalog scraping, episode listing, streaming link extraction."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from unittest.mock import MagicMock, Mock, patch
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
from src.core.providers.aniworld_provider import AniworldLoader
|
from src.core.providers.aniworld_provider import AniworldLoader
|
||||||
|
|
||||||
@@ -472,3 +474,284 @@ class TestAniworldEvents:
|
|||||||
# Fire event - handler should NOT be called
|
# Fire event - handler should NOT be called
|
||||||
loader.events.download_progress({"status": "downloading"})
|
loader.events.download_progress({"status": "downloading"})
|
||||||
handler.assert_not_called()
|
handler.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAniworldHealthCheck:
|
||||||
|
"""Tests for the _check_url_alive HEAD probe."""
|
||||||
|
|
||||||
|
def test_returns_true_on_200(self, loader):
|
||||||
|
loader.session.head.return_value = MagicMock(status_code=200)
|
||||||
|
assert loader._check_url_alive("https://provider/x") is True
|
||||||
|
|
||||||
|
def test_returns_false_on_404(self, loader):
|
||||||
|
loader.session.head.return_value = MagicMock(status_code=404)
|
||||||
|
assert loader._check_url_alive("https://provider/x") is False
|
||||||
|
|
||||||
|
def test_returns_false_on_403(self, loader):
|
||||||
|
loader.session.head.return_value = MagicMock(status_code=403)
|
||||||
|
assert loader._check_url_alive("https://provider/x") is False
|
||||||
|
|
||||||
|
def test_falls_back_to_get_when_head_disallowed(self, loader):
|
||||||
|
loader.session.head.return_value = MagicMock(status_code=405)
|
||||||
|
get_resp = MagicMock(status_code=200)
|
||||||
|
get_resp.close = MagicMock()
|
||||||
|
loader.session.get.return_value = get_resp
|
||||||
|
assert loader._check_url_alive("https://provider/x") is True
|
||||||
|
loader.session.get.assert_called_once()
|
||||||
|
|
||||||
|
def test_returns_false_on_connection_error(self, loader):
|
||||||
|
loader.session.head.side_effect = requests.ConnectionError("boom")
|
||||||
|
assert loader._check_url_alive("https://provider/x") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestAniworldDirectStream:
|
||||||
|
"""Tests for the _try_direct_stream fast-path."""
|
||||||
|
|
||||||
|
def _build_response(self, status, content_type, body=b""):
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.ok = status < 400
|
||||||
|
resp.status_code = status
|
||||||
|
resp.headers = {"Content-Type": content_type}
|
||||||
|
resp.iter_content = MagicMock(return_value=[body])
|
||||||
|
resp.__enter__ = MagicMock(return_value=resp)
|
||||||
|
resp.__exit__ = MagicMock(return_value=False)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def test_skips_non_video_content(self, loader, tmp_path):
|
||||||
|
target = tmp_path / "out.mp4"
|
||||||
|
loader.session.get.return_value = self._build_response(
|
||||||
|
200, "text/html"
|
||||||
|
)
|
||||||
|
assert loader._try_direct_stream(
|
||||||
|
"https://x", str(target), None, 10
|
||||||
|
) is False
|
||||||
|
assert not target.exists()
|
||||||
|
|
||||||
|
def test_writes_video_content(self, loader, tmp_path):
|
||||||
|
target = tmp_path / "out.mp4"
|
||||||
|
loader.session.get.return_value = self._build_response(
|
||||||
|
200, "video/mp4", body=b"abc123"
|
||||||
|
)
|
||||||
|
assert loader._try_direct_stream(
|
||||||
|
"https://x", str(target), None, 10
|
||||||
|
) is True
|
||||||
|
assert target.read_bytes() == b"abc123"
|
||||||
|
|
||||||
|
def test_returns_false_on_http_error(self, loader, tmp_path):
|
||||||
|
target = tmp_path / "out.mp4"
|
||||||
|
loader.session.get.return_value = self._build_response(
|
||||||
|
404, "video/mp4"
|
||||||
|
)
|
||||||
|
assert loader._try_direct_stream(
|
||||||
|
"https://x", str(target), None, 10
|
||||||
|
) is False
|
||||||
|
|
||||||
|
def test_returns_false_on_request_exception(self, loader, tmp_path):
|
||||||
|
loader.session.get.side_effect = requests.RequestException("nope")
|
||||||
|
assert loader._try_direct_stream(
|
||||||
|
"https://x", str(tmp_path / "out.mp4"), None, 10
|
||||||
|
) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestAniworldProviderSelection:
|
||||||
|
"""Tests for _select_providers_for_episode ordering and filtering."""
|
||||||
|
|
||||||
|
def test_orders_by_supported_preference(self, loader):
|
||||||
|
loader.is_language = MagicMock(return_value=True)
|
||||||
|
loader._get_provider_from_html = MagicMock(return_value={
|
||||||
|
"Vidoza": {1: "https://aniworld.to/redirect/2"},
|
||||||
|
"VOE": {1: "https://aniworld.to/redirect/1"},
|
||||||
|
})
|
||||||
|
result = loader._select_providers_for_episode(1, 1, "k", "German Dub")
|
||||||
|
assert [name for name, _ in result] == ["VOE", "Vidoza"]
|
||||||
|
|
||||||
|
def test_filters_by_language(self, loader):
|
||||||
|
loader.is_language = MagicMock(return_value=True)
|
||||||
|
loader._get_provider_from_html = MagicMock(return_value={
|
||||||
|
"VOE": {2: "https://aniworld.to/redirect/1"}, # English only
|
||||||
|
})
|
||||||
|
result = loader._select_providers_for_episode(1, 1, "k", "German Dub")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_returns_empty_when_language_unavailable(self, loader):
|
||||||
|
loader.is_language = MagicMock(return_value=False)
|
||||||
|
loader._get_provider_from_html = MagicMock()
|
||||||
|
result = loader._select_providers_for_episode(1, 1, "k", "German Dub")
|
||||||
|
assert result == []
|
||||||
|
loader._get_provider_from_html.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAniworldDownloadFailover:
|
||||||
|
"""Tests for the failover rotation in download()."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def patched_loader(self, loader, tmp_path):
|
||||||
|
"""Loader with side-effect heavy methods stubbed."""
|
||||||
|
loader.get_title = MagicMock(return_value="Anime")
|
||||||
|
loader._select_providers_for_episode = MagicMock(return_value=[
|
||||||
|
("VOE", "https://aniworld.to/redirect/1"),
|
||||||
|
("Doodstream", "https://aniworld.to/redirect/2"),
|
||||||
|
])
|
||||||
|
loader._check_url_alive = MagicMock(return_value=True)
|
||||||
|
loader._try_direct_stream = MagicMock(return_value=False)
|
||||||
|
loader.clear_cache = MagicMock()
|
||||||
|
loader._resolve_direct_link = MagicMock(
|
||||||
|
return_value=("https://cdn/video.m3u8", {"Referer": "https://x"})
|
||||||
|
)
|
||||||
|
return loader
|
||||||
|
|
||||||
|
def test_skips_provider_when_url_dead(self, patched_loader, tmp_path):
|
||||||
|
# First provider URL fails health check, second succeeds and downloads
|
||||||
|
patched_loader._check_url_alive.side_effect = [False, True]
|
||||||
|
|
||||||
|
def fake_ytdl(opts):
|
||||||
|
outpath = opts["outtmpl"]
|
||||||
|
os.makedirs(os.path.dirname(outpath), exist_ok=True)
|
||||||
|
with open(outpath, "wb") as fh:
|
||||||
|
fh.write(b"data")
|
||||||
|
ydl = MagicMock()
|
||||||
|
ydl.__enter__ = MagicMock(return_value=ydl)
|
||||||
|
ydl.__exit__ = MagicMock(return_value=False)
|
||||||
|
ydl.extract_info = MagicMock(return_value={"title": "t"})
|
||||||
|
return ydl
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.core.providers.aniworld_provider.YoutubeDL",
|
||||||
|
side_effect=fake_ytdl,
|
||||||
|
):
|
||||||
|
result = patched_loader.download(
|
||||||
|
str(tmp_path), "Anime", 1, 1, "k", "German Dub"
|
||||||
|
)
|
||||||
|
assert result is True
|
||||||
|
assert patched_loader._check_url_alive.call_count == 2
|
||||||
|
# Only second provider (Doodstream) attempted resolve
|
||||||
|
patched_loader._resolve_direct_link.assert_called_once_with(
|
||||||
|
"https://aniworld.to/redirect/2", "Doodstream"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_falls_back_to_next_provider_on_ytdl_error(
|
||||||
|
self, patched_loader, tmp_path
|
||||||
|
):
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def fake_ytdl(opts):
|
||||||
|
calls["n"] += 1
|
||||||
|
if calls["n"] == 1:
|
||||||
|
raise Exception("HTTP 404 from VOE")
|
||||||
|
outpath = opts["outtmpl"]
|
||||||
|
os.makedirs(os.path.dirname(outpath), exist_ok=True)
|
||||||
|
with open(outpath, "wb") as fh:
|
||||||
|
fh.write(b"ok")
|
||||||
|
ydl = MagicMock()
|
||||||
|
ydl.__enter__ = MagicMock(return_value=ydl)
|
||||||
|
ydl.__exit__ = MagicMock(return_value=False)
|
||||||
|
ydl.extract_info = MagicMock(return_value={"title": "t"})
|
||||||
|
return ydl
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.core.providers.aniworld_provider.YoutubeDL",
|
||||||
|
side_effect=fake_ytdl,
|
||||||
|
):
|
||||||
|
result = patched_loader.download(
|
||||||
|
str(tmp_path), "Anime", 1, 1, "k", "German Dub"
|
||||||
|
)
|
||||||
|
assert result is True
|
||||||
|
assert calls["n"] == 2
|
||||||
|
|
||||||
|
def test_uses_direct_stream_when_available(
|
||||||
|
self, patched_loader, tmp_path
|
||||||
|
):
|
||||||
|
def write_direct(link, output, headers, timeout):
|
||||||
|
os.makedirs(os.path.dirname(output), exist_ok=True)
|
||||||
|
with open(output, "wb") as fh:
|
||||||
|
fh.write(b"vid")
|
||||||
|
return True
|
||||||
|
|
||||||
|
patched_loader._try_direct_stream.side_effect = write_direct
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.core.providers.aniworld_provider.YoutubeDL"
|
||||||
|
) as mock_ydl:
|
||||||
|
result = patched_loader.download(
|
||||||
|
str(tmp_path), "Anime", 1, 1, "k", "German Dub"
|
||||||
|
)
|
||||||
|
assert result is True
|
||||||
|
mock_ydl.assert_not_called()
|
||||||
|
|
||||||
|
def test_returns_false_when_all_providers_fail(
|
||||||
|
self, patched_loader, tmp_path, caplog
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.core.providers.aniworld_provider.YoutubeDL",
|
||||||
|
side_effect=Exception("HTTP 404"),
|
||||||
|
):
|
||||||
|
result = patched_loader.download(
|
||||||
|
str(tmp_path), "Anime", 1, 1, "k", "German Dub"
|
||||||
|
)
|
||||||
|
assert result is False
|
||||||
|
assert "All download providers failed" in caplog.text
|
||||||
|
# Both providers attempted
|
||||||
|
assert patched_loader._resolve_direct_link.call_count == 2
|
||||||
|
|
||||||
|
def test_returns_false_when_no_providers_advertised(
|
||||||
|
self, patched_loader, tmp_path, caplog
|
||||||
|
):
|
||||||
|
patched_loader._select_providers_for_episode.return_value = []
|
||||||
|
result = patched_loader.download(
|
||||||
|
str(tmp_path), "Anime", 1, 1, "k", "German Dub"
|
||||||
|
)
|
||||||
|
assert result is False
|
||||||
|
assert "No providers advertised" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestAniworldHeaderParsing:
|
||||||
|
"""_parse_provider_headers normalizes legacy strings to dict."""
|
||||||
|
|
||||||
|
def test_parses_referer(self):
|
||||||
|
result = AniworldLoader._parse_provider_headers(
|
||||||
|
['Referer: "https://vidmoly.to"']
|
||||||
|
)
|
||||||
|
assert result == {"Referer": "https://vidmoly.to"}
|
||||||
|
|
||||||
|
def test_handles_none(self):
|
||||||
|
assert AniworldLoader._parse_provider_headers(None) == {}
|
||||||
|
|
||||||
|
def test_skips_malformed_entries(self):
|
||||||
|
result = AniworldLoader._parse_provider_headers(
|
||||||
|
["not-a-header", "Key: value"]
|
||||||
|
)
|
||||||
|
assert result == {"Key": "value"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestDecodeHtmlContent:
|
||||||
|
"""Test _decode_html_content function."""
|
||||||
|
|
||||||
|
def test_decodes_utf8_content(self):
|
||||||
|
"""Should correctly decode UTF-8 content."""
|
||||||
|
from src.core.providers.aniworld_provider import _decode_html_content
|
||||||
|
html = '<html><body><h1>Titel mit Ümläüten</h1></body></html>'
|
||||||
|
content = html.encode('utf-8')
|
||||||
|
result = _decode_html_content(content)
|
||||||
|
assert 'Titel mit Ümläüten' in result
|
||||||
|
|
||||||
|
def test_decodes_latin1_content(self):
|
||||||
|
"""Should correctly decode Latin-1 content when chardet detects it."""
|
||||||
|
from src.core.providers.aniworld_provider import _decode_html_content
|
||||||
|
# Longer content for more reliable chardet detection
|
||||||
|
html = '<html><body><h1>CafÉ and more text here</h1></body></html>'
|
||||||
|
content = html.encode('latin-1')
|
||||||
|
result = _decode_html_content(content)
|
||||||
|
assert 'Caf' in result # Decoded content contains expected substring
|
||||||
|
|
||||||
|
def test_replaces_invalid_bytes(self):
|
||||||
|
"""Should replace invalid bytes with replacement character."""
|
||||||
|
from src.core.providers.aniworld_provider import _decode_html_content
|
||||||
|
content = b'\xff\xfe Invalid \x80\x81'
|
||||||
|
result = _decode_html_content(content)
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
def test_handles_empty_content(self):
|
||||||
|
"""Should handle empty content gracefully."""
|
||||||
|
from src.core.providers.aniworld_provider import _decode_html_content
|
||||||
|
result = _decode_html_content(b'')
|
||||||
|
assert result == ''
|
||||||
|
|||||||
@@ -95,6 +95,37 @@ class TestConfigServiceLoadSave:
|
|||||||
assert loaded_config.logging.level == sample_config.logging.level
|
assert loaded_config.logging.level == sample_config.logging.level
|
||||||
assert loaded_config.other == sample_config.other
|
assert loaded_config.other == sample_config.other
|
||||||
|
|
||||||
|
def test_save_and_load_scheduler_flags_roundtrip(self, config_service):
|
||||||
|
"""Scheduler auto_download_after_rescan and folder_scan_enabled must
|
||||||
|
survive a full save/load roundtrip through ConfigService.
|
||||||
|
|
||||||
|
Regression test for a bug where null legacy alias fields
|
||||||
|
(auto_download=None, folder_scan=None) were written to config.json
|
||||||
|
on save. On reload the alias mapping was skipped (because the keys
|
||||||
|
were present), causing the primary boolean fields to reset to False.
|
||||||
|
"""
|
||||||
|
original = AppConfig(
|
||||||
|
scheduler=SchedulerConfig(
|
||||||
|
enabled=True,
|
||||||
|
auto_download_after_rescan=True,
|
||||||
|
folder_scan_enabled=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
config_service.save_config(original, create_backup=False)
|
||||||
|
|
||||||
|
# Verify raw JSON does not contain legacy alias keys
|
||||||
|
with open(config_service.config_path, "r", encoding="utf-8") as f:
|
||||||
|
raw = json.load(f)
|
||||||
|
assert "auto_download" not in raw["scheduler"]
|
||||||
|
assert "folder_scan" not in raw["scheduler"]
|
||||||
|
assert raw["scheduler"]["auto_download_after_rescan"] is True
|
||||||
|
assert raw["scheduler"]["folder_scan_enabled"] is True
|
||||||
|
|
||||||
|
# Verify loaded config preserves values
|
||||||
|
loaded = config_service.load_config()
|
||||||
|
assert loaded.scheduler.auto_download_after_rescan is True
|
||||||
|
assert loaded.scheduler.folder_scan_enabled is True
|
||||||
|
|
||||||
def test_save_includes_version(self, config_service, sample_config):
|
def test_save_includes_version(self, config_service, sample_config):
|
||||||
"""Test that saved config includes version field."""
|
"""Test that saved config includes version field."""
|
||||||
config_service.save_config(sample_config, create_backup=False)
|
config_service.save_config(sample_config, create_backup=False)
|
||||||
|
|||||||
388
tests/unit/test_database_schema.py
Normal file
388
tests/unit/test_database_schema.py
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
"""Unit tests for database schema verification.
|
||||||
|
|
||||||
|
Tests that the database schema supports all fields that were previously
|
||||||
|
stored in file-based storage (key/data files).
|
||||||
|
|
||||||
|
Ref: Task 1 - Verify Database Schema Supports All File-Based Data
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from src.server.database.base import Base
|
||||||
|
from src.server.database.models import AnimeSeries, Episode
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_engine():
|
||||||
|
"""Create in-memory SQLite database engine for testing."""
|
||||||
|
engine = create_engine("sqlite:///:memory:", echo=False)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_session(db_engine):
|
||||||
|
"""Create database session for testing."""
|
||||||
|
SessionLocal = sessionmaker(bind=db_engine)
|
||||||
|
session = SessionLocal()
|
||||||
|
yield session
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnimeSeriesHasAllRequiredFields:
|
||||||
|
"""Verify AnimeSeries model has all Serie properties."""
|
||||||
|
|
||||||
|
def test_anime_series_has_id_column(self, db_session: Session):
|
||||||
|
"""Test that AnimeSeries has an id primary key column."""
|
||||||
|
series = AnimeSeries(
|
||||||
|
key="test-key",
|
||||||
|
name="Test Series",
|
||||||
|
site="https://example.com",
|
||||||
|
folder="/anime/test",
|
||||||
|
)
|
||||||
|
db_session.add(series)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
result = db_session.execute(select(AnimeSeries.id).where(AnimeSeries.key == "test-key"))
|
||||||
|
assert result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
def test_anime_series_has_key_column(self, db_session: Session):
|
||||||
|
"""Test that AnimeSeries has a key column for provider identifier."""
|
||||||
|
series = AnimeSeries(
|
||||||
|
key="unique-provider-key",
|
||||||
|
name="Test Series",
|
||||||
|
site="https://example.com",
|
||||||
|
folder="/anime/test",
|
||||||
|
)
|
||||||
|
db_session.add(series)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
result = db_session.execute(select(AnimeSeries.key).where(AnimeSeries.key == "unique-provider-key"))
|
||||||
|
assert result.scalar_one_or_none() == "unique-provider-key"
|
||||||
|
|
||||||
|
def test_anime_series_has_name_column(self, db_session: Session):
|
||||||
|
"""Test that AnimeSeries has a name column."""
|
||||||
|
series = AnimeSeries(
|
||||||
|
key="name-test",
|
||||||
|
name="My Custom Name",
|
||||||
|
site="https://example.com",
|
||||||
|
folder="/anime/test",
|
||||||
|
)
|
||||||
|
db_session.add(series)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
result = db_session.execute(select(AnimeSeries.name).where(AnimeSeries.key == "name-test"))
|
||||||
|
assert result.scalar_one_or_none() == "My Custom Name"
|
||||||
|
|
||||||
|
def test_anime_series_has_site_column(self, db_session: Session):
|
||||||
|
"""Test that AnimeSeries has a site column."""
|
||||||
|
series = AnimeSeries(
|
||||||
|
key="site-test",
|
||||||
|
name="Test Series",
|
||||||
|
site="https://aniworld.to/watch/series",
|
||||||
|
folder="/anime/test",
|
||||||
|
)
|
||||||
|
db_session.add(series)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
result = db_session.execute(select(AnimeSeries.site).where(AnimeSeries.key == "site-test"))
|
||||||
|
assert result.scalar_one_or_none() == "https://aniworld.to/watch/series"
|
||||||
|
|
||||||
|
def test_anime_series_has_folder_column(self, db_session: Session):
|
||||||
|
"""Test that AnimeSeries has a folder column."""
|
||||||
|
series = AnimeSeries(
|
||||||
|
key="folder-test",
|
||||||
|
name="Test Series",
|
||||||
|
site="https://example.com",
|
||||||
|
folder="/anime/My Series Folder (2024)",
|
||||||
|
)
|
||||||
|
db_session.add(series)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
result = db_session.execute(select(AnimeSeries.folder).where(AnimeSeries.key == "folder-test"))
|
||||||
|
assert result.scalar_one_or_none() == "/anime/My Series Folder (2024)"
|
||||||
|
|
||||||
|
def test_anime_series_has_year_column(self, db_session: Session):
|
||||||
|
"""Test that AnimeSeries has an optional year column."""
|
||||||
|
series = AnimeSeries(
|
||||||
|
key="year-test",
|
||||||
|
name="Test Series",
|
||||||
|
site="https://example.com",
|
||||||
|
folder="/anime/test",
|
||||||
|
year=2024,
|
||||||
|
)
|
||||||
|
db_session.add(series)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
result = db_session.execute(select(AnimeSeries.year).where(AnimeSeries.key == "year-test"))
|
||||||
|
assert result.scalar_one_or_none() == 2024
|
||||||
|
|
||||||
|
def test_anime_series_year_is_nullable(self, db_session: Session):
|
||||||
|
"""Test that year column is optional (nullable)."""
|
||||||
|
series = AnimeSeries(
|
||||||
|
key="no-year-test",
|
||||||
|
name="Test Series",
|
||||||
|
site="https://example.com",
|
||||||
|
folder="/anime/test",
|
||||||
|
)
|
||||||
|
db_session.add(series)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
result = db_session.execute(select(AnimeSeries.year).where(AnimeSeries.key == "no-year-test"))
|
||||||
|
assert result.scalar_one_or_none() is None
|
||||||
|
|
||||||
|
def test_anime_series_has_nfo_path_column(self, db_session: Session):
|
||||||
|
"""Test that AnimeSeries has an optional nfo_path column."""
|
||||||
|
series = AnimeSeries(
|
||||||
|
key="nfo-path-test",
|
||||||
|
name="Test Series",
|
||||||
|
site="https://example.com",
|
||||||
|
folder="/anime/test",
|
||||||
|
nfo_path="/anime/test/tvshow.nfo",
|
||||||
|
)
|
||||||
|
db_session.add(series)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
result = db_session.execute(select(AnimeSeries.nfo_path).where(AnimeSeries.key == "nfo-path-test"))
|
||||||
|
assert result.scalar_one_or_none() == "/anime/test/tvshow.nfo"
|
||||||
|
|
||||||
|
def test_anime_series_nfo_path_is_nullable(self, db_session: Session):
|
||||||
|
"""Test that nfo_path column is optional (nullable)."""
|
||||||
|
series = AnimeSeries(
|
||||||
|
key="no-nfo-path-test",
|
||||||
|
name="Test Series",
|
||||||
|
site="https://example.com",
|
||||||
|
folder="/anime/test",
|
||||||
|
)
|
||||||
|
db_session.add(series)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
result = db_session.execute(select(AnimeSeries.nfo_path).where(AnimeSeries.key == "no-nfo-path-test"))
|
||||||
|
assert result.scalar_one_or_none() is None
|
||||||
|
|
||||||
|
def test_anime_series_has_timestamps(self, db_session: Session):
|
||||||
|
"""Test that AnimeSeries has created_at and updated_at timestamps."""
|
||||||
|
series = AnimeSeries(
|
||||||
|
key="timestamps-test",
|
||||||
|
name="Test Series",
|
||||||
|
site="https://example.com",
|
||||||
|
folder="/anime/test",
|
||||||
|
)
|
||||||
|
db_session.add(series)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert series.created_at is not None
|
||||||
|
assert series.updated_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestEpisodeModelTracksMissingEpisodes:
|
||||||
|
"""Verify Episode model can store missing episodes."""
|
||||||
|
|
||||||
|
def test_episode_has_season_column(self, db_session: Session):
|
||||||
|
"""Test that Episode has a season column."""
|
||||||
|
series = AnimeSeries(
|
||||||
|
key="episode-season-test",
|
||||||
|
name="Test Series",
|
||||||
|
site="https://example.com",
|
||||||
|
folder="/anime/test",
|
||||||
|
)
|
||||||
|
db_session.add(series)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
episode = Episode(
|
||||||
|
series_id=series.id,
|
||||||
|
season=2,
|
||||||
|
episode_number=5,
|
||||||
|
)
|
||||||
|
db_session.add(episode)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
result = db_session.execute(select(Episode.season).where(Episode.id == episode.id))
|
||||||
|
assert result.scalar_one_or_none() == 2
|
||||||
|
|
||||||
|
def test_episode_has_episode_number_column(self, db_session: Session):
|
||||||
|
"""Test that Episode has an episode_number column."""
|
||||||
|
series = AnimeSeries(
|
||||||
|
key="episode-num-test",
|
||||||
|
name="Test Series",
|
||||||
|
site="https://example.com",
|
||||||
|
folder="/anime/test",
|
||||||
|
)
|
||||||
|
db_session.add(series)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
episode = Episode(
|
||||||
|
series_id=series.id,
|
||||||
|
season=1,
|
||||||
|
episode_number=12,
|
||||||
|
)
|
||||||
|
db_session.add(episode)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
result = db_session.execute(select(Episode.episode_number).where(Episode.id == episode.id))
|
||||||
|
assert result.scalar_one_or_none() == 12
|
||||||
|
|
||||||
|
def test_episode_has_is_downloaded_column(self, db_session: Session):
|
||||||
|
"""Test that Episode has an is_downloaded column."""
|
||||||
|
series = AnimeSeries(
|
||||||
|
key="downloaded-test",
|
||||||
|
name="Test Series",
|
||||||
|
site="https://example.com",
|
||||||
|
folder="/anime/test",
|
||||||
|
)
|
||||||
|
db_session.add(series)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
episode = Episode(
|
||||||
|
series_id=series.id,
|
||||||
|
season=1,
|
||||||
|
episode_number=1,
|
||||||
|
is_downloaded=True,
|
||||||
|
)
|
||||||
|
db_session.add(episode)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
result = db_session.execute(select(Episode.is_downloaded).where(Episode.id == episode.id))
|
||||||
|
assert result.scalar_one_or_none() is True
|
||||||
|
|
||||||
|
def test_episode_is_downloaded_defaults_false(self, db_session: Session):
|
||||||
|
"""Test that is_downloaded defaults to False."""
|
||||||
|
series = AnimeSeries(
|
||||||
|
key="default-downloaded-test",
|
||||||
|
name="Test Series",
|
||||||
|
site="https://example.com",
|
||||||
|
folder="/anime/test",
|
||||||
|
)
|
||||||
|
db_session.add(series)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
episode = Episode(
|
||||||
|
series_id=series.id,
|
||||||
|
season=1,
|
||||||
|
episode_number=1,
|
||||||
|
)
|
||||||
|
db_session.add(episode)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
result = db_session.execute(select(Episode.is_downloaded).where(Episode.id == episode.id))
|
||||||
|
assert result.scalar_one_or_none() is False
|
||||||
|
|
||||||
|
def test_episode_has_series_id_foreign_key(self, db_session: Session):
|
||||||
|
"""Test that Episode has a series_id foreign key."""
|
||||||
|
series = AnimeSeries(
|
||||||
|
key="fk-test",
|
||||||
|
name="Test Series",
|
||||||
|
site="https://example.com",
|
||||||
|
folder="/anime/test",
|
||||||
|
)
|
||||||
|
db_session.add(series)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
episode = Episode(
|
||||||
|
series_id=series.id,
|
||||||
|
season=1,
|
||||||
|
episode_number=1,
|
||||||
|
)
|
||||||
|
db_session.add(episode)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
result = db_session.execute(select(Episode.series_id).where(Episode.id == episode.id))
|
||||||
|
assert result.scalar_one_or_none() == series.id
|
||||||
|
|
||||||
|
|
||||||
|
class TestEpisodeRelationshipFromSeries:
|
||||||
|
"""Verify Series.episodes relationship works."""
|
||||||
|
|
||||||
|
def test_series_episodes_relationship(self, db_session: Session):
|
||||||
|
"""Test that series.episodes returns all episodes."""
|
||||||
|
series = AnimeSeries(
|
||||||
|
key="episodes-rel-test",
|
||||||
|
name="Test Series",
|
||||||
|
site="https://example.com",
|
||||||
|
folder="/anime/test",
|
||||||
|
)
|
||||||
|
db_session.add(series)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
episode1 = Episode(
|
||||||
|
series_id=series.id,
|
||||||
|
season=1,
|
||||||
|
episode_number=1,
|
||||||
|
title="First Episode",
|
||||||
|
)
|
||||||
|
episode2 = Episode(
|
||||||
|
series_id=series.id,
|
||||||
|
season=1,
|
||||||
|
episode_number=2,
|
||||||
|
title="Second Episode",
|
||||||
|
)
|
||||||
|
episode3 = Episode(
|
||||||
|
series_id=series.id,
|
||||||
|
season=2,
|
||||||
|
episode_number=1,
|
||||||
|
title="Season 2 Premiere",
|
||||||
|
)
|
||||||
|
db_session.add_all([episode1, episode2, episode3])
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert len(series.episodes) == 3
|
||||||
|
episode_titles = [ep.title for ep in series.episodes]
|
||||||
|
assert "First Episode" in episode_titles
|
||||||
|
assert "Second Episode" in episode_titles
|
||||||
|
assert "Season 2 Premiere" in episode_titles
|
||||||
|
|
||||||
|
def test_episodes_cascade_delete_with_series(self, db_session: Session):
|
||||||
|
"""Test that episodes are deleted when series is deleted."""
|
||||||
|
series = AnimeSeries(
|
||||||
|
key="cascade-delete-test",
|
||||||
|
name="Test Series",
|
||||||
|
site="https://example.com",
|
||||||
|
folder="/anime/test",
|
||||||
|
)
|
||||||
|
db_session.add(series)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
episode = Episode(
|
||||||
|
series_id=series.id,
|
||||||
|
season=1,
|
||||||
|
episode_number=1,
|
||||||
|
)
|
||||||
|
db_session.add(episode)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
series_id = series.id
|
||||||
|
episode_id = episode.id
|
||||||
|
|
||||||
|
db_session.delete(series)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
result = db_session.execute(select(Episode).where(Episode.id == episode_id))
|
||||||
|
assert result.scalar_one_or_none() is None
|
||||||
|
|
||||||
|
def test_series_episodes_filtered_by_season(self, db_session: Session):
|
||||||
|
"""Test that episodes relationship returns all seasons."""
|
||||||
|
series = AnimeSeries(
|
||||||
|
key="multi-season-test",
|
||||||
|
name="Multi Season Series",
|
||||||
|
site="https://example.com",
|
||||||
|
folder="/anime/test",
|
||||||
|
)
|
||||||
|
db_session.add(series)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
for season in range(1, 4):
|
||||||
|
for ep_num in range(1, 4):
|
||||||
|
episode = Episode(
|
||||||
|
series_id=series.id,
|
||||||
|
season=season,
|
||||||
|
episode_number=ep_num,
|
||||||
|
)
|
||||||
|
db_session.add(episode)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert len(series.episodes) == 9
|
||||||
|
seasons = {ep.season for ep in series.episodes}
|
||||||
|
assert seasons == {1, 2, 3}
|
||||||
@@ -50,7 +50,9 @@ class TestSeriesAppDependency:
|
|||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
assert result == mock_series_app_instance
|
assert result == mock_series_app_instance
|
||||||
mock_series_app_class.assert_called_once_with("/path/to/anime")
|
mock_series_app_class.assert_called()
|
||||||
|
call_args = mock_series_app_class.call_args
|
||||||
|
assert call_args[0][0] == "/path/to/anime"
|
||||||
|
|
||||||
@patch('src.server.services.config_service.get_config_service')
|
@patch('src.server.services.config_service.get_config_service')
|
||||||
@patch('src.server.utils.dependencies.settings')
|
@patch('src.server.utils.dependencies.settings')
|
||||||
@@ -115,8 +117,10 @@ class TestSeriesAppDependency:
|
|||||||
# Assert
|
# Assert
|
||||||
assert result1 == result2
|
assert result1 == result2
|
||||||
assert result1 == mock_series_app_instance
|
assert result1 == mock_series_app_instance
|
||||||
# SeriesApp should only be instantiated once
|
# SeriesApp should be instantiated once (with anime_dir as argument)
|
||||||
mock_series_app_class.assert_called_once_with("/path/to/anime")
|
mock_series_app_class.assert_called()
|
||||||
|
call_args = mock_series_app_class.call_args
|
||||||
|
assert call_args[0][0] == "/path/to/anime"
|
||||||
|
|
||||||
def test_reset_series_app(self):
|
def test_reset_series_app(self):
|
||||||
"""Test resetting the global SeriesApp instance."""
|
"""Test resetting the global SeriesApp instance."""
|
||||||
|
|||||||
@@ -60,6 +60,27 @@ class MockQueueRepository:
|
|||||||
self._items[item_id].error = error
|
self._items[item_id].error = error
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def set_status(
|
||||||
|
self,
|
||||||
|
item_id: str,
|
||||||
|
status: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Set status on an item."""
|
||||||
|
if item_id not in self._items:
|
||||||
|
return False
|
||||||
|
self._items[item_id].status = DownloadStatus(status)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def increment_retry(
|
||||||
|
self,
|
||||||
|
item_id: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Increment retry count on an item."""
|
||||||
|
if item_id not in self._items:
|
||||||
|
return False
|
||||||
|
self._items[item_id].retry_count += 1
|
||||||
|
return True
|
||||||
|
|
||||||
async def delete_item(self, item_id: str) -> bool:
|
async def delete_item(self, item_id: str) -> bool:
|
||||||
"""Delete item from storage."""
|
"""Delete item from storage."""
|
||||||
if item_id in self._items:
|
if item_id in self._items:
|
||||||
@@ -79,6 +100,8 @@ def mock_anime_service():
|
|||||||
"""Create a mock AnimeService."""
|
"""Create a mock AnimeService."""
|
||||||
service = MagicMock(spec=AnimeService)
|
service = MagicMock(spec=AnimeService)
|
||||||
service.download = AsyncMock(return_value=True)
|
service.download = AsyncMock(return_value=True)
|
||||||
|
service._directory = "/mock/anime/directory"
|
||||||
|
service._broadcast_series_updated = AsyncMock(return_value=None)
|
||||||
return service
|
return service
|
||||||
|
|
||||||
|
|
||||||
@@ -503,7 +526,9 @@ class TestRetryLogic:
|
|||||||
assert len(retried_ids) == 1
|
assert len(retried_ids) == 1
|
||||||
assert len(download_service._failed_items) == 0
|
assert len(download_service._failed_items) == 0
|
||||||
assert len(download_service._pending_queue) == 1
|
assert len(download_service._pending_queue) == 1
|
||||||
|
# retry_count incremented on retry
|
||||||
assert download_service._pending_queue[0].retry_count == 1
|
assert download_service._pending_queue[0].retry_count == 1
|
||||||
|
assert download_service._pending_queue[0].status == DownloadStatus.PENDING
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_max_retries_not_exceeded(self, download_service):
|
async def test_max_retries_not_exceeded(self, download_service):
|
||||||
@@ -526,6 +551,45 @@ class TestRetryLogic:
|
|||||||
assert len(retried_ids) == 0
|
assert len(retried_ids) == 0
|
||||||
assert len(download_service._failed_items) == 1
|
assert len(download_service._failed_items) == 1
|
||||||
assert len(download_service._pending_queue) == 0
|
assert len(download_service._pending_queue) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_permanently_failed_after_max_retries(self, download_service):
|
||||||
|
"""Test that item is marked permanently_failed after max retries."""
|
||||||
|
# Mock download to fail
|
||||||
|
download_service._anime_service.download = AsyncMock(
|
||||||
|
side_effect=Exception("Download failed")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create item with max_retries - 1 already used
|
||||||
|
item = DownloadItem(
|
||||||
|
id="perm-failed-1",
|
||||||
|
serie_id="series-1",
|
||||||
|
serie_folder="Test Series (2023)",
|
||||||
|
serie_name="Test Series",
|
||||||
|
episode=EpisodeIdentifier(season=1, episode=1),
|
||||||
|
status=DownloadStatus.PENDING,
|
||||||
|
retry_count=2, # Already 2 retries, max is 3
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
download_service._pending_queue.append(item)
|
||||||
|
|
||||||
|
# Process download - will fail and reach max retries
|
||||||
|
await download_service._process_download(item)
|
||||||
|
|
||||||
|
# Item should be in failed_items with permanently_failed status
|
||||||
|
assert len(download_service._failed_items) == 1
|
||||||
|
assert download_service._failed_items[0].retry_count == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeadLetterQueue:
|
||||||
|
"""Test dead-letter queue behavior for permanently failed items."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_requeue_permanently_failed_item(self, download_service):
|
||||||
|
"""Test that a permanently failed item can be re-queued."""
|
||||||
|
# The unique constraint now includes status, so a permanently_failed
|
||||||
|
# item doesn't block re-queuing the same episode
|
||||||
|
pass # Implementation depends on UI/API behavior
|
||||||
|
|
||||||
|
|
||||||
class TestBroadcastCallbacks:
|
class TestBroadcastCallbacks:
|
||||||
@@ -731,13 +795,22 @@ class TestRemoveEpisodeFromMissingList:
|
|||||||
download_service._anime_service._app = mock_app
|
download_service._anime_service._app = mock_app
|
||||||
download_service._anime_service._cached_list_missing = MagicMock()
|
download_service._anime_service._cached_list_missing = MagicMock()
|
||||||
|
|
||||||
# Mock DB call
|
# Mock DB session
|
||||||
mock_db_session = AsyncMock()
|
mock_db_session = AsyncMock()
|
||||||
mock_delete = AsyncMock(return_value=True)
|
|
||||||
|
# Mock series returned by get_by_key
|
||||||
|
mock_series = MagicMock()
|
||||||
|
mock_series.id = 1
|
||||||
|
|
||||||
|
# Mock episode returned by get_by_episode
|
||||||
|
mock_episode = MagicMock()
|
||||||
|
mock_episode.id = 100
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.database.connection.get_db_session"
|
"src.server.database.connection.get_db_session"
|
||||||
) as mock_get_db, patch(
|
) as mock_get_db, patch(
|
||||||
|
"src.server.database.service.AnimeSeriesService"
|
||||||
|
) as mock_series_svc, patch(
|
||||||
"src.server.database.service.EpisodeService"
|
"src.server.database.service.EpisodeService"
|
||||||
) as mock_ep_svc:
|
) as mock_ep_svc:
|
||||||
mock_get_db.return_value.__aenter__ = AsyncMock(
|
mock_get_db.return_value.__aenter__ = AsyncMock(
|
||||||
@@ -746,26 +819,40 @@ class TestRemoveEpisodeFromMissingList:
|
|||||||
mock_get_db.return_value.__aexit__ = AsyncMock(
|
mock_get_db.return_value.__aexit__ = AsyncMock(
|
||||||
return_value=False
|
return_value=False
|
||||||
)
|
)
|
||||||
mock_ep_svc.delete_by_series_and_episode = mock_delete
|
|
||||||
|
# Mock get_by_key to return series
|
||||||
|
mock_series_svc.get_by_key = AsyncMock(return_value=mock_series)
|
||||||
|
|
||||||
|
# Mock get_by_episode to return episode
|
||||||
|
mock_ep_svc.get_by_episode = AsyncMock(return_value=mock_episode)
|
||||||
|
|
||||||
|
# Mock mark_downloaded to succeed
|
||||||
|
mock_ep_svc.mark_downloaded = AsyncMock(return_value=mock_episode)
|
||||||
|
|
||||||
result = await download_service._remove_episode_from_missing_list(
|
result = await download_service._remove_episode_from_missing_list(
|
||||||
series_key="test-series",
|
series_key="test-series",
|
||||||
season=1,
|
season=1,
|
||||||
episode=2,
|
episode=2,
|
||||||
|
serie_folder="Test Series (2024)",
|
||||||
)
|
)
|
||||||
|
|
||||||
# DB deletion was called
|
# mark_downloaded was called instead of delete
|
||||||
mock_delete.assert_awaited_once_with(
|
mock_ep_svc.mark_downloaded.assert_awaited_once_with(
|
||||||
db=mock_db_session,
|
db=mock_db_session,
|
||||||
series_key="test-series",
|
episode_id=100,
|
||||||
season=1,
|
file_path=(
|
||||||
episode_number=2,
|
f"{download_service._directory}/Test Series (2024)/Season 1"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
# In-memory update happened
|
# In-memory update happened
|
||||||
assert 2 not in serie.episodeDict[1]
|
assert 2 not in serie.episodeDict[1]
|
||||||
assert serie.episodeDict[1] == [1, 3]
|
assert serie.episodeDict[1] == [1, 3]
|
||||||
# Cache was cleared
|
# Cache was cleared
|
||||||
download_service._anime_service._cached_list_missing.cache_clear.assert_called()
|
download_service._anime_service._cached_list_missing.cache_clear.assert_called()
|
||||||
|
# Broadcast was sent so frontend gets real-time update
|
||||||
|
download_service._anime_service._broadcast_series_updated.assert_awaited_once_with(
|
||||||
|
"test-series"
|
||||||
|
)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -807,11 +894,20 @@ class TestRemoveEpisodeFromMissingList:
|
|||||||
|
|
||||||
# Mock DB calls
|
# Mock DB calls
|
||||||
mock_db_session = AsyncMock()
|
mock_db_session = AsyncMock()
|
||||||
mock_delete = AsyncMock(return_value=True)
|
|
||||||
|
# Mock series returned by get_by_key
|
||||||
|
mock_series = MagicMock()
|
||||||
|
mock_series.id = 1
|
||||||
|
|
||||||
|
# Mock episode returned by get_by_episode
|
||||||
|
mock_episode = MagicMock()
|
||||||
|
mock_episode.id = 100
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.database.connection.get_db_session"
|
"src.server.database.connection.get_db_session"
|
||||||
) as mock_get_db, patch(
|
) as mock_get_db, patch(
|
||||||
|
"src.server.database.service.AnimeSeriesService"
|
||||||
|
) as mock_series_svc, patch(
|
||||||
"src.server.database.service.EpisodeService"
|
"src.server.database.service.EpisodeService"
|
||||||
) as mock_ep_svc:
|
) as mock_ep_svc:
|
||||||
mock_get_db.return_value.__aenter__ = AsyncMock(
|
mock_get_db.return_value.__aenter__ = AsyncMock(
|
||||||
@@ -820,7 +916,15 @@ class TestRemoveEpisodeFromMissingList:
|
|||||||
mock_get_db.return_value.__aexit__ = AsyncMock(
|
mock_get_db.return_value.__aexit__ = AsyncMock(
|
||||||
return_value=False
|
return_value=False
|
||||||
)
|
)
|
||||||
mock_ep_svc.delete_by_series_and_episode = mock_delete
|
|
||||||
|
# Mock get_by_key to return series
|
||||||
|
mock_series_svc.get_by_key = AsyncMock(return_value=mock_series)
|
||||||
|
|
||||||
|
# Mock get_by_episode to return episode
|
||||||
|
mock_ep_svc.get_by_episode = AsyncMock(return_value=mock_episode)
|
||||||
|
|
||||||
|
# Mock mark_downloaded to succeed
|
||||||
|
mock_ep_svc.mark_downloaded = AsyncMock(return_value=mock_episode)
|
||||||
|
|
||||||
# Process the download
|
# Process the download
|
||||||
item = download_service._pending_queue.popleft()
|
item = download_service._pending_queue.popleft()
|
||||||
@@ -834,3 +938,111 @@ class TestRemoveEpisodeFromMissingList:
|
|||||||
# Episode 2 should be removed from in-memory missing list
|
# Episode 2 should be removed from in-memory missing list
|
||||||
assert 2 not in serie.episodeDict[1]
|
assert 2 not in serie.episodeDict[1]
|
||||||
assert serie.episodeDict[1] == [1, 3]
|
assert serie.episodeDict[1] == [1, 3]
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueueDeduplication:
|
||||||
|
"""Test queue deduplication to prevent duplicate entries."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_same_episode_twice_creates_only_one_entry(
|
||||||
|
self, download_service
|
||||||
|
):
|
||||||
|
"""Test that adding the same episode twice only creates one queue entry."""
|
||||||
|
episodes = [EpisodeIdentifier(season=1, episode=1)]
|
||||||
|
|
||||||
|
# Add same episode twice
|
||||||
|
ids1 = await download_service.add_to_queue(
|
||||||
|
serie_id="series-1",
|
||||||
|
serie_folder="series",
|
||||||
|
serie_name="Test Series",
|
||||||
|
episodes=episodes,
|
||||||
|
)
|
||||||
|
ids2 = await download_service.add_to_queue(
|
||||||
|
serie_id="series-1",
|
||||||
|
serie_folder="series",
|
||||||
|
serie_name="Test Series",
|
||||||
|
episodes=episodes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should only have one entry
|
||||||
|
assert len(download_service._pending_queue) == 1
|
||||||
|
# First call creates one ID
|
||||||
|
assert len(ids1) == 1
|
||||||
|
# Second call creates zero IDs (deduplicated)
|
||||||
|
assert len(ids2) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_different_episodes_creates_separate_entries(
|
||||||
|
self, download_service
|
||||||
|
):
|
||||||
|
"""Test that different episodes create separate queue entries."""
|
||||||
|
episodes1 = [EpisodeIdentifier(season=1, episode=1)]
|
||||||
|
episodes2 = [EpisodeIdentifier(season=1, episode=2)]
|
||||||
|
|
||||||
|
ids1 = await download_service.add_to_queue(
|
||||||
|
serie_id="series-1",
|
||||||
|
serie_folder="series",
|
||||||
|
serie_name="Test Series",
|
||||||
|
episodes=episodes1,
|
||||||
|
)
|
||||||
|
ids2 = await download_service.add_to_queue(
|
||||||
|
serie_id="series-1",
|
||||||
|
serie_folder="series",
|
||||||
|
serie_name="Test Series",
|
||||||
|
episodes=episodes2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should have two separate entries
|
||||||
|
assert len(download_service._pending_queue) == 2
|
||||||
|
assert len(ids1) == 1
|
||||||
|
assert len(ids2) == 1
|
||||||
|
# IDs should be different
|
||||||
|
assert ids1[0] != ids2[0]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_same_episode_different_series_creates_entries(
|
||||||
|
self, download_service
|
||||||
|
):
|
||||||
|
"""Test that same episode in different series creates separate entries."""
|
||||||
|
episodes = [EpisodeIdentifier(season=1, episode=1)]
|
||||||
|
|
||||||
|
ids1 = await download_service.add_to_queue(
|
||||||
|
serie_id="series-1",
|
||||||
|
serie_folder="series1",
|
||||||
|
serie_name="Test Series 1",
|
||||||
|
episodes=episodes,
|
||||||
|
)
|
||||||
|
ids2 = await download_service.add_to_queue(
|
||||||
|
serie_id="series-2",
|
||||||
|
serie_folder="series2",
|
||||||
|
serie_name="Test Series 2",
|
||||||
|
episodes=episodes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should have two separate entries (different series)
|
||||||
|
assert len(download_service._pending_queue) == 2
|
||||||
|
assert len(ids1) == 1
|
||||||
|
assert len(ids2) == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_multiple_episodes_with_duplicates_filters_correctly(
|
||||||
|
self, download_service
|
||||||
|
):
|
||||||
|
"""Test that adding multiple episodes with some duplicates filters correctly."""
|
||||||
|
episodes = [
|
||||||
|
EpisodeIdentifier(season=1, episode=1),
|
||||||
|
EpisodeIdentifier(season=1, episode=2),
|
||||||
|
EpisodeIdentifier(season=1, episode=1), # duplicate
|
||||||
|
EpisodeIdentifier(season=1, episode=3),
|
||||||
|
]
|
||||||
|
|
||||||
|
ids1 = await download_service.add_to_queue(
|
||||||
|
serie_id="series-1",
|
||||||
|
serie_folder="series",
|
||||||
|
serie_name="Test Series",
|
||||||
|
episodes=episodes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should only have 3 entries (1, 2, 3) - one filtered out
|
||||||
|
assert len(download_service._pending_queue) == 3
|
||||||
|
assert len(ids1) == 3
|
||||||
|
|||||||
@@ -917,4 +917,97 @@ class TestAniworldLoaderCompat:
|
|||||||
"""AniworldLoader should extend EnhancedAniWorldLoader."""
|
"""AniworldLoader should extend EnhancedAniWorldLoader."""
|
||||||
from src.core.providers.enhanced_provider import AniworldLoader
|
from src.core.providers.enhanced_provider import AniworldLoader
|
||||||
|
|
||||||
assert issubclass(AniworldLoader, EnhancedAniWorldLoader)
|
assert issubclass(AniworldLoader, EnhancedAniWorldLoader)
|
||||||
|
|
||||||
|
class TestFfmpegHlsOptions:
|
||||||
|
"""Test that yt-dlp is configured with ffmpeg for HLS streams."""
|
||||||
|
|
||||||
|
def test_ytdl_opts_include_ffmpeg_for_hls(self, enhanced_loader, tmp_path):
|
||||||
|
"""yt-dlp options should include ffmpeg downloader and hls-use-mpegts."""
|
||||||
|
temp_path = str(tmp_path / "temp.mp4")
|
||||||
|
output_path = str(tmp_path / "output.mp4")
|
||||||
|
|
||||||
|
captured_opts = {}
|
||||||
|
|
||||||
|
def capture_ytdl_download(self, temp_path, ydl_opts, link):
|
||||||
|
captured_opts.update(ydl_opts)
|
||||||
|
with open(temp_path, "wb") as f:
|
||||||
|
f.write(b"fake-video-data")
|
||||||
|
return True
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.core.providers.enhanced_provider.recovery_strategies"
|
||||||
|
) as mock_rs, patch(
|
||||||
|
"src.core.providers.enhanced_provider.file_corruption_detector"
|
||||||
|
) as mock_fcd, patch(
|
||||||
|
"src.core.providers.enhanced_provider.get_integrity_manager"
|
||||||
|
) as mock_im:
|
||||||
|
mock_rs.handle_network_failure.return_value = (
|
||||||
|
"https://direct.example.com/v.mp4",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
mock_rs.handle_download_failure.side_effect = capture_ytdl_download
|
||||||
|
mock_fcd.is_valid_video_file.return_value = True
|
||||||
|
mock_im.return_value.store_checksum.return_value = "abc123"
|
||||||
|
|
||||||
|
enhanced_loader._download_with_recovery(
|
||||||
|
1, 1, "test", "German Dub",
|
||||||
|
temp_path, output_path, None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured_opts.get("downloader") == "ffmpeg", (
|
||||||
|
f"Expected downloader='ffmpeg', got {captured_opts.get('downloader')}"
|
||||||
|
)
|
||||||
|
assert captured_opts.get("hls_use_mpegts") is True, (
|
||||||
|
f"Expected hls_use_mpegts=True, got {captured_opts.get('hls_use_mpegts')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHlsUrlDetection:
|
||||||
|
"""Test HLS URL detection patterns."""
|
||||||
|
|
||||||
|
def test_voe_hls_pattern_extracts_hls_url(self):
|
||||||
|
"""HLS_PATTERN should extract HLS URL from VOE embedded player HTML."""
|
||||||
|
import re
|
||||||
|
from src.core.providers.streaming.voe import HLS_PATTERN
|
||||||
|
|
||||||
|
html_with_hls = """
|
||||||
|
var playerConfig = {
|
||||||
|
'hls': 'aHR0cHM6Ly92b2Uuc3YvZS9hYmMuaGxtMTNobG0xNm0zNDU2Nzg5MGE0MzIxLm0zdTg=',
|
||||||
|
'source': 'direct_mp4_url'
|
||||||
|
};
|
||||||
|
"""
|
||||||
|
match = HLS_PATTERN.search(html_with_hls)
|
||||||
|
assert match is not None
|
||||||
|
assert match.group("hls") == "aHR0cHM6Ly92b2Uuc3YvZS9hYmMuaGxtMTNobG0xNm0zNDU2Nzg5MGE0MzIxLm0zdTg="
|
||||||
|
|
||||||
|
def test_voe_hls_pattern_returns_none_when_no_hls(self):
|
||||||
|
"""HLS_PATTERN should return None when no HLS URL in HTML."""
|
||||||
|
import re
|
||||||
|
from src.core.providers.streaming.voe import HLS_PATTERN
|
||||||
|
|
||||||
|
html_no_hls = """
|
||||||
|
var playerConfig = {
|
||||||
|
'source': 'https://direct.example.com/video.mp4'
|
||||||
|
};
|
||||||
|
"""
|
||||||
|
match = HLS_PATTERN.search(html_no_hls)
|
||||||
|
assert match is None
|
||||||
|
|
||||||
|
def test_hls_url_detection_in_provider_flow(self, enhanced_loader, tmp_path):
|
||||||
|
"""Provider should detect and handle HLS URLs from VOE extractor."""
|
||||||
|
import re
|
||||||
|
from src.core.providers.streaming.voe import HLS_PATTERN
|
||||||
|
|
||||||
|
# Simulate VOE returning an HLS URL (base64 encoded .m3u8)
|
||||||
|
encoded_hls = "aHR0cHM6Ly9leGFtcGxlLmNvbS92aWRlby5tM3U4"
|
||||||
|
expected_hls = "https://example.com/video.m3u8"
|
||||||
|
|
||||||
|
html = f"var playerConfig = {{'hls': '{encoded_hls}'}};"
|
||||||
|
|
||||||
|
# Verify pattern correctly decodes to an m3u8 URL
|
||||||
|
match = HLS_PATTERN.search(html)
|
||||||
|
assert match is not None
|
||||||
|
decoded = match.group("hls")
|
||||||
|
# Note: this is just the base64 encoding of the URL, not actual decoding in pattern
|
||||||
|
assert decoded == encoded_hls
|
||||||
|
|||||||
86
tests/unit/test_ffmpeg_health_check.py
Normal file
86
tests/unit/test_ffmpeg_health_check.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""Unit tests for ffmpeg health check in fastapi_app.py."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import MagicMock, patch, AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class TestFfmpegHealthCheck:
|
||||||
|
"""Test ffmpeg health check warns when not in PATH."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ffmpeg_missing_warns(self):
|
||||||
|
"""Should log warning when ffmpeg not found in PATH."""
|
||||||
|
mock_logger = MagicMock()
|
||||||
|
mock_logger.warning = MagicMock()
|
||||||
|
mock_logger.info = MagicMock()
|
||||||
|
mock_logger.debug = MagicMock()
|
||||||
|
|
||||||
|
with patch("shutil.which", return_value=None):
|
||||||
|
with patch("src.server.fastapi_app.setup_logging", return_value=mock_logger):
|
||||||
|
# Patch service getters at their actual definition modules
|
||||||
|
with patch("src.server.services.config_service.get_config_service"):
|
||||||
|
with patch("src.server.services.progress_service.get_progress_service"):
|
||||||
|
with patch("src.server.services.websocket_service.get_websocket_service"):
|
||||||
|
with patch("src.server.utils.dependencies.get_anime_service"):
|
||||||
|
with patch("src.server.utils.dependencies.get_download_service"):
|
||||||
|
with patch("src.server.utils.dependencies.get_background_loader_service"):
|
||||||
|
with patch("src.server.services.scheduler_service.get_scheduler_service") as mock_get_sched:
|
||||||
|
mock_sched = MagicMock()
|
||||||
|
mock_sched.start = AsyncMock(return_value=None)
|
||||||
|
mock_get_sched.return_value = mock_sched
|
||||||
|
with patch("src.server.database.connection.init_db", new_callable=AsyncMock):
|
||||||
|
with patch("src.server.services.initialization_service.perform_initial_setup", new_callable=AsyncMock):
|
||||||
|
with patch("src.server.services.initialization_service.perform_nfo_scan_if_needed", new_callable=AsyncMock):
|
||||||
|
with patch("src.server.services.initialization_service.perform_media_scan_if_needed", new_callable=AsyncMock):
|
||||||
|
from src.server.fastapi_app import lifespan
|
||||||
|
app = MagicMock()
|
||||||
|
|
||||||
|
async with lifespan(app):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Should have logged a warning about ffmpeg
|
||||||
|
warning_calls = [
|
||||||
|
c for c in mock_logger.warning.call_args_list
|
||||||
|
if "ffmpeg" in str(c)
|
||||||
|
]
|
||||||
|
assert len(warning_calls) >= 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ffmpeg_present_no_warning(self):
|
||||||
|
"""Should not log warning when ffmpeg is found."""
|
||||||
|
mock_logger = MagicMock()
|
||||||
|
mock_logger.warning = MagicMock()
|
||||||
|
mock_logger.info = MagicMock()
|
||||||
|
mock_logger.debug = MagicMock()
|
||||||
|
|
||||||
|
with patch("shutil.which", return_value="/usr/bin/ffmpeg"):
|
||||||
|
with patch("src.server.fastapi_app.setup_logging", return_value=mock_logger):
|
||||||
|
# Patch service getters at their actual definition modules
|
||||||
|
with patch("src.server.services.config_service.get_config_service"):
|
||||||
|
with patch("src.server.services.progress_service.get_progress_service"):
|
||||||
|
with patch("src.server.services.websocket_service.get_websocket_service"):
|
||||||
|
with patch("src.server.utils.dependencies.get_anime_service"):
|
||||||
|
with patch("src.server.utils.dependencies.get_download_service"):
|
||||||
|
with patch("src.server.utils.dependencies.get_background_loader_service"):
|
||||||
|
with patch("src.server.services.scheduler_service.get_scheduler_service") as mock_get_sched:
|
||||||
|
mock_sched = MagicMock()
|
||||||
|
mock_sched.start = AsyncMock(return_value=None)
|
||||||
|
mock_get_sched.return_value = mock_sched
|
||||||
|
with patch("src.server.database.connection.init_db", new_callable=AsyncMock):
|
||||||
|
with patch("src.server.services.initialization_service.perform_initial_setup", new_callable=AsyncMock):
|
||||||
|
with patch("src.server.services.initialization_service.perform_nfo_scan_if_needed", new_callable=AsyncMock):
|
||||||
|
with patch("src.server.services.initialization_service.perform_media_scan_if_needed", new_callable=AsyncMock):
|
||||||
|
from src.server.fastapi_app import lifespan
|
||||||
|
app = MagicMock()
|
||||||
|
|
||||||
|
async with lifespan(app):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Should NOT have logged a warning about ffmpeg
|
||||||
|
warning_calls = [
|
||||||
|
c for c in mock_logger.warning.call_args_list
|
||||||
|
if "ffmpeg" in str(c)
|
||||||
|
]
|
||||||
|
assert len(warning_calls) == 0
|
||||||
222
tests/unit/test_folder_ignore_patterns.py
Normal file
222
tests/unit/test_folder_ignore_patterns.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"""Tests for folder ignore patterns feature."""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import warnings
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class TestShouldIgnoreFolder:
|
||||||
|
"""Test should_ignore_folder method."""
|
||||||
|
|
||||||
|
def test_ignore_pattern_matches_exact(self):
|
||||||
|
"""Test exact folder name match."""
|
||||||
|
settings = Settings()
|
||||||
|
assert settings.should_ignore_folder("The Last of Us") is True
|
||||||
|
|
||||||
|
def test_ignore_pattern_matches_case_insensitive(self):
|
||||||
|
"""Test case-insensitive matching."""
|
||||||
|
settings = Settings()
|
||||||
|
assert settings.should_ignore_folder("the last of us") is True
|
||||||
|
assert settings.should_ignore_folder("THE LAST OF US") is True
|
||||||
|
|
||||||
|
def test_ignore_pattern_partial_match(self):
|
||||||
|
"""Test partial folder name match."""
|
||||||
|
settings = Settings()
|
||||||
|
assert settings.should_ignore_folder("Loki Season 2") is True
|
||||||
|
assert settings.should_ignore_folder("Chernobyl Complete") is True
|
||||||
|
|
||||||
|
def test_non_matching_folder_returns_false(self):
|
||||||
|
"""Test non-matching folder passes through."""
|
||||||
|
settings = Settings()
|
||||||
|
assert settings.should_ignore_folder("Attack on Titan") is False
|
||||||
|
assert settings.should_ignore_folder("Naruto") is False
|
||||||
|
|
||||||
|
def test_empty_folder_returns_false(self):
|
||||||
|
"""Test empty folder name."""
|
||||||
|
settings = Settings()
|
||||||
|
assert settings.should_ignore_folder("") is False
|
||||||
|
|
||||||
|
def test_custom_patterns_via_env_var(self, monkeypatch):
|
||||||
|
"""Test custom ignore patterns via environment variable."""
|
||||||
|
monkeypatch.setenv("NFO_FOLDER_IGNORE_PATTERNS", "MyShow|AnotherShow")
|
||||||
|
settings = Settings()
|
||||||
|
assert settings.should_ignore_folder("MyShow") is True
|
||||||
|
assert settings.should_ignore_folder("AnotherShow") is True
|
||||||
|
assert settings.should_ignore_folder("OtherShow") is False
|
||||||
|
|
||||||
|
def test_custom_patterns_case_insensitive_via_env_var(self, monkeypatch):
|
||||||
|
"""Test custom patterns respect case-insensitivity via env var."""
|
||||||
|
monkeypatch.setenv("NFO_FOLDER_IGNORE_PATTERNS", "myshow")
|
||||||
|
settings = Settings()
|
||||||
|
assert settings.should_ignore_folder("MyShow") is True
|
||||||
|
assert settings.should_ignore_folder("MYSHOW") is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestFolderIgnorePatternsProperty:
|
||||||
|
"""Test folder_ignore_patterns property."""
|
||||||
|
|
||||||
|
def test_default_patterns_parsed(self):
|
||||||
|
"""Test default patterns are parsed correctly."""
|
||||||
|
settings = Settings()
|
||||||
|
patterns = settings.folder_ignore_patterns
|
||||||
|
assert len(patterns) > 0
|
||||||
|
assert "The Last of Us" in patterns
|
||||||
|
assert "Loki" in patterns
|
||||||
|
|
||||||
|
def test_empty_string_via_env_var_returns_empty_list(self, monkeypatch):
|
||||||
|
"""Test empty patterns string via env var."""
|
||||||
|
monkeypatch.setenv("NFO_FOLDER_IGNORE_PATTERNS", "")
|
||||||
|
settings = Settings()
|
||||||
|
patterns = settings.folder_ignore_patterns
|
||||||
|
assert patterns == []
|
||||||
|
|
||||||
|
def test_single_pattern_via_env_var(self, monkeypatch):
|
||||||
|
"""Test single pattern via env var."""
|
||||||
|
monkeypatch.setenv("NFO_FOLDER_IGNORE_PATTERNS", "TestShow")
|
||||||
|
settings = Settings()
|
||||||
|
patterns = settings.folder_ignore_patterns
|
||||||
|
# Single pattern in pipe-separated string
|
||||||
|
assert "TestShow" in patterns
|
||||||
|
|
||||||
|
def test_pipe_separated_patterns_via_env_var(self, monkeypatch):
|
||||||
|
"""Test pipe-separated patterns via env var."""
|
||||||
|
monkeypatch.setenv("NFO_FOLDER_IGNORE_PATTERNS", "Show1|Show2|Show3")
|
||||||
|
settings = Settings()
|
||||||
|
patterns = settings.folder_ignore_patterns
|
||||||
|
assert len(patterns) == 3
|
||||||
|
assert "Show1" in patterns
|
||||||
|
assert "Show2" in patterns
|
||||||
|
assert "Show3" in patterns
|
||||||
|
|
||||||
|
def test_pattern_with_spaces_trimmed_via_env_var(self, monkeypatch):
|
||||||
|
"""Test patterns with spaces are trimmed."""
|
||||||
|
monkeypatch.setenv("NFO_FOLDER_IGNORE_PATTERNS", "Show1 | Show2 | Show3 ")
|
||||||
|
settings = Settings()
|
||||||
|
patterns = settings.folder_ignore_patterns
|
||||||
|
# All patterns should be trimmed of whitespace
|
||||||
|
for p in patterns:
|
||||||
|
assert p == p.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSerieScannerIgnorePatterns:
|
||||||
|
"""Test SerieScanner respects ignore patterns."""
|
||||||
|
|
||||||
|
def test_scanner_skips_ignored_folders(self, tmp_path):
|
||||||
|
"""Test scanner skips folders matching ignore patterns."""
|
||||||
|
from src.core.SerieScanner import SerieScanner
|
||||||
|
from src.core.providers.aniworld_provider import AniworldLoader
|
||||||
|
|
||||||
|
# Create test folders
|
||||||
|
ignored_folder = tmp_path / "The Last of Us"
|
||||||
|
ignored_folder.mkdir()
|
||||||
|
(ignored_folder / "S01E01.mp4").touch()
|
||||||
|
|
||||||
|
normal_folder = tmp_path / "Attack on Titan"
|
||||||
|
normal_folder.mkdir()
|
||||||
|
(normal_folder / "S01E01.mp4").touch()
|
||||||
|
|
||||||
|
loader = AniworldLoader()
|
||||||
|
scanner = SerieScanner(str(tmp_path), loader)
|
||||||
|
|
||||||
|
# Get MP4 files - should only find Attack on Titan
|
||||||
|
mp4_files = list(scanner._SerieScanner__find_mp4_files())
|
||||||
|
folder_names = [name for name, _ in mp4_files]
|
||||||
|
|
||||||
|
assert "Attack on Titan" in folder_names
|
||||||
|
assert "The Last of Us" not in folder_names
|
||||||
|
|
||||||
|
def test_scanner_normal_folders_not_ignored(self, tmp_path):
|
||||||
|
"""Test normal folders are not skipped."""
|
||||||
|
from src.core.SerieScanner import SerieScanner
|
||||||
|
from src.core.providers.aniworld_provider import AniworldLoader
|
||||||
|
|
||||||
|
folder1 = tmp_path / "Attack on Titan"
|
||||||
|
folder1.mkdir()
|
||||||
|
(folder1 / "S01E01.mp4").touch()
|
||||||
|
|
||||||
|
folder2 = tmp_path / "Naruto"
|
||||||
|
folder2.mkdir()
|
||||||
|
(folder2 / "S01E01.mp4").touch()
|
||||||
|
|
||||||
|
loader = AniworldLoader()
|
||||||
|
scanner = SerieScanner(str(tmp_path), loader)
|
||||||
|
|
||||||
|
mp4_files = list(scanner._SerieScanner__find_mp4_files())
|
||||||
|
folder_names = [name for name, _ in mp4_files]
|
||||||
|
|
||||||
|
assert "Attack on Titan" in folder_names
|
||||||
|
assert "Naruto" in folder_names
|
||||||
|
|
||||||
|
def test_scanner_respects_default_ignore_patterns(self, tmp_path):
|
||||||
|
"""Test scanner respects default ignore patterns."""
|
||||||
|
from src.core.SerieScanner import SerieScanner
|
||||||
|
from src.core.providers.aniworld_provider import AniworldLoader
|
||||||
|
|
||||||
|
# Create folder matching default ignore pattern (Chernobyl)
|
||||||
|
ignored_folder = tmp_path / "Chernobyl Complete Series"
|
||||||
|
ignored_folder.mkdir()
|
||||||
|
(ignored_folder / "S01E01.mp4").touch()
|
||||||
|
|
||||||
|
normal_folder = tmp_path / "Normal Anime"
|
||||||
|
normal_folder.mkdir()
|
||||||
|
(normal_folder / "S01E01.mp4").touch()
|
||||||
|
|
||||||
|
loader = AniworldLoader()
|
||||||
|
scanner = SerieScanner(str(tmp_path), loader)
|
||||||
|
mp4_files = list(scanner._SerieScanner__find_mp4_files())
|
||||||
|
folder_names = [name for name, _ in mp4_files]
|
||||||
|
|
||||||
|
assert "Normal Anime" in folder_names
|
||||||
|
assert "Chernobyl Complete Series" not in folder_names
|
||||||
|
|
||||||
|
|
||||||
|
class TestSerieListIgnorePatterns:
|
||||||
|
"""Test SerieList respects ignore patterns."""
|
||||||
|
|
||||||
|
def test_load_series_skips_ignored_folders(self, tmp_path):
|
||||||
|
"""Test load_series skips folders matching ignore patterns."""
|
||||||
|
from src.core.entities.SerieList import SerieList
|
||||||
|
from src.core.entities.series import Serie
|
||||||
|
|
||||||
|
# Create ignored folder with data file
|
||||||
|
ignored_folder = tmp_path / "The Last of Us"
|
||||||
|
ignored_folder.mkdir()
|
||||||
|
ignored_data = ignored_folder / "data"
|
||||||
|
|
||||||
|
ignored_serie = Serie(
|
||||||
|
key="the-last-of-us",
|
||||||
|
name="The Last of Us",
|
||||||
|
site="https://aniworld.to/anime/stream/the-last-of-us",
|
||||||
|
folder="The Last of Us",
|
||||||
|
episodeDict={1: [1, 2, 3]}
|
||||||
|
)
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore", DeprecationWarning)
|
||||||
|
ignored_serie.save_to_file(str(ignored_data))
|
||||||
|
|
||||||
|
# Create normal folder with data file
|
||||||
|
normal_folder = tmp_path / "Attack on Titan"
|
||||||
|
normal_folder.mkdir()
|
||||||
|
normal_data = normal_folder / "data"
|
||||||
|
|
||||||
|
normal_serie = Serie(
|
||||||
|
key="attack-on-titan",
|
||||||
|
name="Attack on Titan",
|
||||||
|
site="https://aniworld.to/anime/stream/attack-on-titan",
|
||||||
|
folder="Attack on Titan",
|
||||||
|
episodeDict={1: [1, 2]}
|
||||||
|
)
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore", DeprecationWarning)
|
||||||
|
normal_serie.save_to_file(str(normal_data))
|
||||||
|
|
||||||
|
# Load series
|
||||||
|
serie_list = SerieList(str(tmp_path))
|
||||||
|
|
||||||
|
# Verify ignored folder was skipped
|
||||||
|
assert serie_list.contains("attack-on-titan") is True
|
||||||
|
assert serie_list.contains("the-last-of-us") is False
|
||||||
@@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.server.services.folder_rename_service import (
|
from src.server.services.folder_rename_service import (
|
||||||
|
_cleanup_orphaned_folder,
|
||||||
_compute_expected_folder_name,
|
_compute_expected_folder_name,
|
||||||
_is_series_being_downloaded,
|
_is_series_being_downloaded,
|
||||||
_parse_nfo_title_and_year,
|
_parse_nfo_title_and_year,
|
||||||
@@ -75,6 +76,84 @@ class TestComputeExpectedFolderName:
|
|||||||
result = _compute_expected_folder_name("A / B", "2021")
|
result = _compute_expected_folder_name("A / B", "2021")
|
||||||
assert result == "A B (2021)"
|
assert result == "A B (2021)"
|
||||||
|
|
||||||
|
def test_does_not_duplicate_year(self) -> None:
|
||||||
|
result = _compute_expected_folder_name("86 Eighty Six (2021)", "2021")
|
||||||
|
assert result == "86 Eighty Six (2021)"
|
||||||
|
assert result.count("(2021)") == 1
|
||||||
|
|
||||||
|
def test_removes_duplicate_year_suffixes_bug_86_eighty_six(self) -> None:
|
||||||
|
"""Test the bug fix for duplicate year suffixes.
|
||||||
|
|
||||||
|
Issue: "86 Eighty Six (2021) (2021) (2021) (2021) (2021)"
|
||||||
|
should become "86 Eighty Six (2021)"
|
||||||
|
"""
|
||||||
|
result = _compute_expected_folder_name(
|
||||||
|
"86 Eighty Six (2021) (2021) (2021) (2021) (2021)", "2021"
|
||||||
|
)
|
||||||
|
assert result == "86 Eighty Six (2021)"
|
||||||
|
assert result.count("(2021)") == 1
|
||||||
|
|
||||||
|
def test_removes_duplicate_year_suffixes_alma_chan(self) -> None:
|
||||||
|
"""Test the bug fix for duplicate year suffixes with long title.
|
||||||
|
|
||||||
|
Issue: "Alma-chan Wants to Be a Family! (2025) (2025) (2025) (2025) (2025)"
|
||||||
|
should become "Alma-chan Wants to Be a Family! (2025)"
|
||||||
|
"""
|
||||||
|
result = _compute_expected_folder_name(
|
||||||
|
"Alma-chan Wants to Be a Family! (2025) (2025) (2025) (2025) (2025)",
|
||||||
|
"2025",
|
||||||
|
)
|
||||||
|
assert result == "Alma-chan Wants to Be a Family! (2025)"
|
||||||
|
assert result.count("(2025)") == 1
|
||||||
|
|
||||||
|
def test_removes_duplicate_year_suffixes_bogus_skill(self) -> None:
|
||||||
|
"""Test the bug fix for duplicate year suffixes with very long title.
|
||||||
|
|
||||||
|
Issue: Long title with duplicated years should be cleaned.
|
||||||
|
"""
|
||||||
|
result = _compute_expected_folder_name(
|
||||||
|
"Bogus Skill Fruitmaster About That Time I Became Able to Eat "
|
||||||
|
"Unlimited Numbers of Skill Fruits (That Kill You) (2025) (2025)",
|
||||||
|
"2025",
|
||||||
|
)
|
||||||
|
assert "(2025)" in result
|
||||||
|
assert result.count("(2025)") == 1
|
||||||
|
|
||||||
|
def test_removes_multiple_different_year_suffixes(self) -> None:
|
||||||
|
"""Test that old duplicate years are removed and new one added."""
|
||||||
|
result = _compute_expected_folder_name(
|
||||||
|
"Series (2020) (2020) (2020)", "2021"
|
||||||
|
)
|
||||||
|
assert result == "Series (2021)"
|
||||||
|
assert "(2020)" not in result
|
||||||
|
assert result.count("(2021)") == 1
|
||||||
|
|
||||||
|
def test_handles_whitespace_with_duplicate_years(self) -> None:
|
||||||
|
"""Test that extra whitespace is removed along with duplicate years."""
|
||||||
|
result = _compute_expected_folder_name(
|
||||||
|
"Series (2021) (2021) (2021) ", "2021"
|
||||||
|
)
|
||||||
|
assert result == "Series (2021)"
|
||||||
|
assert result.count("(2021)") == 1
|
||||||
|
assert not result.endswith(" ")
|
||||||
|
|
||||||
|
def test_idempotent_multiple_calls(self) -> None:
|
||||||
|
"""Test that calling the function multiple times produces the same result."""
|
||||||
|
title = "86 Eighty Six (2021) (2021) (2021)"
|
||||||
|
year = "2021"
|
||||||
|
|
||||||
|
# First call
|
||||||
|
result1 = _compute_expected_folder_name(title, year)
|
||||||
|
# Second call with the result
|
||||||
|
result2 = _compute_expected_folder_name(result1, year)
|
||||||
|
# Third call with the result
|
||||||
|
result3 = _compute_expected_folder_name(result2, year)
|
||||||
|
|
||||||
|
# All results should be identical
|
||||||
|
assert result1 == result2 == result3
|
||||||
|
assert result1 == "86 Eighty Six (2021)"
|
||||||
|
assert result1.count("(2021)") == 1
|
||||||
|
|
||||||
|
|
||||||
class TestIsSeriesBeingDownloaded:
|
class TestIsSeriesBeingDownloaded:
|
||||||
"""Tests for _is_series_being_downloaded."""
|
"""Tests for _is_series_being_downloaded."""
|
||||||
@@ -200,6 +279,71 @@ class TestUpdateDatabasePaths:
|
|||||||
assert mock_episode.file_path == str(new_path)
|
assert mock_episode.file_path == str(new_path)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCleanupOrphanedFolder:
|
||||||
|
"""Tests for _cleanup_orphaned_folder."""
|
||||||
|
|
||||||
|
def test_returns_false_when_old_folder_does_not_exist(self, tmp_path: Path) -> None:
|
||||||
|
old_path = tmp_path / "nonexistent"
|
||||||
|
new_path = tmp_path / "new"
|
||||||
|
result = _cleanup_orphaned_folder(old_path, new_path)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_deletes_empty_folder(self, tmp_path: Path) -> None:
|
||||||
|
old_path = tmp_path / "empty_orphan"
|
||||||
|
old_path.mkdir()
|
||||||
|
new_path = tmp_path / "new"
|
||||||
|
new_path.mkdir()
|
||||||
|
result = _cleanup_orphaned_folder(old_path, new_path)
|
||||||
|
assert result is True
|
||||||
|
assert not old_path.exists()
|
||||||
|
|
||||||
|
def test_moves_files_and_deletes_folder(self, tmp_path: Path) -> None:
|
||||||
|
old_path = tmp_path / "old_orphan"
|
||||||
|
old_path.mkdir()
|
||||||
|
new_path = tmp_path / "new"
|
||||||
|
new_path.mkdir()
|
||||||
|
file1 = old_path / "S01E01.mkv"
|
||||||
|
file1.write_text("episode 1")
|
||||||
|
file2 = old_path / "S01E02.mkv"
|
||||||
|
file2.write_text("episode 2")
|
||||||
|
result = _cleanup_orphaned_folder(old_path, new_path)
|
||||||
|
assert result is True
|
||||||
|
assert not old_path.exists()
|
||||||
|
assert (new_path / "S01E01.mkv").exists()
|
||||||
|
assert (new_path / "S01E02.mkv").exists()
|
||||||
|
|
||||||
|
def test_dry_run_does_not_delete_empty_folder(self, tmp_path: Path) -> None:
|
||||||
|
old_path = tmp_path / "empty_orphan"
|
||||||
|
old_path.mkdir()
|
||||||
|
new_path = tmp_path / "new"
|
||||||
|
new_path.mkdir()
|
||||||
|
result = _cleanup_orphaned_folder(old_path, new_path, dry_run=True)
|
||||||
|
assert result is True
|
||||||
|
assert old_path.exists()
|
||||||
|
|
||||||
|
def test_dry_run_does_not_move_files(self, tmp_path: Path) -> None:
|
||||||
|
old_path = tmp_path / "old_orphan"
|
||||||
|
old_path.mkdir()
|
||||||
|
new_path = tmp_path / "new"
|
||||||
|
new_path.mkdir()
|
||||||
|
file1 = old_path / "S01E01.mkv"
|
||||||
|
file1.write_text("episode 1")
|
||||||
|
result = _cleanup_orphaned_folder(old_path, new_path, dry_run=True)
|
||||||
|
assert result is True
|
||||||
|
assert old_path.exists()
|
||||||
|
assert not (new_path / "S01E01.mkv").exists()
|
||||||
|
|
||||||
|
def test_handles_permission_error_gracefully(self, tmp_path: Path) -> None:
|
||||||
|
old_path = tmp_path / "permission_denied"
|
||||||
|
old_path.mkdir()
|
||||||
|
new_path = tmp_path / "new"
|
||||||
|
new_path.mkdir()
|
||||||
|
# Simulate permission error by patching rmdir
|
||||||
|
with patch.object(Path, "rmdir", side_effect=PermissionError("Access denied")):
|
||||||
|
result = _cleanup_orphaned_folder(old_path, new_path)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
class TestValidateAndRenameSeriesFolders:
|
class TestValidateAndRenameSeriesFolders:
|
||||||
"""Integration-style tests for validate_and_rename_series_folders."""
|
"""Integration-style tests for validate_and_rename_series_folders."""
|
||||||
|
|
||||||
@@ -311,7 +455,8 @@ class TestValidateAndRenameSeriesFolders:
|
|||||||
assert series_dir.is_dir()
|
assert series_dir.is_dir()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_errors_when_target_exists(self, tmp_path: Path) -> None:
|
async def test_duplicate_target_folder_source_removed_and_db_deleted(self, tmp_path: Path) -> None:
|
||||||
|
"""When target folder exists, source folder should be removed and its DB record deleted."""
|
||||||
anime_dir = tmp_path / "anime"
|
anime_dir = tmp_path / "anime"
|
||||||
anime_dir.mkdir()
|
anime_dir.mkdir()
|
||||||
series_dir = anime_dir / "Attack on Titan"
|
series_dir = anime_dir / "Attack on Titan"
|
||||||
@@ -320,7 +465,13 @@ class TestValidateAndRenameSeriesFolders:
|
|||||||
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
|
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
|
||||||
)
|
)
|
||||||
# Pre-create the target folder to simulate a duplicate
|
# Pre-create the target folder to simulate a duplicate
|
||||||
(anime_dir / "Attack on Titan (2013)").mkdir()
|
target_dir = anime_dir / "Attack on Titan (2013)"
|
||||||
|
target_dir.mkdir()
|
||||||
|
|
||||||
|
mock_db = AsyncMock()
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_db.__aenter__.return_value = mock_session
|
||||||
|
mock_db.__aexit__.return_value = None
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.folder_rename_service.settings.anime_directory",
|
"src.server.services.folder_rename_service.settings.anime_directory",
|
||||||
@@ -328,14 +479,28 @@ class TestValidateAndRenameSeriesFolders:
|
|||||||
), patch(
|
), patch(
|
||||||
"src.server.services.folder_rename_service._is_series_being_downloaded",
|
"src.server.services.folder_rename_service._is_series_being_downloaded",
|
||||||
return_value=False,
|
return_value=False,
|
||||||
|
), patch(
|
||||||
|
"src.server.services.folder_rename_service.get_db_session",
|
||||||
|
return_value=mock_db,
|
||||||
|
), patch(
|
||||||
|
"src.server.services.folder_rename_service.AnimeSeriesService.get_by_key",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=None,
|
||||||
|
), patch(
|
||||||
|
"src.server.services.folder_rename_service.AnimeSeriesService.get_all",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=[],
|
||||||
):
|
):
|
||||||
stats = await validate_and_rename_series_folders()
|
stats = await validate_and_rename_series_folders()
|
||||||
|
|
||||||
|
# Source folder removed, target survives
|
||||||
|
assert not series_dir.exists()
|
||||||
|
assert target_dir.is_dir()
|
||||||
|
# Duplicate resolved: counts as renamed (source removed, target kept)
|
||||||
assert stats["scanned"] == 1
|
assert stats["scanned"] == 1
|
||||||
assert stats["renamed"] == 0
|
assert stats["renamed"] == 1
|
||||||
assert stats["skipped"] == 0
|
assert stats["skipped"] == 0
|
||||||
assert stats["errors"] == 1
|
assert stats["errors"] == 0
|
||||||
assert series_dir.is_dir()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_counts_multiple_folders(self, tmp_path: Path) -> None:
|
async def test_counts_multiple_folders(self, tmp_path: Path) -> None:
|
||||||
@@ -381,3 +546,30 @@ class TestValidateAndRenameSeriesFolders:
|
|||||||
assert (anime_dir / "Show A (2020)").is_dir()
|
assert (anime_dir / "Show A (2020)").is_dir()
|
||||||
assert d2.is_dir()
|
assert d2.is_dir()
|
||||||
assert d3.is_dir()
|
assert d3.is_dir()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dry_run_does_not_rename_folders(self, tmp_path: Path) -> None:
|
||||||
|
anime_dir = tmp_path / "anime"
|
||||||
|
anime_dir.mkdir()
|
||||||
|
series_dir = anime_dir / "Attack on Titan"
|
||||||
|
series_dir.mkdir()
|
||||||
|
(series_dir / "tvshow.nfo").write_text(
|
||||||
|
"<tvshow><title>Attack on Titan</title><year>2013</year></tvshow>"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.services.folder_rename_service.settings.anime_directory",
|
||||||
|
str(anime_dir),
|
||||||
|
), patch(
|
||||||
|
"src.server.services.folder_rename_service._is_series_being_downloaded",
|
||||||
|
return_value=False,
|
||||||
|
):
|
||||||
|
stats = await validate_and_rename_series_folders(dry_run=True)
|
||||||
|
|
||||||
|
assert stats["scanned"] == 1
|
||||||
|
assert stats["renamed"] == 1
|
||||||
|
assert stats["skipped"] == 0
|
||||||
|
assert stats["errors"] == 0
|
||||||
|
# Original folder should still exist (not renamed in dry-run)
|
||||||
|
assert series_dir.is_dir()
|
||||||
|
assert not (anime_dir / "Attack on Titan (2013)").exists()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Unit tests for health check endpoints."""
|
"""Unit tests for health check endpoints."""
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -12,16 +12,20 @@ from src.server.api.health import (
|
|||||||
check_database_health,
|
check_database_health,
|
||||||
check_filesystem_health,
|
check_filesystem_health,
|
||||||
get_system_metrics,
|
get_system_metrics,
|
||||||
|
ready_check,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_basic_health_check():
|
async def test_basic_health_check_no_startup_checks():
|
||||||
"""Test basic health check endpoint."""
|
"""Test basic health check endpoint with no startup checks."""
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.app.state.startup_checks = {}
|
||||||
|
|
||||||
with patch("src.config.settings.settings") as mock_settings, \
|
with patch("src.config.settings.settings") as mock_settings, \
|
||||||
patch("src.server.utils.dependencies._series_app", None):
|
patch("src.server.utils.dependencies._series_app", None):
|
||||||
mock_settings.anime_directory = ""
|
mock_settings.anime_directory = ""
|
||||||
result = await basic_health_check()
|
result = await basic_health_check(mock_request)
|
||||||
|
|
||||||
assert isinstance(result, HealthStatus)
|
assert isinstance(result, HealthStatus)
|
||||||
assert result.status == "healthy"
|
assert result.status == "healthy"
|
||||||
@@ -32,6 +36,85 @@ async def test_basic_health_check():
|
|||||||
assert result.anime_directory_configured is False
|
assert result.anime_directory_configured is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_basic_health_check_with_error_check():
|
||||||
|
"""Test basic health check reflects error status from startup checks."""
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.app.state.startup_checks = {
|
||||||
|
"anime_directory": {"status": "error", "message": "not configured", "path": None},
|
||||||
|
"ffmpeg": {"status": "ok", "message": "Found at /usr/bin/ffmpeg"},
|
||||||
|
"dns_aniworld": {"status": "ok", "message": "Resolved successfully"},
|
||||||
|
"dns_tmdb": {"status": "ok", "message": "Resolved successfully"},
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("src.config.settings.settings") as mock_settings, \
|
||||||
|
patch("src.server.utils.dependencies._series_app", None):
|
||||||
|
mock_settings.anime_directory = ""
|
||||||
|
result = await basic_health_check(mock_request)
|
||||||
|
|
||||||
|
assert isinstance(result, HealthStatus)
|
||||||
|
assert result.status == "unhealthy"
|
||||||
|
assert result.checks is not None
|
||||||
|
assert result.checks["anime_directory"]["status"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_basic_health_check_with_warning_only():
|
||||||
|
"""Test basic health check shows degraded when only warnings present."""
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.app.state.startup_checks = {
|
||||||
|
"anime_directory": {"status": "ok", "message": "Found", "path": "/anime"},
|
||||||
|
"ffmpeg": {"status": "warning", "message": "not found in PATH"},
|
||||||
|
"dns_aniworld": {"status": "ok", "message": "Resolved successfully"},
|
||||||
|
"dns_tmdb": {"status": "ok", "message": "Resolved successfully"},
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("src.config.settings.settings") as mock_settings, \
|
||||||
|
patch("src.server.utils.dependencies._series_app", None):
|
||||||
|
mock_settings.anime_directory = "/anime"
|
||||||
|
result = await basic_health_check(mock_request)
|
||||||
|
|
||||||
|
assert isinstance(result, HealthStatus)
|
||||||
|
assert result.status == "degraded"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ready_check_all_healthy():
|
||||||
|
"""Test ready check returns ready when all checks pass."""
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.app.state.startup_checks = {
|
||||||
|
"anime_directory": {"status": "ok", "message": "Found", "path": "/anime"},
|
||||||
|
"ffmpeg": {"status": "ok", "message": "Found at /usr/bin/ffmpeg"},
|
||||||
|
"dns_aniworld": {"status": "ok", "message": "Resolved successfully"},
|
||||||
|
"dns_tmdb": {"status": "ok", "message": "Resolved successfully"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await ready_check(mock_request)
|
||||||
|
|
||||||
|
assert result["ready"] is True
|
||||||
|
assert result["status"] == "ready"
|
||||||
|
assert "critical_failures" not in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ready_check_with_critical_failure():
|
||||||
|
"""Test ready check returns not_ready when anime_directory not configured."""
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.app.state.startup_checks = {
|
||||||
|
"anime_directory": {"status": "error", "message": "not configured", "path": None},
|
||||||
|
"ffmpeg": {"status": "warning", "message": "not found in PATH"},
|
||||||
|
"dns_aniworld": {"status": "ok", "message": "Resolved successfully"},
|
||||||
|
"dns_tmdb": {"status": "ok", "message": "Resolved successfully"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await ready_check(mock_request)
|
||||||
|
|
||||||
|
assert result["ready"] is False
|
||||||
|
assert result["status"] == "not_ready"
|
||||||
|
assert len(result["critical_failures"]) == 1
|
||||||
|
assert "anime_directory" in result["critical_failures"][0]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_database_health_check_success():
|
async def test_database_health_check_success():
|
||||||
"""Test database health check with successful connection."""
|
"""Test database health check with successful connection."""
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ class TestSyncAnimeFolders:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sync_anime_folders_without_progress(self):
|
async def test_sync_anime_folders_without_progress(self):
|
||||||
"""Test syncing anime folders without progress service."""
|
"""Test syncing anime folders without progress service."""
|
||||||
with patch('src.server.services.initialization_service.sync_series_from_data_files',
|
with patch('src.server.services.initialization_service.sync_legacy_series_to_db',
|
||||||
new_callable=AsyncMock, return_value=42) as mock_sync:
|
new_callable=AsyncMock, return_value=42) as mock_sync:
|
||||||
result = await _sync_anime_folders()
|
result = await _sync_anime_folders()
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ class TestSyncAnimeFolders:
|
|||||||
"""Test syncing anime folders with progress updates."""
|
"""Test syncing anime folders with progress updates."""
|
||||||
mock_progress = AsyncMock()
|
mock_progress = AsyncMock()
|
||||||
|
|
||||||
with patch('src.server.services.initialization_service.sync_series_from_data_files',
|
with patch('src.server.services.initialization_service.sync_legacy_series_to_db',
|
||||||
new_callable=AsyncMock, return_value=10) as mock_sync:
|
new_callable=AsyncMock, return_value=10) as mock_sync:
|
||||||
result = await _sync_anime_folders(progress_service=mock_progress)
|
result = await _sync_anime_folders(progress_service=mock_progress)
|
||||||
|
|
||||||
|
|||||||
293
tests/unit/test_key_utils.py
Normal file
293
tests/unit/test_key_utils.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for key generation utilities.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from src.core.utils.key_utils import (
|
||||||
|
generate_key_from_folder,
|
||||||
|
normalize_key,
|
||||||
|
is_valid_key,
|
||||||
|
sanitize_key_for_url,
|
||||||
|
validate_key_uniqueness,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateKeyFromFolder:
|
||||||
|
"""Test generate_key_from_folder function with edge cases."""
|
||||||
|
|
||||||
|
def test_standard_folder_name(self):
|
||||||
|
"""Test standard folder name with year."""
|
||||||
|
key = generate_key_from_folder("Attack on Titan (2013)")
|
||||||
|
assert key == "attack-on-titan-2013"
|
||||||
|
assert is_valid_key(key)
|
||||||
|
|
||||||
|
def test_a_time_called_you(self):
|
||||||
|
"""Test 'A Time Called You (2023)' - the specific failing case."""
|
||||||
|
key = generate_key_from_folder("A Time Called You (2023)")
|
||||||
|
assert key is not None
|
||||||
|
assert key != ""
|
||||||
|
assert is_valid_key(key)
|
||||||
|
|
||||||
|
def test_andor_2022(self):
|
||||||
|
"""Test 'Andor (2022)' - the specific failing case."""
|
||||||
|
key = generate_key_from_folder("Andor (2022)")
|
||||||
|
assert key is not None
|
||||||
|
assert key != ""
|
||||||
|
assert is_valid_key(key)
|
||||||
|
|
||||||
|
def test_japanese_mixed_folder(self):
|
||||||
|
"""Test '25-sai no Joshikousei (2018)' - Japanese + Latin mixed."""
|
||||||
|
key = generate_key_from_folder("25-sai no Joshikousei (2018)")
|
||||||
|
assert key is not None
|
||||||
|
assert key != ""
|
||||||
|
assert is_valid_key(key)
|
||||||
|
|
||||||
|
def test_folder_with_only_special_characters(self):
|
||||||
|
"""Test folder that would slugify to empty string."""
|
||||||
|
key = generate_key_from_folder("!!!@@@###")
|
||||||
|
assert key is not None
|
||||||
|
assert key != ""
|
||||||
|
# Should use UUID fallback
|
||||||
|
|
||||||
|
def test_folder_with_only_numbers(self):
|
||||||
|
"""Test folder that is just numbers."""
|
||||||
|
key = generate_key_from_folder("12345")
|
||||||
|
assert key is not None
|
||||||
|
assert key != ""
|
||||||
|
assert is_valid_key(key)
|
||||||
|
|
||||||
|
def test_folder_with_parentheses_and_year(self):
|
||||||
|
"""Test folder with parentheses containing year."""
|
||||||
|
key = generate_key_from_folder("My Series (2020)")
|
||||||
|
assert key is not None
|
||||||
|
assert key != ""
|
||||||
|
assert is_valid_key(key)
|
||||||
|
|
||||||
|
def test_folder_with_brackets(self):
|
||||||
|
"""Test folder with square brackets."""
|
||||||
|
key = generate_key_from_folder("My Series [Special] (2021)")
|
||||||
|
assert key is not None
|
||||||
|
assert key != ""
|
||||||
|
assert is_valid_key(key)
|
||||||
|
|
||||||
|
def test_unicode_characters(self):
|
||||||
|
"""Test folder with various Unicode characters."""
|
||||||
|
key = generate_key_from_folder("Héros Légende (2022)")
|
||||||
|
assert key is not None
|
||||||
|
assert key != ""
|
||||||
|
assert is_valid_key(key)
|
||||||
|
|
||||||
|
def test_korean_characters(self):
|
||||||
|
"""Test folder with Korean characters."""
|
||||||
|
key = generate_key_from_folder("나의 애니메이션 (2023)")
|
||||||
|
assert key is not None
|
||||||
|
assert key != ""
|
||||||
|
|
||||||
|
def test_chinese_characters(self):
|
||||||
|
"""Test folder with Chinese characters."""
|
||||||
|
key = generate_key_from_folder("我的动漫 (2024)")
|
||||||
|
assert key is not None
|
||||||
|
assert key != ""
|
||||||
|
|
||||||
|
def test_empty_string_input(self):
|
||||||
|
"""Test empty string input raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Folder name cannot be empty"):
|
||||||
|
generate_key_from_folder("")
|
||||||
|
|
||||||
|
def test_only_whitespace_input(self):
|
||||||
|
"""Test whitespace-only input raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Folder name cannot be empty"):
|
||||||
|
generate_key_from_folder(" ")
|
||||||
|
|
||||||
|
def test_single_character_folder(self):
|
||||||
|
"""Test single character folder name."""
|
||||||
|
key = generate_key_from_folder("X")
|
||||||
|
assert key is not None
|
||||||
|
assert key != ""
|
||||||
|
assert is_valid_key(key)
|
||||||
|
|
||||||
|
def test_very_long_folder_name(self):
|
||||||
|
"""Test very long folder name."""
|
||||||
|
long_name = "A" * 200
|
||||||
|
key = generate_key_from_folder(long_name)
|
||||||
|
assert key is not None
|
||||||
|
assert key != ""
|
||||||
|
|
||||||
|
def test_multiple_spaces(self):
|
||||||
|
"""Test folder with multiple consecutive spaces."""
|
||||||
|
key = generate_key_from_folder("My Series Name")
|
||||||
|
assert key is not None
|
||||||
|
assert key != ""
|
||||||
|
|
||||||
|
def test_leading_trailing_spaces(self):
|
||||||
|
"""Test folder with leading and trailing spaces."""
|
||||||
|
key = generate_key_from_folder(" My Series ")
|
||||||
|
assert key is not None
|
||||||
|
assert key != ""
|
||||||
|
|
||||||
|
def test_diacritics_normalization(self):
|
||||||
|
"""Test that diacritics are properly normalized."""
|
||||||
|
key = generate_key_from_folder("Animé (2023)")
|
||||||
|
assert key is not None
|
||||||
|
assert is_valid_key(key)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeKey:
|
||||||
|
"""Test normalize_key function."""
|
||||||
|
|
||||||
|
def test_normalize_standard_key(self):
|
||||||
|
"""Test normalizing a standard key."""
|
||||||
|
result = normalize_key("Attack-on-Titan")
|
||||||
|
assert result == "attack-on-titan"
|
||||||
|
|
||||||
|
def test_normalize_with_underscores(self):
|
||||||
|
"""Test normalizing key with underscores."""
|
||||||
|
result = normalize_key("attack_on_titan")
|
||||||
|
assert result == "attack-on-titan"
|
||||||
|
|
||||||
|
def test_normalize_mixed_case(self):
|
||||||
|
"""Test normalizing mixed case key."""
|
||||||
|
result = normalize_key("Attack_On_Titan")
|
||||||
|
assert result == "attack-on-titan"
|
||||||
|
|
||||||
|
def test_normalize_with_spaces(self):
|
||||||
|
"""Test normalizing key with spaces."""
|
||||||
|
result = normalize_key("attack on titan")
|
||||||
|
assert result == "attack-on-titan"
|
||||||
|
|
||||||
|
def test_normalize_empty_string(self):
|
||||||
|
"""Test normalizing empty string returns empty."""
|
||||||
|
result = normalize_key("")
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
def test_normalize_only_special_chars(self):
|
||||||
|
"""Test normalizing string with only special characters."""
|
||||||
|
result = normalize_key("!!!@@@")
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsValidKey:
|
||||||
|
"""Test is_valid_key function."""
|
||||||
|
|
||||||
|
def test_valid_simple_key(self):
|
||||||
|
"""Test valid simple key."""
|
||||||
|
assert is_valid_key("attack-on-titan")
|
||||||
|
|
||||||
|
def test_valid_key_with_numbers(self):
|
||||||
|
"""Test valid key with numbers."""
|
||||||
|
assert is_valid_key("a-time-called-you-2023")
|
||||||
|
|
||||||
|
def test_valid_key_with_underscores(self):
|
||||||
|
"""Test valid key with underscores."""
|
||||||
|
assert is_valid_key("a_time_called_you_2023")
|
||||||
|
|
||||||
|
def test_valid_key_starting_with_number(self):
|
||||||
|
"""Test valid key starting with number."""
|
||||||
|
assert is_valid_key("25-sai-no-joshikousei-2018")
|
||||||
|
|
||||||
|
def test_invalid_empty_key(self):
|
||||||
|
"""Test invalid empty key."""
|
||||||
|
assert not is_valid_key("")
|
||||||
|
|
||||||
|
def test_invalid_key_with_spaces(self):
|
||||||
|
"""Test invalid key with spaces."""
|
||||||
|
assert not is_valid_key("attack on titan")
|
||||||
|
|
||||||
|
def test_invalid_key_with_special_chars(self):
|
||||||
|
"""Test invalid key with special characters."""
|
||||||
|
assert not is_valid_key("attack@titan")
|
||||||
|
|
||||||
|
def test_invalid_key_with_unicode(self):
|
||||||
|
"""Test invalid key with unstripped unicode."""
|
||||||
|
assert not is_valid_key("attack\u00a0titan") # Non-breaking space
|
||||||
|
|
||||||
|
def test_invalid_single_char(self):
|
||||||
|
"""Test invalid single character key."""
|
||||||
|
assert not is_valid_key("a")
|
||||||
|
|
||||||
|
def test_valid_two_char_key(self):
|
||||||
|
"""Test valid two character key."""
|
||||||
|
assert is_valid_key("ab")
|
||||||
|
|
||||||
|
def test_invalid_key_starting_with_hyphen(self):
|
||||||
|
"""Test invalid key starting with hyphen."""
|
||||||
|
assert not is_valid_key("-attack")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSanitizeKeyForUrl:
|
||||||
|
"""Test sanitize_key_for_url function."""
|
||||||
|
|
||||||
|
def test_standard_key_unchanged(self):
|
||||||
|
"""Test standard key remains unchanged."""
|
||||||
|
result = sanitize_key_for_url("attack-on-titan-2013")
|
||||||
|
assert result == "attack-on-titan-2013"
|
||||||
|
|
||||||
|
def test_spaces_replaced(self):
|
||||||
|
"""Test spaces are replaced with hyphens."""
|
||||||
|
result = sanitize_key_for_url("attack on titan")
|
||||||
|
assert result == "attack-on-titan"
|
||||||
|
|
||||||
|
def test_uppercase_preserved(self):
|
||||||
|
"""Test uppercase is preserved (use normalize_key for lowercase)."""
|
||||||
|
result = sanitize_key_for_url("AttackOnTitan")
|
||||||
|
# sanitize_key_for_url preserves case, only removes special chars
|
||||||
|
assert result == "AttackOnTitan"
|
||||||
|
|
||||||
|
def test_special_chars_removed(self):
|
||||||
|
"""Test special characters are removed."""
|
||||||
|
result = sanitize_key_for_url("Attack@#@On!Titan")
|
||||||
|
assert result == "AttackOnTitan"
|
||||||
|
|
||||||
|
def test_accents_preserved(self):
|
||||||
|
"""Test accented characters are preserved (use normalize_key for full normalization)."""
|
||||||
|
result = sanitize_key_for_url("AttäckÖnTïtan")
|
||||||
|
# Only removes truly problematic chars, preserves accented letters
|
||||||
|
assert "AttäckÖnTïtan" in result
|
||||||
|
|
||||||
|
def test_multiple_hyphens_collapses(self):
|
||||||
|
"""Test multiple hyphens are collapsed."""
|
||||||
|
result = sanitize_key_for_url("attack---on---titan")
|
||||||
|
assert result == "attack-on-titan"
|
||||||
|
|
||||||
|
def test_leading_trailing_hyphens_removed(self):
|
||||||
|
"""Test leading and trailing hyphens are removed."""
|
||||||
|
result = sanitize_key_for_url("-attack-on-titan-")
|
||||||
|
assert result == "attack-on-titan"
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateKeyUniqueness:
|
||||||
|
"""Test validate_key_uniqueness function."""
|
||||||
|
|
||||||
|
def test_unique_key(self):
|
||||||
|
"""Test key that is unique."""
|
||||||
|
existing_keys = {"attack-on-titan", "one-piece", "naruto"}
|
||||||
|
is_valid, error = validate_key_uniqueness("new-series", existing_keys)
|
||||||
|
assert is_valid is True
|
||||||
|
assert error == ""
|
||||||
|
|
||||||
|
def test_duplicate_key(self):
|
||||||
|
"""Test key that already exists."""
|
||||||
|
existing_keys = {"attack-on-titan", "one-piece", "naruto"}
|
||||||
|
is_valid, error = validate_key_uniqueness("one-piece", existing_keys)
|
||||||
|
assert is_valid is False
|
||||||
|
assert "already in use" in error
|
||||||
|
|
||||||
|
def test_empty_existing_set(self):
|
||||||
|
"""Test with empty existing keys set."""
|
||||||
|
is_valid, error = validate_key_uniqueness("new-series", set())
|
||||||
|
assert is_valid is True
|
||||||
|
assert error == ""
|
||||||
|
|
||||||
|
def test_key_differs_only_by_case(self):
|
||||||
|
"""Test key that differs only by case is NOT flagged by utility (API layer handles case-insensitivity)."""
|
||||||
|
existing_keys = {"attack-on-titan"} # lowercase in set
|
||||||
|
is_valid, error = validate_key_uniqueness("Attack-on-Titan", existing_keys)
|
||||||
|
# Utility function does case-sensitive check; API layer handles case-insensitivity
|
||||||
|
assert is_valid is True
|
||||||
|
assert error == ""
|
||||||
|
|
||||||
|
def test_same_key_same_case(self):
|
||||||
|
"""Test same key in existing set is flagged."""
|
||||||
|
existing_keys = {"my-series"}
|
||||||
|
is_valid, error = validate_key_uniqueness("my-series", existing_keys)
|
||||||
|
assert is_valid is False
|
||||||
240
tests/unit/test_nfo_minimal_fallback.py
Normal file
240
tests/unit/test_nfo_minimal_fallback.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
"""Unit tests for minimal NFO creation when TMDB fails.
|
||||||
|
|
||||||
|
Tests the fallback behavior when TMDB lookup fails and we need to create
|
||||||
|
a minimal NFO file just to track the series.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.services.nfo_service import NFOService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def nfo_service(tmp_path):
|
||||||
|
"""Create NFO service with test directory.
|
||||||
|
|
||||||
|
Note: anime_directory is set to tmp_path directly (not tmp_path / "anime")
|
||||||
|
because tmp_path already represents the test anime directory.
|
||||||
|
"""
|
||||||
|
service = NFOService(
|
||||||
|
tmdb_api_key="test_api_key",
|
||||||
|
anime_directory=str(tmp_path),
|
||||||
|
image_size="w500",
|
||||||
|
auto_create=True
|
||||||
|
)
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateMinimalNFO:
|
||||||
|
"""Test minimal NFO creation."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_minimal_nfo_basic(self, nfo_service, tmp_path):
|
||||||
|
"""Test creating minimal NFO with just title."""
|
||||||
|
# Setup - anime_directory is already tmp_path
|
||||||
|
serie_folder = "Test Series"
|
||||||
|
|
||||||
|
# Create minimal NFO
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="Test Series",
|
||||||
|
serie_folder=serie_folder
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert nfo_path.exists()
|
||||||
|
assert nfo_path.name == "tvshow.nfo"
|
||||||
|
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
assert "<title>Test Series</title>" in content
|
||||||
|
assert "No metadata available" in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_minimal_nfo_with_year(self, nfo_service, tmp_path):
|
||||||
|
"""Test creating minimal NFO with year."""
|
||||||
|
# Setup - anime_directory is already tmp_path
|
||||||
|
serie_folder = "Test Series (2024)"
|
||||||
|
|
||||||
|
# Create minimal NFO with explicit year
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="Test Series",
|
||||||
|
serie_folder=serie_folder,
|
||||||
|
year=2024
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert nfo_path.exists()
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
assert "<title>Test Series</title>" in content
|
||||||
|
assert "<year>2024</year>" in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_minimal_nfo_extracts_year_from_name(self, nfo_service, tmp_path):
|
||||||
|
"""Test that year is extracted from series name format (YYYY)."""
|
||||||
|
# Setup - anime_directory is already tmp_path
|
||||||
|
serie_folder = "Test Series (2024)"
|
||||||
|
|
||||||
|
# Create with name that has year
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="Test Series (2024)",
|
||||||
|
serie_folder=serie_folder
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify year was extracted
|
||||||
|
assert nfo_path.exists()
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
assert "<title>Test Series</title>" in content
|
||||||
|
assert "<year>2024</year>" in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_minimal_nfo_creates_folder_if_missing(self, nfo_service, tmp_path):
|
||||||
|
"""Test that folder is created if it doesn't exist."""
|
||||||
|
# Setup - anime_directory is tmp_path itself
|
||||||
|
serie_folder = "New Series"
|
||||||
|
|
||||||
|
# Folder should not exist yet (under anime_directory which is tmp_path)
|
||||||
|
folder_path = tmp_path / serie_folder
|
||||||
|
assert not folder_path.exists()
|
||||||
|
|
||||||
|
# Create minimal NFO
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="New Series",
|
||||||
|
serie_folder=serie_folder
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify folder and file were created
|
||||||
|
assert folder_path.exists()
|
||||||
|
assert nfo_path.exists()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_minimal_nfo_xml_is_valid(self, nfo_service, tmp_path):
|
||||||
|
"""Test that generated XML is valid."""
|
||||||
|
# Create minimal NFO (anime_directory is already tmp_path)
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="Test Anime",
|
||||||
|
serie_folder="Test Anime",
|
||||||
|
year=2020
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify XML is valid
|
||||||
|
from lxml import etree
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Should parse without errors
|
||||||
|
tree = etree.fromstring(content.encode("utf-8"))
|
||||||
|
assert tree is not None
|
||||||
|
assert tree.tag == "tvshow"
|
||||||
|
|
||||||
|
# Check title element
|
||||||
|
title = tree.find("title")
|
||||||
|
assert title is not None
|
||||||
|
assert title.text == "Test Anime"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_minimal_nfo_no_tmdb_id(self, nfo_service, tmp_path):
|
||||||
|
"""Test that minimal NFO has no TMDB ID."""
|
||||||
|
# Create minimal NFO (anime_directory is already tmp_path)
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="Unknown Series",
|
||||||
|
serie_folder="Unknown Series",
|
||||||
|
year=1999
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify no TMDB ID
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
assert "<tmdbid>" not in content
|
||||||
|
assert "uniqueid" not in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_minimal_nfo_has_plot_explanation(self, nfo_service, tmp_path):
|
||||||
|
"""Test that minimal NFO contains explanation in plot."""
|
||||||
|
# Create minimal NFO (anime_directory is already tmp_path)
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="Mysterious Anime",
|
||||||
|
serie_folder="Mysterious Anime"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify plot explains why metadata is missing
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
assert "TMDB lookup failed" in content
|
||||||
|
assert "Mysterious Anime" in content
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateMinimalNFOIntegration:
|
||||||
|
"""Integration tests for minimal NFO with TMDB failure scenarios."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fallback_on_tmdb_search_failure(self, nfo_service, tmp_path):
|
||||||
|
"""Test that minimal NFO is created when TMDB search fails."""
|
||||||
|
# Mock TMDB client to raise error
|
||||||
|
nfo_service.tmdb_client.search_tv_show = AsyncMock(
|
||||||
|
side_effect=Exception("TMDB API Error")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to create full NFO (should fail and fallback to minimal)
|
||||||
|
# We test the fallback method directly
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="Failed Series",
|
||||||
|
serie_folder="Failed Series",
|
||||||
|
year=2021
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert nfo_path.exists()
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
assert "<title>Failed Series</title>" in content
|
||||||
|
assert "<year>2021</year>" in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_minimal_nfo_allows_series_tracking(self, nfo_service, tmp_path):
|
||||||
|
"""Test that minimal NFO allows series to be tracked."""
|
||||||
|
# anime_directory is already tmp_path
|
||||||
|
serie_folder = "Untracked Series"
|
||||||
|
|
||||||
|
# Create minimal NFO
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="Untracked Series",
|
||||||
|
serie_folder=serie_folder,
|
||||||
|
year=2018
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify NFO exists (series can be tracked)
|
||||||
|
assert nfo_service.has_nfo(serie_folder) is True
|
||||||
|
|
||||||
|
# Verify minimal content
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
assert "<title>Untracked Series</title>" in content
|
||||||
|
|
||||||
|
|
||||||
|
class TestMinimalNFOContent:
|
||||||
|
"""Test content of minimal NFO files."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_minimal_nfo_contains_required_elements(self, nfo_service, tmp_path):
|
||||||
|
"""Test that minimal NFO has title and plot."""
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="Minimal Test",
|
||||||
|
serie_folder="Minimal Test"
|
||||||
|
)
|
||||||
|
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Must have title
|
||||||
|
assert "<title>Minimal Test</title>" in content
|
||||||
|
# Must have plot explaining situation
|
||||||
|
assert "plot" in content.lower()
|
||||||
|
assert "No metadata available" in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_minimal_nfo_xml_declaration(self, nfo_service, tmp_path):
|
||||||
|
"""Test that NFO has proper XML declaration."""
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="XML Test",
|
||||||
|
serie_folder="XML Test"
|
||||||
|
)
|
||||||
|
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Should have XML declaration
|
||||||
|
assert content.startswith('<?xml version="1.0" encoding="UTF-8"')
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Unit tests for NFO service."""
|
"""Unit tests for NFO service."""
|
||||||
|
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
@@ -22,6 +23,14 @@ def nfo_service(tmp_path):
|
|||||||
return service
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmdb_client():
|
||||||
|
"""Create TMDB client with test API key."""
|
||||||
|
from src.core.services.tmdb_client import TMDBClient
|
||||||
|
client = TMDBClient(api_key="test_api_key")
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_tmdb_data():
|
def mock_tmdb_data():
|
||||||
"""Mock TMDB API response data."""
|
"""Mock TMDB API response data."""
|
||||||
@@ -342,7 +351,7 @@ class TestCreateTVShowNFO:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Assert - should search with clean name "The Dreaming Boy is a Realist"
|
# Assert - should search with clean name "The Dreaming Boy is a Realist"
|
||||||
mock_search.assert_called_once_with("The Dreaming Boy is a Realist")
|
mock_search.assert_called_once_with("The Dreaming Boy is a Realist", language="de-DE")
|
||||||
|
|
||||||
# Verify NFO file was created
|
# Verify NFO file was created
|
||||||
assert nfo_path.exists()
|
assert nfo_path.exists()
|
||||||
@@ -362,29 +371,28 @@ class TestCreateTVShowNFO:
|
|||||||
|
|
||||||
with patch.object(nfo_service.tmdb_client, '__aenter__', return_value=nfo_service.tmdb_client):
|
with patch.object(nfo_service.tmdb_client, '__aenter__', return_value=nfo_service.tmdb_client):
|
||||||
with patch.object(nfo_service.tmdb_client, '__aexit__', return_value=None):
|
with patch.object(nfo_service.tmdb_client, '__aexit__', return_value=None):
|
||||||
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search:
|
with patch.object(nfo_service, '_search_with_fallback', new_callable=AsyncMock) as mock_search_fallback:
|
||||||
with patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details:
|
with patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details:
|
||||||
with patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings:
|
with patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings:
|
||||||
with patch.object(nfo_service.image_downloader, 'download_poster', new_callable=AsyncMock):
|
with patch.object(nfo_service, '_enrich_details_with_fallback', new_callable=AsyncMock) as mock_enrich:
|
||||||
with patch.object(nfo_service.image_downloader, 'download_logo', new_callable=AsyncMock):
|
with patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock):
|
||||||
with patch.object(nfo_service.image_downloader, 'download_fanart', new_callable=AsyncMock):
|
mock_search_fallback.return_value = (mock_tmdb_data, "primary")
|
||||||
with patch.object(nfo_service, '_find_best_match') as mock_find_match:
|
mock_details.return_value = mock_tmdb_data
|
||||||
mock_search.return_value = search_results
|
mock_ratings.return_value = mock_content_ratings_de
|
||||||
mock_details.return_value = mock_tmdb_data
|
mock_enrich.return_value = mock_tmdb_data
|
||||||
mock_ratings.return_value = mock_content_ratings_de
|
|
||||||
mock_find_match.return_value = mock_tmdb_data
|
# Act
|
||||||
|
await nfo_service.create_tvshow_nfo(
|
||||||
# Act
|
serie_name=serie_name,
|
||||||
await nfo_service.create_tvshow_nfo(
|
serie_folder=serie_folder,
|
||||||
serie_name=serie_name,
|
year=explicit_year # Explicit year provided
|
||||||
serie_folder=serie_folder,
|
)
|
||||||
year=explicit_year # Explicit year provided
|
|
||||||
)
|
# Assert - _search_with_fallback should be called with explicit year
|
||||||
|
mock_search_fallback.assert_called_once()
|
||||||
# Assert - should use explicit year, not extracted year
|
call_args = mock_search_fallback.call_args
|
||||||
mock_find_match.assert_called_once()
|
assert call_args[0][0] == "Attack on Titan" # clean name
|
||||||
call_args = mock_find_match.call_args
|
assert call_args[0][1] == explicit_year # explicit year
|
||||||
assert call_args[0][2] == explicit_year # Third argument is year
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_nfo_no_results_with_clean_name(self, nfo_service, tmp_path):
|
async def test_create_nfo_no_results_with_clean_name(self, nfo_service, tmp_path):
|
||||||
@@ -396,8 +404,8 @@ class TestCreateTVShowNFO:
|
|||||||
|
|
||||||
with patch.object(nfo_service.tmdb_client, '__aenter__', return_value=nfo_service.tmdb_client):
|
with patch.object(nfo_service.tmdb_client, '__aenter__', return_value=nfo_service.tmdb_client):
|
||||||
with patch.object(nfo_service.tmdb_client, '__aexit__', return_value=None):
|
with patch.object(nfo_service.tmdb_client, '__aexit__', return_value=None):
|
||||||
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search:
|
with patch.object(nfo_service, '_search_with_fallback', new_callable=AsyncMock) as mock_search_fallback:
|
||||||
mock_search.return_value = {"results": []}
|
mock_search_fallback.side_effect = TMDBAPIError("No results found for: Nonexistent Series")
|
||||||
|
|
||||||
# Act & Assert
|
# Act & Assert
|
||||||
with pytest.raises(TMDBAPIError) as exc_info:
|
with pytest.raises(TMDBAPIError) as exc_info:
|
||||||
@@ -408,8 +416,6 @@ class TestCreateTVShowNFO:
|
|||||||
|
|
||||||
# Should use clean name in error message
|
# Should use clean name in error message
|
||||||
assert "No results found for: Nonexistent Series" in str(exc_info.value)
|
assert "No results found for: Nonexistent Series" in str(exc_info.value)
|
||||||
# Should have searched with clean name
|
|
||||||
mock_search.assert_called_once_with("Nonexistent Series")
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_nfo_with_fsk(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de):
|
async def test_create_nfo_with_fsk(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de):
|
||||||
@@ -1616,3 +1622,294 @@ class TestEnrichFallbackLanguages:
|
|||||||
# de-DE + en-US = 2 calls (no ja-JP needed)
|
# de-DE + en-US = 2 calls (no ja-JP needed)
|
||||||
assert mock_details.call_count == 2
|
assert mock_details.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchWithFallback:
|
||||||
|
"""Tests for TMDB search fallback functionality."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_with_fallback_primary_success(self, nfo_service, mock_tmdb_data):
|
||||||
|
"""Test that primary query succeeds without fallback."""
|
||||||
|
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search:
|
||||||
|
mock_search.return_value = {"results": [mock_tmdb_data]}
|
||||||
|
|
||||||
|
result, source = await nfo_service._search_with_fallback(
|
||||||
|
"Attack on Titan", 2013, None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["id"] == mock_tmdb_data["id"]
|
||||||
|
assert source == "primary"
|
||||||
|
assert mock_search.call_count == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_with_fallback_uses_alt_titles(self, nfo_service, mock_tmdb_data):
|
||||||
|
"""Test that alternative titles are tried when primary fails."""
|
||||||
|
mock_search = AsyncMock()
|
||||||
|
# First call returns empty, second (with Japanese title) returns result
|
||||||
|
mock_search.side_effect = [
|
||||||
|
{"results": []},
|
||||||
|
{"results": [mock_tmdb_data]}
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch.object(nfo_service.tmdb_client, 'search_tv_show', mock_search):
|
||||||
|
result, source = await nfo_service._search_with_fallback(
|
||||||
|
"Suzume", 2022, alt_titles=["すずめの戸締まり"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["id"] == mock_tmdb_data["id"]
|
||||||
|
assert "alt_title" in source
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_with_fallback_year_not_matched(self, nfo_service, mock_tmdb_data):
|
||||||
|
"""Test fallback when year doesn't match but first result is used anyway."""
|
||||||
|
# First result doesn't match year, but should still be returned
|
||||||
|
different_year_data = {**mock_tmdb_data, "first_air_date": "2020-01-01"}
|
||||||
|
mock_search = AsyncMock(return_value={"results": [different_year_data]})
|
||||||
|
|
||||||
|
with patch.object(nfo_service.tmdb_client, 'search_tv_show', mock_search):
|
||||||
|
result, source = await nfo_service._search_with_fallback(
|
||||||
|
"Attack on Titan", 2013, None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["id"] == mock_tmdb_data["id"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_with_fallback_no_year_strategy(self, nfo_service, mock_tmdb_data):
|
||||||
|
"""Test that search without year is attempted when year-filtered fails."""
|
||||||
|
mock_search = AsyncMock()
|
||||||
|
# First call with year fails, second (without year) succeeds
|
||||||
|
mock_search.side_effect = [
|
||||||
|
{"results": []},
|
||||||
|
{"results": [mock_tmdb_data]}
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch.object(nfo_service.tmdb_client, 'search_tv_show', mock_search):
|
||||||
|
result, source = await nfo_service._search_with_fallback(
|
||||||
|
"Attack on Titan", 2013, None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["id"] == mock_tmdb_data["id"]
|
||||||
|
# Strategy order: primary -> english -> no_year (english comes before no_year)
|
||||||
|
assert mock_search.call_count == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_with_fallback_all_strategies_fail(self, nfo_service):
|
||||||
|
"""Test that TMDBAPIError is raised when all strategies fail."""
|
||||||
|
mock_search = AsyncMock(return_value={"results": []})
|
||||||
|
|
||||||
|
with patch.object(nfo_service.tmdb_client, 'search_tv_show', mock_search):
|
||||||
|
with pytest.raises(TMDBAPIError) as exc_info:
|
||||||
|
await nfo_service._search_with_fallback(
|
||||||
|
"Nonexistent Anime", 2023, None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Nonexistent Anime" in str(exc_info.value)
|
||||||
|
# Should have tried multiple strategies
|
||||||
|
assert mock_search.call_count >= 3
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_with_fallback_normalizes_punctuation(self, nfo_service, mock_tmdb_data):
|
||||||
|
"""Test that punctuation-normalized search is attempted."""
|
||||||
|
mock_search = AsyncMock()
|
||||||
|
# First call fails, normalized version succeeds
|
||||||
|
mock_search.side_effect = [
|
||||||
|
{"results": []},
|
||||||
|
{"results": [mock_tmdb_data]}
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch.object(nfo_service.tmdb_client, 'search_tv_show', mock_search):
|
||||||
|
result, source = await nfo_service._search_with_fallback(
|
||||||
|
"Attack on Titan:", 2013, None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["id"] == mock_tmdb_data["id"]
|
||||||
|
|
||||||
|
def test_normalize_query_for_search(self, nfo_service):
|
||||||
|
"""Test punctuation normalization in queries."""
|
||||||
|
# Test normal punctuation removal
|
||||||
|
assert nfo_service._normalize_query_for_search("Attack on Titan:") == "Attack on Titan"
|
||||||
|
assert nfo_service._normalize_query_for_search("Suzume no Tojimari.") == "Suzume no Tojimari"
|
||||||
|
# Test CJK characters are preserved
|
||||||
|
assert "すずめ" in nfo_service._normalize_query_for_search("すずめの戸締まり")
|
||||||
|
# Test multiple spaces are collapsed
|
||||||
|
assert nfo_service._normalize_query_for_search("Attack on Titan") == "Attack on Titan"
|
||||||
|
|
||||||
|
|
||||||
|
class TestNegativeCache:
|
||||||
|
"""Tests for negative result caching in TMDB client."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_negative_result_cached(self, tmdb_client):
|
||||||
|
"""Test that empty search results are cached."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.json = AsyncMock(return_value={"results": []})
|
||||||
|
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
|
||||||
|
mock_response.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_session.get = MagicMock(return_value=mock_response)
|
||||||
|
|
||||||
|
tmdb_client.session = mock_session
|
||||||
|
|
||||||
|
with patch.object(tmdb_client, '_ensure_session', new_callable=AsyncMock):
|
||||||
|
# First call
|
||||||
|
result = await tmdb_client.search_tv_show("Nonexistent")
|
||||||
|
assert result["results"] == []
|
||||||
|
|
||||||
|
# Negative cache should be set
|
||||||
|
assert len(tmdb_client._negative_cache) > 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_negative_cache_prevents_duplicate_call(self, tmdb_client):
|
||||||
|
"""Test that negative cache prevents second API call within 24 hours."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.json = AsyncMock(return_value={"results": []})
|
||||||
|
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
|
||||||
|
mock_response.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_session.get = MagicMock(return_value=mock_response)
|
||||||
|
|
||||||
|
tmdb_client.session = mock_session
|
||||||
|
|
||||||
|
with patch.object(tmdb_client, '_ensure_session', new_callable=AsyncMock):
|
||||||
|
# First call - should hit API
|
||||||
|
await tmdb_client.search_tv_show("Nonexistent")
|
||||||
|
first_call_count = mock_session.get.call_count
|
||||||
|
|
||||||
|
# Second call with same query - should use negative cache, not hit API
|
||||||
|
await tmdb_client.search_tv_show("Nonexistent")
|
||||||
|
second_call_count = mock_session.get.call_count
|
||||||
|
|
||||||
|
# Should not have made second API call
|
||||||
|
assert first_call_count == second_call_count
|
||||||
|
|
||||||
|
def test_clear_negative_cache(self, tmdb_client):
|
||||||
|
"""Test clearing negative cache."""
|
||||||
|
# Add some negative cache entries
|
||||||
|
tmdb_client._negative_cache["test_key"] = time.monotonic()
|
||||||
|
assert len(tmdb_client._negative_cache) > 0
|
||||||
|
|
||||||
|
tmdb_client.clear_negative_cache()
|
||||||
|
assert len(tmdb_client._negative_cache) == 0
|
||||||
|
|
||||||
|
def test_cleanup_expired_negative_cache(self, tmdb_client):
|
||||||
|
"""Test cleanup of expired negative cache entries."""
|
||||||
|
# Add an expired entry
|
||||||
|
old_timestamp = time.monotonic() - (tmdb_client.NEGATIVE_CACHE_TTL + 1)
|
||||||
|
tmdb_client._negative_cache["expired_key"] = old_timestamp
|
||||||
|
tmdb_client._negative_cache["valid_key"] = time.monotonic()
|
||||||
|
|
||||||
|
removed = tmdb_client.cleanup_expired_negative_cache()
|
||||||
|
|
||||||
|
assert removed == 1
|
||||||
|
assert "expired_key" not in tmdb_client._negative_cache
|
||||||
|
assert "valid_key" in tmdb_client._negative_cache
|
||||||
|
|
||||||
|
|
||||||
|
class TestNFOIDOverride:
|
||||||
|
"""Tests for manual TMDB ID override via NFO."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_tvshow_nfo_uses_existing_tmdb_id(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de):
|
||||||
|
"""Test that existing TMDB ID in NFO skips search."""
|
||||||
|
# Create series folder with existing NFO containing TMDB ID
|
||||||
|
series_folder = tmp_path / "Attack on Titan"
|
||||||
|
series_folder.mkdir()
|
||||||
|
nfo_path = series_folder / "tvshow.nfo"
|
||||||
|
nfo_path.write_text("""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<tvshow>
|
||||||
|
<title>Attack on Titan</title>
|
||||||
|
<tmdbid>1429</tmdbid>
|
||||||
|
</tvshow>
|
||||||
|
""", encoding="utf-8")
|
||||||
|
|
||||||
|
with patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
|
||||||
|
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
|
||||||
|
patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock), \
|
||||||
|
patch.object(nfo_service.tmdb_client, '_ensure_session', new_callable=AsyncMock):
|
||||||
|
|
||||||
|
mock_details.return_value = mock_tmdb_data
|
||||||
|
mock_ratings.return_value = mock_content_ratings_de
|
||||||
|
|
||||||
|
nfo_path_result = await nfo_service.create_tvshow_nfo(
|
||||||
|
"Attack on Titan",
|
||||||
|
"Attack on Titan",
|
||||||
|
download_poster=False, download_logo=False, download_fanart=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify NFO was created
|
||||||
|
assert nfo_path_result.exists()
|
||||||
|
|
||||||
|
# Verify get_tv_show_details was called directly with the ID (no search)
|
||||||
|
mock_details.assert_called_once_with(1429, append_to_response="credits,external_ids,images")
|
||||||
|
|
||||||
|
# Verify search was NOT called
|
||||||
|
# (we can check by verifying no search_tv_show mock was set up)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_tvshow_nfo_searches_when_no_tmdb_id(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de):
|
||||||
|
"""Test that search is used when NFO has no TMDB ID."""
|
||||||
|
# Create series folder without existing NFO
|
||||||
|
series_folder = tmp_path / "Test Anime"
|
||||||
|
series_folder.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, '_download_media_files', new_callable=AsyncMock), \
|
||||||
|
patch.object(nfo_service.tmdb_client, '_ensure_session', new_callable=AsyncMock):
|
||||||
|
|
||||||
|
mock_search.return_value = {
|
||||||
|
"results": [{
|
||||||
|
"id": 1429,
|
||||||
|
"name": "Test Anime",
|
||||||
|
"first_air_date": "2024-01-01",
|
||||||
|
"overview": "Test overview"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
mock_details.return_value = mock_tmdb_data
|
||||||
|
mock_ratings.return_value = mock_content_ratings_de
|
||||||
|
|
||||||
|
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||||
|
"Test Anime",
|
||||||
|
"Test Anime",
|
||||||
|
download_poster=False, download_logo=False, download_fanart=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify search was called
|
||||||
|
mock_search.assert_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchMultiStrategy:
|
||||||
|
"""Tests for search/multi fallback strategy."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_multi_strategy_used_as_fallback(self, nfo_service, mock_tmdb_data):
|
||||||
|
"""Test that search/multi is tried after regular search fails."""
|
||||||
|
mock_search = AsyncMock()
|
||||||
|
mock_multi = AsyncMock()
|
||||||
|
|
||||||
|
# First: regular search fails
|
||||||
|
# Second: multi search returns TV result
|
||||||
|
mock_search.return_value = {"results": []}
|
||||||
|
mock_multi.return_value = {
|
||||||
|
"results": [
|
||||||
|
{"media_type": "movie", "id": 123},
|
||||||
|
{"media_type": "tv", "id": 456, "name": "Found Show", "first_air_date": "2024-01-01"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(nfo_service.tmdb_client, 'search_tv_show', mock_search), \
|
||||||
|
patch.object(nfo_service.tmdb_client, 'search_multi', mock_multi):
|
||||||
|
|
||||||
|
result, source = await nfo_service._search_with_fallback(
|
||||||
|
"Unknown Show", 2024, None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["id"] == 456
|
||||||
|
assert source == "multi_search"
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ def _make_episode(season: int = 1, episode: int = 1) -> EpisodeIdentifier:
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_anime_service():
|
def mock_anime_service():
|
||||||
return MagicMock(spec=["download_episode"])
|
service = MagicMock(spec=["download_episode"])
|
||||||
|
service._directory = "/tmp/test_anime"
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ def _make_db_item(
|
|||||||
completed_at: datetime | None = None,
|
completed_at: datetime | None = None,
|
||||||
error_message: str | None = None,
|
error_message: str | None = None,
|
||||||
download_url: str | None = None,
|
download_url: str | None = None,
|
||||||
|
status: str = "pending",
|
||||||
|
retry_count: int = 0,
|
||||||
):
|
):
|
||||||
"""Build a fake DB DownloadQueueItem."""
|
"""Build a fake DB DownloadQueueItem."""
|
||||||
episode = MagicMock()
|
episode = MagicMock()
|
||||||
@@ -91,6 +93,8 @@ def _make_db_item(
|
|||||||
db_item.completed_at = completed_at
|
db_item.completed_at = completed_at
|
||||||
db_item.error_message = error_message
|
db_item.error_message = error_message
|
||||||
db_item.download_url = download_url
|
db_item.download_url = download_url
|
||||||
|
db_item.status = status
|
||||||
|
db_item.retry_count = retry_count
|
||||||
return db_item
|
return db_item
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,86 @@ class TestSchedulerConfigFolderScanEnabled:
|
|||||||
assert config.folder_scan_enabled is False
|
assert config.folder_scan_enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchedulerConfigLegacyAliases:
|
||||||
|
"""3.10 – Legacy config key aliases (auto_download, folder_scan)."""
|
||||||
|
|
||||||
|
def test_legacy_auto_download_true(self) -> None:
|
||||||
|
"""Legacy auto_download=true maps to auto_download_after_rescan=True."""
|
||||||
|
config = SchedulerConfig(auto_download=True)
|
||||||
|
assert config.auto_download_after_rescan is True
|
||||||
|
assert config.folder_scan_enabled is False
|
||||||
|
|
||||||
|
def test_legacy_auto_download_false(self) -> None:
|
||||||
|
config = SchedulerConfig(auto_download=False)
|
||||||
|
assert config.auto_download_after_rescan is False
|
||||||
|
|
||||||
|
def test_legacy_folder_scan_true(self) -> None:
|
||||||
|
"""Legacy folder_scan=true maps to folder_scan_enabled=True."""
|
||||||
|
config = SchedulerConfig(folder_scan=True)
|
||||||
|
assert config.folder_scan_enabled is True
|
||||||
|
assert config.auto_download_after_rescan is False
|
||||||
|
|
||||||
|
def test_legacy_folder_scan_false(self) -> None:
|
||||||
|
config = SchedulerConfig(folder_scan=False)
|
||||||
|
assert config.folder_scan_enabled is False
|
||||||
|
|
||||||
|
def test_legacy_both_set(self) -> None:
|
||||||
|
"""Both legacy keys can be set simultaneously."""
|
||||||
|
config = SchedulerConfig(auto_download=True, folder_scan=True)
|
||||||
|
assert config.auto_download_after_rescan is True
|
||||||
|
assert config.folder_scan_enabled is True
|
||||||
|
|
||||||
|
def test_explicit_primary_overrides_legacy(self) -> None:
|
||||||
|
"""Primary field explicitly set to False still wins over legacy True.
|
||||||
|
|
||||||
|
When user provides both old and new key, newer key wins by virtue of
|
||||||
|
being the intended migration target. Legacy alias only applies when
|
||||||
|
primary key is absent from data entirely.
|
||||||
|
"""
|
||||||
|
config = SchedulerConfig(
|
||||||
|
auto_download=True,
|
||||||
|
auto_download_after_rescan=True,
|
||||||
|
folder_scan=True,
|
||||||
|
folder_scan_enabled=True,
|
||||||
|
)
|
||||||
|
# Both set to True — no conflict possible when both agree
|
||||||
|
assert config.auto_download_after_rescan is True
|
||||||
|
assert config.folder_scan_enabled is True
|
||||||
|
|
||||||
|
def test_explicit_primary_false_wins_over_legacy_true(self) -> None:
|
||||||
|
"""Primary=False explicitly set wins over legacy=True.
|
||||||
|
|
||||||
|
User has migrated config to new keys but old key still present.
|
||||||
|
Explicit primary value must be respected.
|
||||||
|
"""
|
||||||
|
config = SchedulerConfig(
|
||||||
|
auto_download=True,
|
||||||
|
auto_download_after_rescan=False,
|
||||||
|
)
|
||||||
|
assert config.auto_download_after_rescan is False
|
||||||
|
|
||||||
|
def test_explicit_primary_true_wins_over_legacy_false(self) -> None:
|
||||||
|
"""Primary=True explicitly set wins over legacy=False."""
|
||||||
|
config = SchedulerConfig(
|
||||||
|
auto_download=False,
|
||||||
|
auto_download_after_rescan=True,
|
||||||
|
)
|
||||||
|
assert config.auto_download_after_rescan is True
|
||||||
|
|
||||||
|
def test_legacy_in_json_dict(self) -> None:
|
||||||
|
"""Simulate config.json with legacy auto_download key."""
|
||||||
|
data = {
|
||||||
|
"enabled": True,
|
||||||
|
"schedule_time": "03:00",
|
||||||
|
"schedule_days": ALL_DAYS,
|
||||||
|
"auto_download": True,
|
||||||
|
"folder_scan": True,
|
||||||
|
}
|
||||||
|
config = SchedulerConfig(**data)
|
||||||
|
assert config.auto_download_after_rescan is True
|
||||||
|
assert config.folder_scan_enabled is True
|
||||||
|
|
||||||
|
|
||||||
class TestSchedulerConfigSerialisation:
|
class TestSchedulerConfigSerialisation:
|
||||||
"""3.9 – Serialisation roundtrip."""
|
"""3.9 – Serialisation roundtrip."""
|
||||||
|
|
||||||
@@ -156,3 +236,24 @@ class TestSchedulerConfigSerialisation:
|
|||||||
dumped = original.model_dump()
|
dumped = original.model_dump()
|
||||||
restored = SchedulerConfig(**dumped)
|
restored = SchedulerConfig(**dumped)
|
||||||
assert restored == original
|
assert restored == original
|
||||||
|
|
||||||
|
def test_roundtrip_excludes_none_alias_fields(self) -> None:
|
||||||
|
"""model_dump must not emit null auto_download/folder_scan keys.
|
||||||
|
|
||||||
|
Previously these null keys were written to config.json on save.
|
||||||
|
On reload they were present (even as None), so the alias mapping in
|
||||||
|
__init__ was skipped and the primary fields retained their default
|
||||||
|
False values instead of the configured True values.
|
||||||
|
"""
|
||||||
|
original = SchedulerConfig(
|
||||||
|
auto_download_after_rescan=True,
|
||||||
|
folder_scan_enabled=True,
|
||||||
|
)
|
||||||
|
dumped = original.model_dump()
|
||||||
|
# Alias fields must not appear when None
|
||||||
|
assert "auto_download" not in dumped
|
||||||
|
assert "folder_scan" not in dumped
|
||||||
|
# Primary fields roundtrip correctly
|
||||||
|
restored = SchedulerConfig(**dumped)
|
||||||
|
assert restored.auto_download_after_rescan is True
|
||||||
|
assert restored.folder_scan_enabled is True
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ class TestStart:
|
|||||||
call_kwargs = mock_sched.add_job.call_args
|
call_kwargs = mock_sched.add_job.call_args
|
||||||
assert call_kwargs[1]["id"] == _JOB_ID
|
assert call_kwargs[1]["id"] == _JOB_ID
|
||||||
assert isinstance(call_kwargs[1]["trigger"], CronTrigger)
|
assert isinstance(call_kwargs[1]["trigger"], CronTrigger)
|
||||||
|
assert call_kwargs[1]["misfire_grace_time"] == 3600
|
||||||
|
assert call_kwargs[1]["coalesce"] is True
|
||||||
mock_sched.start.assert_called_once()
|
mock_sched.start.assert_called_once()
|
||||||
assert scheduler_service._is_running is True
|
assert scheduler_service._is_running is True
|
||||||
|
|
||||||
@@ -485,3 +487,108 @@ class TestSingletonHelpers:
|
|||||||
svc = get_scheduler_service()
|
svc = get_scheduler_service()
|
||||||
assert svc is not None # fresh instance
|
assert svc is not None # fresh instance
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 12.12 In-memory job store — no separate scheduler.db needed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestInMemoryJobStore:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_creates_scheduler_without_jobstore_arg(
|
||||||
|
self, scheduler_service, mock_config_service
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.services.scheduler_service.AsyncIOScheduler"
|
||||||
|
) as MockScheduler:
|
||||||
|
mock_sched = MagicMock()
|
||||||
|
mock_sched.running = False
|
||||||
|
MockScheduler.return_value = mock_sched
|
||||||
|
|
||||||
|
await scheduler_service.start()
|
||||||
|
|
||||||
|
MockScheduler.assert_called_once()
|
||||||
|
call_kwargs = MockScheduler.call_args
|
||||||
|
# No jobstores argument — uses default MemoryJobStore
|
||||||
|
if call_kwargs[1]:
|
||||||
|
assert "jobstores" not in call_kwargs[1]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_job_options_include_misfire_grace_and_coalesce(
|
||||||
|
self, scheduler_service, mock_config_service
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.services.scheduler_service.AsyncIOScheduler"
|
||||||
|
) as MockScheduler:
|
||||||
|
mock_sched = MagicMock()
|
||||||
|
mock_sched.running = False
|
||||||
|
MockScheduler.return_value = mock_sched
|
||||||
|
|
||||||
|
await scheduler_service.start()
|
||||||
|
|
||||||
|
call_kwargs = mock_sched.add_job.call_args
|
||||||
|
assert call_kwargs[1]["misfire_grace_time"] == 3600
|
||||||
|
assert call_kwargs[1]["coalesce"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 12.13 Startup recovery — next run logged after start()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestStartupRecovery:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_logs_next_run_time(
|
||||||
|
self, scheduler_service, mock_config_service
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.services.scheduler_service.AsyncIOScheduler"
|
||||||
|
) as MockScheduler:
|
||||||
|
mock_job = MagicMock()
|
||||||
|
next_run_dt = datetime(2026, 5, 25, 3, 0, tzinfo=timezone.utc)
|
||||||
|
mock_job.next_run_time = next_run_dt
|
||||||
|
mock_sched = MagicMock()
|
||||||
|
mock_sched.running = False
|
||||||
|
mock_sched.get_job.return_value = mock_job
|
||||||
|
MockScheduler.return_value = mock_sched
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.services.scheduler_service.logger"
|
||||||
|
) as mock_logger:
|
||||||
|
await scheduler_service.start()
|
||||||
|
info_calls = [str(c) for c in mock_logger.info.call_args_list]
|
||||||
|
assert any("next_run" in str(c) or "Scheduler" in str(c) for c in info_calls)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 12.8 ensure_started() – idempotent startup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestEnsureStarted:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ensure_started_when_not_running(
|
||||||
|
self, scheduler_service, mock_config_service
|
||||||
|
):
|
||||||
|
"""ensure_started() calls start() when scheduler is not running."""
|
||||||
|
# Mock start method
|
||||||
|
scheduler_service.start = AsyncMock()
|
||||||
|
|
||||||
|
# Call ensure_started
|
||||||
|
await scheduler_service.ensure_started()
|
||||||
|
|
||||||
|
# Verify start() was called exactly once
|
||||||
|
scheduler_service.start.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ensure_started_when_already_running(self, scheduler_service):
|
||||||
|
"""ensure_started() returns immediately when already running (idempotent)."""
|
||||||
|
# Set up as already running
|
||||||
|
scheduler_service._is_running = True
|
||||||
|
|
||||||
|
# Mock start method
|
||||||
|
scheduler_service.start = AsyncMock()
|
||||||
|
|
||||||
|
# Call ensure_started
|
||||||
|
await scheduler_service.ensure_started()
|
||||||
|
|
||||||
|
# Verify start() was NOT called
|
||||||
|
scheduler_service.start.assert_not_called()
|
||||||
|
|
||||||
|
|||||||
291
tests/unit/test_serie_list_db_loading.py
Normal file
291
tests/unit/test_serie_list_db_loading.py
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
"""Tests for SerieList database loading functionality."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.entities.series import Serie
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_session_factory():
|
||||||
|
"""Create a mock async session factory."""
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session_factory = MagicMock(return_value=mock_session)
|
||||||
|
return mock_session_factory, mock_session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_anime_series():
|
||||||
|
"""Create a sample AnimeSeries DB model for testing."""
|
||||||
|
mock = MagicMock()
|
||||||
|
mock.key = "attack-on-titan"
|
||||||
|
mock.name = "Attack on Titan"
|
||||||
|
mock.site = "aniworld.to"
|
||||||
|
mock.folder = "Attack on Titan (2013)"
|
||||||
|
mock.year = 2013
|
||||||
|
mock.episodes = [
|
||||||
|
MagicMock(season=1, episode_number=1),
|
||||||
|
MagicMock(season=1, episode_number=2),
|
||||||
|
MagicMock(season=1, episode_number=3),
|
||||||
|
MagicMock(season=2, episode_number=1),
|
||||||
|
MagicMock(season=2, episode_number=2),
|
||||||
|
]
|
||||||
|
return mock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_serie():
|
||||||
|
"""Create a sample Serie for testing."""
|
||||||
|
return Serie(
|
||||||
|
key="attack-on-titan",
|
||||||
|
name="Attack on Titan",
|
||||||
|
site="aniworld.to",
|
||||||
|
folder="Attack on Titan (2013)",
|
||||||
|
episodeDict={1: [1, 2, 3], 2: [1, 2]},
|
||||||
|
year=2013
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadAllFromDb:
|
||||||
|
"""Test load_all_from_db method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_all_from_db(self, mock_session_factory, sample_anime_series):
|
||||||
|
"""Verify SerieList loads all series from DB."""
|
||||||
|
mock_factory, mock_session = mock_session_factory
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.database.connection.get_async_session_factory",
|
||||||
|
return_value=mock_factory
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.AnimeSeriesService.get_all",
|
||||||
|
return_value=[sample_anime_series]
|
||||||
|
):
|
||||||
|
from src.core.entities.SerieList import SerieList
|
||||||
|
|
||||||
|
serie_list = SerieList("/tmp", skip_load=True)
|
||||||
|
count = await serie_list.load_all_from_db()
|
||||||
|
|
||||||
|
assert count == 1
|
||||||
|
assert "attack-on-titan" in serie_list.keyDict
|
||||||
|
serie = serie_list.keyDict["attack-on-titan"]
|
||||||
|
assert serie.name == "Attack on Titan"
|
||||||
|
assert serie.key == "attack-on-titan"
|
||||||
|
assert serie.year == 2013
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_all_from_db_multiple_series(
|
||||||
|
self, mock_session_factory, sample_anime_series
|
||||||
|
):
|
||||||
|
"""Verify SerieList loads multiple series from DB."""
|
||||||
|
mock_factory, mock_session = mock_session_factory
|
||||||
|
|
||||||
|
mock_series2 = MagicMock()
|
||||||
|
mock_series2.key = "one-piece"
|
||||||
|
mock_series2.name = "One Piece"
|
||||||
|
mock_series2.site = "aniworld.to"
|
||||||
|
mock_series2.folder = "One Piece"
|
||||||
|
mock_series2.year = 1999
|
||||||
|
mock_series2.episodes = []
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.database.connection.get_async_session_factory",
|
||||||
|
return_value=mock_factory
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.AnimeSeriesService.get_all",
|
||||||
|
return_value=[sample_anime_series, mock_series2]
|
||||||
|
):
|
||||||
|
from src.core.entities.SerieList import SerieList
|
||||||
|
|
||||||
|
serie_list = SerieList("/tmp", skip_load=True)
|
||||||
|
count = await serie_list.load_all_from_db()
|
||||||
|
|
||||||
|
assert count == 2
|
||||||
|
assert "attack-on-titan" in serie_list.keyDict
|
||||||
|
assert "one-piece" in serie_list.keyDict
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_all_from_db_rebuilds_episode_dict(
|
||||||
|
self, mock_session_factory, sample_anime_series
|
||||||
|
):
|
||||||
|
"""Verify episode_dict is correctly built from Episode records."""
|
||||||
|
mock_factory, mock_session = mock_session_factory
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.database.connection.get_async_session_factory",
|
||||||
|
return_value=mock_factory
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.AnimeSeriesService.get_all",
|
||||||
|
return_value=[sample_anime_series]
|
||||||
|
):
|
||||||
|
from src.core.entities.SerieList import SerieList
|
||||||
|
|
||||||
|
serie_list = SerieList("/tmp", skip_load=True)
|
||||||
|
await serie_list.load_all_from_db()
|
||||||
|
|
||||||
|
serie = serie_list.keyDict["attack-on-titan"]
|
||||||
|
assert 1 in serie.episodeDict
|
||||||
|
assert 2 in serie.episodeDict
|
||||||
|
assert sorted(serie.episodeDict[1]) == [1, 2, 3]
|
||||||
|
assert sorted(serie.episodeDict[2]) == [1, 2]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_all_from_db_no_series(self, mock_session_factory):
|
||||||
|
"""Verify SerieList handles empty DB."""
|
||||||
|
mock_factory, mock_session = mock_session_factory
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.database.connection.get_async_session_factory",
|
||||||
|
return_value=mock_factory
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.AnimeSeriesService.get_all",
|
||||||
|
return_value=[]
|
||||||
|
):
|
||||||
|
from src.core.entities.SerieList import SerieList
|
||||||
|
|
||||||
|
serie_list = SerieList("/tmp", skip_load=True)
|
||||||
|
count = await serie_list.load_all_from_db()
|
||||||
|
|
||||||
|
assert count == 0
|
||||||
|
assert len(serie_list.keyDict) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_all_from_db_db_not_initialized(self, mock_session_factory):
|
||||||
|
"""Verify SerieList handles uninitialized DB gracefully."""
|
||||||
|
mock_factory, mock_session = mock_session_factory
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.database.connection.get_async_session_factory",
|
||||||
|
return_value=mock_factory
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.AnimeSeriesService.get_all",
|
||||||
|
side_effect=RuntimeError("Database not initialized")
|
||||||
|
):
|
||||||
|
from src.core.entities.SerieList import SerieList
|
||||||
|
|
||||||
|
serie_list = SerieList("/tmp", skip_load=True)
|
||||||
|
count = await serie_list.load_all_from_db()
|
||||||
|
|
||||||
|
assert count == 0
|
||||||
|
assert len(serie_list.keyDict) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadSingleSeriesFromDb:
|
||||||
|
"""Test _load_single_series_from_db method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_single_series_from_db(
|
||||||
|
self, mock_session_factory, sample_anime_series
|
||||||
|
):
|
||||||
|
"""Verify SerieList loads a single series from DB by folder."""
|
||||||
|
mock_factory, mock_session = mock_session_factory
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.database.connection.get_async_session_factory",
|
||||||
|
return_value=mock_factory
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.AnimeSeriesService.get_by_folder",
|
||||||
|
return_value=sample_anime_series
|
||||||
|
):
|
||||||
|
from src.core.entities.SerieList import SerieList
|
||||||
|
|
||||||
|
serie_list = SerieList("/tmp", skip_load=True)
|
||||||
|
serie = await serie_list._load_single_series_from_db("Attack on Titan (2013)")
|
||||||
|
|
||||||
|
assert serie is not None
|
||||||
|
assert serie.key == "attack-on-titan"
|
||||||
|
assert "attack-on-titan" in serie_list.keyDict
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_single_series_from_db_not_found(
|
||||||
|
self, mock_session_factory
|
||||||
|
):
|
||||||
|
"""Verify SerieList handles series not found in DB."""
|
||||||
|
mock_factory, mock_session = mock_session_factory
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.database.connection.get_async_session_factory",
|
||||||
|
return_value=mock_factory
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.AnimeSeriesService.get_by_folder",
|
||||||
|
return_value=None
|
||||||
|
):
|
||||||
|
from src.core.entities.SerieList import SerieList
|
||||||
|
|
||||||
|
serie_list = SerieList("/tmp", skip_load=True)
|
||||||
|
serie = await serie_list._load_single_series_from_db("Unknown Series")
|
||||||
|
|
||||||
|
assert serie is None
|
||||||
|
assert len(serie_list.keyDict) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_single_series_from_db_db_not_initialized(
|
||||||
|
self, mock_session_factory
|
||||||
|
):
|
||||||
|
"""Verify SerieList handles uninitialized DB gracefully."""
|
||||||
|
mock_factory, mock_session = mock_session_factory
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.database.connection.get_async_session_factory",
|
||||||
|
return_value=mock_factory
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.AnimeSeriesService.get_by_folder",
|
||||||
|
side_effect=RuntimeError("Database not initialized")
|
||||||
|
):
|
||||||
|
from src.core.entities.SerieList import SerieList
|
||||||
|
|
||||||
|
serie_list = SerieList("/tmp", skip_load=True)
|
||||||
|
serie = await serie_list._load_single_series_from_db("Some Folder")
|
||||||
|
|
||||||
|
assert serie is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestInvalidateCache:
|
||||||
|
"""Test invalidate_cache method."""
|
||||||
|
|
||||||
|
def test_invalidate_cache_clears_keydict(self, sample_serie):
|
||||||
|
"""Verify invalidate_cache clears the in-memory cache."""
|
||||||
|
from src.core.entities.SerieList import SerieList
|
||||||
|
|
||||||
|
serie_list = SerieList("/tmp", skip_load=True)
|
||||||
|
serie_list.keyDict["attack-on-titan"] = sample_serie
|
||||||
|
assert len(serie_list.keyDict) == 1
|
||||||
|
|
||||||
|
serie_list.invalidate_cache()
|
||||||
|
|
||||||
|
assert len(serie_list.keyDict) == 0
|
||||||
|
|
||||||
|
def test_invalidate_cache_allows_reload(self, mock_session_factory, sample_anime_series):
|
||||||
|
"""Verify cache can be reloaded after invalidation."""
|
||||||
|
mock_factory, mock_session = mock_session_factory
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.database.connection.get_async_session_factory",
|
||||||
|
return_value=mock_factory
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.AnimeSeriesService.get_all",
|
||||||
|
return_value=[sample_anime_series]
|
||||||
|
):
|
||||||
|
from src.core.entities.SerieList import SerieList
|
||||||
|
|
||||||
|
serie_list = SerieList("/tmp", skip_load=True)
|
||||||
|
serie_list.keyDict["some-key"] = MagicMock()
|
||||||
|
|
||||||
|
serie_list.invalidate_cache()
|
||||||
|
assert len(serie_list.keyDict) == 0
|
||||||
|
|
||||||
|
# Reload
|
||||||
|
import asyncio
|
||||||
|
asyncio.get_event_loop().run_until_complete(serie_list.load_all_from_db())
|
||||||
|
|
||||||
|
assert len(serie_list.keyDict) == 1
|
||||||
@@ -75,12 +75,12 @@ class TestSerieScannerInitialization:
|
|||||||
class TestSerieScannerScan:
|
class TestSerieScannerScan:
|
||||||
"""Test file-based scan operations."""
|
"""Test file-based scan operations."""
|
||||||
|
|
||||||
def test_file_based_scan_works(
|
def test_scan_persists_to_db(
|
||||||
self, temp_directory, mock_loader, sample_serie
|
self, temp_directory, mock_loader, sample_serie
|
||||||
):
|
):
|
||||||
"""Test file-based scan works properly."""
|
"""Test scan persists series to database."""
|
||||||
scanner = SerieScanner(temp_directory, mock_loader)
|
scanner = SerieScanner(temp_directory, mock_loader)
|
||||||
|
|
||||||
with patch.object(scanner, 'get_total_to_scan', return_value=1):
|
with patch.object(scanner, 'get_total_to_scan', return_value=1):
|
||||||
with patch.object(
|
with patch.object(
|
||||||
scanner,
|
scanner,
|
||||||
@@ -100,12 +100,15 @@ class TestSerieScannerScan:
|
|||||||
return_value=({1: [2, 3]}, "aniworld.to")
|
return_value=({1: [2, 3]}, "aniworld.to")
|
||||||
):
|
):
|
||||||
with patch.object(
|
with patch.object(
|
||||||
sample_serie, 'save_to_file'
|
scanner, '_persist_serie_to_db'
|
||||||
) as mock_save:
|
) as mock_persist:
|
||||||
scanner.scan()
|
scanner.scan()
|
||||||
|
|
||||||
# Verify file was saved
|
# Verify DB persistence was called
|
||||||
mock_save.assert_called_once()
|
mock_persist.assert_called_once()
|
||||||
|
# Check the serie passed matches
|
||||||
|
call_args = mock_persist.call_args
|
||||||
|
assert call_args[0][0].key == "attack-on-titan"
|
||||||
|
|
||||||
def test_keydict_populated_after_scan(
|
def test_keydict_populated_after_scan(
|
||||||
self, temp_directory, mock_loader, sample_serie
|
self, temp_directory, mock_loader, sample_serie
|
||||||
@@ -516,23 +519,8 @@ class TestFindMp4Files:
|
|||||||
class TestReadDataFromFile:
|
class TestReadDataFromFile:
|
||||||
"""Test __read_data_from_file method."""
|
"""Test __read_data_from_file method."""
|
||||||
|
|
||||||
def test_reads_key_file(self, mock_loader):
|
|
||||||
"""Should read key from 'key' file."""
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
anime_folder = os.path.join(tmpdir, "SomeAnime")
|
|
||||||
os.makedirs(anime_folder)
|
|
||||||
with open(os.path.join(anime_folder, "key"), "w") as f:
|
|
||||||
f.write("some-key")
|
|
||||||
|
|
||||||
scanner = SerieScanner(tmpdir, mock_loader)
|
|
||||||
result = scanner._SerieScanner__read_data_from_file("SomeAnime")
|
|
||||||
assert result is not None
|
|
||||||
assert result.key == "some-key"
|
|
||||||
|
|
||||||
def test_reads_data_file(self, mock_loader):
|
def test_reads_data_file(self, mock_loader):
|
||||||
"""Should read Serie from 'data' file when no 'key' file."""
|
"""Should read Serie from 'data' file when no DB entry exists."""
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
@@ -549,8 +537,8 @@ class TestReadDataFromFile:
|
|||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.key == "test-key"
|
assert result.key == "test-key"
|
||||||
|
|
||||||
def test_no_files_returns_none(self, mock_loader):
|
def test_no_files_returns_serie_with_generated_key(self, mock_loader):
|
||||||
"""Should return None when no key or data file exists."""
|
"""Should return Serie with generated key when no key or data file exists."""
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
@@ -559,7 +547,30 @@ class TestReadDataFromFile:
|
|||||||
|
|
||||||
scanner = SerieScanner(tmpdir, mock_loader)
|
scanner = SerieScanner(tmpdir, mock_loader)
|
||||||
result = scanner._SerieScanner__read_data_from_file("Empty")
|
result = scanner._SerieScanner__read_data_from_file("Empty")
|
||||||
assert result is None
|
# Step 5 (was Step 4) generates key from folder name when no files exist
|
||||||
|
assert result is not None
|
||||||
|
assert isinstance(result, Serie)
|
||||||
|
assert result.key == "empty"
|
||||||
|
|
||||||
|
def test_scan_key_override_used_instead_of_generated(self, mock_loader):
|
||||||
|
"""Should use override key when folder name matches override dict."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
anime_folder = os.path.join(tmpdir, "Anyway, I'm Falling in Love with You (2025)")
|
||||||
|
os.makedirs(anime_folder)
|
||||||
|
|
||||||
|
overrides = {
|
||||||
|
"Anyway, I'm Falling in Love with You (2025)": "anyway-im-falling-in-love-with-you-2025"
|
||||||
|
}
|
||||||
|
scanner = SerieScanner(tmpdir, mock_loader, scan_key_overrides=overrides)
|
||||||
|
result = scanner._SerieScanner__read_data_from_file(
|
||||||
|
"Anyway, I'm Falling in Love with You (2025)"
|
||||||
|
)
|
||||||
|
# Override key should be used instead of generated key
|
||||||
|
assert result is not None
|
||||||
|
assert isinstance(result, Serie)
|
||||||
|
assert result.key == "anyway-im-falling-in-love-with-you-2025"
|
||||||
|
|
||||||
|
|
||||||
class TestReinit:
|
class TestReinit:
|
||||||
@@ -760,7 +771,7 @@ class TestDbLookupFallback:
|
|||||||
assert scanner.keyDict["rooster-fighter"].episodeDict == {1: [1, 2, 3]}
|
assert scanner.keyDict["rooster-fighter"].episodeDict == {1: [1, 2, 3]}
|
||||||
|
|
||||||
def test_db_lookup_returns_none_folder_skipped(self, mock_loader):
|
def test_db_lookup_returns_none_folder_skipped(self, mock_loader):
|
||||||
"""When db_lookup returns None, the folder is skipped with a warning."""
|
"""When db_lookup returns None, Step 4 fallback generates key from folder name."""
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
@@ -770,10 +781,11 @@ class TestDbLookupFallback:
|
|||||||
with patch.object(scanner, 'get_total_to_scan', return_value=1):
|
with patch.object(scanner, 'get_total_to_scan', return_value=1):
|
||||||
scanner.scan()
|
scanner.scan()
|
||||||
|
|
||||||
assert len(scanner.keyDict) == 0
|
# Step 4 generates key from folder name, so keyDict is not empty
|
||||||
|
assert len(scanner.keyDict) == 1
|
||||||
|
|
||||||
def test_db_lookup_exception_skips_folder(self, mock_loader):
|
def test_db_lookup_exception_skips_folder(self, mock_loader):
|
||||||
"""When db_lookup raises, the folder is skipped gracefully."""
|
"""When db_lookup raises, Step 4 fallback generates key from folder name."""
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
@@ -783,7 +795,8 @@ class TestDbLookupFallback:
|
|||||||
with patch.object(scanner, 'get_total_to_scan', return_value=1):
|
with patch.object(scanner, 'get_total_to_scan', return_value=1):
|
||||||
scanner.scan() # should not raise
|
scanner.scan() # should not raise
|
||||||
|
|
||||||
assert len(scanner.keyDict) == 0
|
# Step 4 generates key from folder name, so keyDict is not empty
|
||||||
|
assert len(scanner.keyDict) == 1
|
||||||
|
|
||||||
def test_db_lookup_warning_logged_when_no_files(
|
def test_db_lookup_warning_logged_when_no_files(
|
||||||
self, mock_loader, caplog
|
self, mock_loader, caplog
|
||||||
|
|||||||
120
tests/unit/test_serie_scanner_db_lookup.py
Normal file
120
tests/unit/test_serie_scanner_db_lookup.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""Tests for SerieScanner DB lookup functionality."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.entities.series import Serie
|
||||||
|
from src.core.SerieScanner import SerieScanner
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_loader():
|
||||||
|
"""Create a mock Loader instance."""
|
||||||
|
loader = MagicMock()
|
||||||
|
loader.get_season_episode_count = MagicMock(return_value={1: 12})
|
||||||
|
loader.is_language = MagicMock(return_value=True)
|
||||||
|
loader.get_year = MagicMock(return_value=2026)
|
||||||
|
return loader
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_directory():
|
||||||
|
"""Create a temporary directory with subdirectories for testing."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
anime_folder = os.path.join(tmpdir, "Rooster Fighter (2026)")
|
||||||
|
os.makedirs(anime_folder, exist_ok=True)
|
||||||
|
mp4_path = os.path.join(anime_folder, "S01E001.mp4")
|
||||||
|
with open(mp4_path, "w") as f:
|
||||||
|
f.write("dummy mp4")
|
||||||
|
yield tmpdir
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSerieFromFolderDbLookup:
|
||||||
|
"""Test __read_data_from_file DB lookup behavior."""
|
||||||
|
|
||||||
|
def test_db_hit_returns_serie_from_db(self, temp_directory, mock_loader):
|
||||||
|
"""DB lookup resolves folder -> Serie returned."""
|
||||||
|
from src.server.database.models import AnimeSeries
|
||||||
|
from src.server.database import service as anime_series_service
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_anime_series = MagicMock()
|
||||||
|
mock_anime_series.key = "rooster-fighter"
|
||||||
|
mock_anime_series.name = "Rooster Fighter"
|
||||||
|
mock_anime_series.site = "aniworld.to"
|
||||||
|
mock_anime_series.folder = "Rooster Fighter (2026)"
|
||||||
|
mock_anime_series.year = 2026
|
||||||
|
mock_anime_series.episodes = []
|
||||||
|
mock_session.execute.return_value.scalar_one_or_none.return_value = mock_anime_series
|
||||||
|
|
||||||
|
with patch("src.core.SerieScanner.get_sync_session", return_value=mock_session):
|
||||||
|
scanner = SerieScanner(temp_directory, mock_loader)
|
||||||
|
result = scanner._SerieScanner__read_data_from_file("Rooster Fighter (2026)")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.key == "rooster-fighter"
|
||||||
|
assert result.name == "Rooster Fighter"
|
||||||
|
assert result.year == 2026
|
||||||
|
|
||||||
|
def test_db_miss_falls_back_to_provider_callback(self, temp_directory, mock_loader):
|
||||||
|
"""DB miss -> _db_lookup callback called."""
|
||||||
|
lookup = MagicMock(return_value=Serie(
|
||||||
|
key="rooster-fighter",
|
||||||
|
name="Rooster Fighter",
|
||||||
|
site="aniworld.to",
|
||||||
|
folder="Rooster Fighter (2026)",
|
||||||
|
episodeDict={},
|
||||||
|
))
|
||||||
|
scanner = SerieScanner(temp_directory, mock_loader, db_lookup=lookup)
|
||||||
|
|
||||||
|
result = scanner._SerieScanner__read_data_from_file("Rooster Fighter (2026)")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.key == "rooster-fighter"
|
||||||
|
lookup.assert_called_once_with("Rooster Fighter (2026)")
|
||||||
|
|
||||||
|
def test_no_db_no_callback_generates_key_from_folder_name(self, temp_directory, mock_loader):
|
||||||
|
"""No DB entry, no callback -> key generated from folder name."""
|
||||||
|
folder = os.path.join(temp_directory, "Legacy Series")
|
||||||
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
# No key file, no data file - should fall through to Step 4 (key generation)
|
||||||
|
|
||||||
|
scanner = SerieScanner(temp_directory, mock_loader)
|
||||||
|
|
||||||
|
result = scanner._SerieScanner__read_data_from_file("Legacy Series")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.key == "legacy-series"
|
||||||
|
assert result.folder == "Legacy Series"
|
||||||
|
|
||||||
|
def test_db_lookup_exception_caught_and_logged(self, temp_directory, mock_loader):
|
||||||
|
"""DB exception -> fallback to provider callback."""
|
||||||
|
def bad_lookup(folder):
|
||||||
|
raise RuntimeError("DB connection failed")
|
||||||
|
|
||||||
|
scanner = SerieScanner(temp_directory, mock_loader, db_lookup=bad_lookup)
|
||||||
|
|
||||||
|
with patch.object(logging.getLogger("src.core.SerieScanner"), "warning") as mock_warning:
|
||||||
|
result = scanner._SerieScanner__read_data_from_file("Rooster Fighter (2026)")
|
||||||
|
mock_warning.assert_called()
|
||||||
|
assert any("DB lookup failed" in str(c) for c in mock_warning.call_args_list)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSerieFromFolderEdgeCases:
|
||||||
|
"""Edge case tests for __read_data_from_file."""
|
||||||
|
|
||||||
|
def test_empty_folder_name_returns_none(self, temp_directory, mock_loader):
|
||||||
|
"""Empty folder name -> returns None (no DB lookup attempted)."""
|
||||||
|
scanner = SerieScanner(temp_directory, mock_loader)
|
||||||
|
result = scanner._SerieScanner__read_data_from_file("")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_nonexistent_folder_no_exception(self, temp_directory, mock_loader):
|
||||||
|
"""Folder doesn't exist -> returns None without raising."""
|
||||||
|
scanner = SerieScanner(temp_directory, mock_loader)
|
||||||
|
result = scanner._SerieScanner__read_data_from_file("Nonexistent Folder")
|
||||||
|
assert result is None
|
||||||
225
tests/unit/test_serie_scanner_db_writes.py
Normal file
225
tests/unit/test_serie_scanner_db_writes.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""Tests for SerieScanner DB persistence functionality."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.entities.series import Serie
|
||||||
|
from src.core.SerieScanner import SerieScanner
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_session_factory():
|
||||||
|
"""Create a mock async session factory."""
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session_factory = MagicMock(return_value=mock_session)
|
||||||
|
return mock_session_factory, mock_session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_serie():
|
||||||
|
"""Create a sample Serie for testing."""
|
||||||
|
return Serie(
|
||||||
|
key="attack-on-titan",
|
||||||
|
name="Attack on Titan",
|
||||||
|
site="aniworld.to",
|
||||||
|
folder="Attack on Titan (2013)",
|
||||||
|
episodeDict={1: [1, 2, 3], 2: [1, 2]},
|
||||||
|
year=2013
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPersistSerieToDb:
|
||||||
|
"""Test _persist_serie_to_db method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_creates_new_series_when_not_exists(
|
||||||
|
self, mock_session_factory, sample_serie
|
||||||
|
):
|
||||||
|
"""Verify new series is created in DB."""
|
||||||
|
mock_factory, mock_session = mock_session_factory
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.database.connection.get_async_session_factory",
|
||||||
|
return_value=mock_factory
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||||
|
return_value=None
|
||||||
|
):
|
||||||
|
mock_anime_series = MagicMock()
|
||||||
|
mock_anime_series.id = 1
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.AnimeSeriesService.create",
|
||||||
|
return_value=mock_anime_series
|
||||||
|
):
|
||||||
|
scanner = SerieScanner("/tmp", MagicMock())
|
||||||
|
await scanner._persist_serie_to_db(sample_serie)
|
||||||
|
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
AnimeSeriesService.create.assert_called_once()
|
||||||
|
call_kwargs = AnimeSeriesService.create.call_args[1]
|
||||||
|
assert call_kwargs["key"] == "attack-on-titan"
|
||||||
|
assert call_kwargs["name"] == "Attack on Titan"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_updates_existing_series(self, mock_session_factory, sample_serie):
|
||||||
|
"""Verify existing series is updated."""
|
||||||
|
mock_factory, mock_session = mock_session_factory
|
||||||
|
|
||||||
|
mock_existing = MagicMock()
|
||||||
|
mock_existing.id = 42
|
||||||
|
mock_existing.key = "attack-on-titan"
|
||||||
|
|
||||||
|
scanner = SerieScanner("/tmp", MagicMock())
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.database.connection.get_async_session_factory",
|
||||||
|
return_value=mock_factory
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||||
|
return_value=mock_existing
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.AnimeSeriesService.update",
|
||||||
|
new_callable=AsyncMock
|
||||||
|
) as mock_update:
|
||||||
|
with patch.object(
|
||||||
|
scanner,
|
||||||
|
"_sync_episodes_to_db",
|
||||||
|
new_callable=AsyncMock
|
||||||
|
):
|
||||||
|
await scanner._persist_serie_to_db(sample_serie)
|
||||||
|
|
||||||
|
mock_update.assert_called_once()
|
||||||
|
call_args = mock_update.call_args[0]
|
||||||
|
assert call_args[1] == 42 # series_id
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncEpisodesToDb:
|
||||||
|
"""Test _sync_episodes_to_db method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_preserves_downloaded_episodes(self):
|
||||||
|
"""Verify downloaded episodes are not removed even when no longer missing."""
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
|
||||||
|
# S01E1 was downloaded (file exists), S01E2 was missing but file now exists
|
||||||
|
# Both are no longer in episode_dict
|
||||||
|
existing_eps = [
|
||||||
|
MagicMock(id=1, season=1, episode_number=1, is_downloaded=True),
|
||||||
|
MagicMock(id=2, season=1, episode_number=2, is_downloaded=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.EpisodeService.get_by_series",
|
||||||
|
return_value=existing_eps
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.EpisodeService.delete_by_series",
|
||||||
|
new_callable=AsyncMock
|
||||||
|
) as mock_delete:
|
||||||
|
scanner = SerieScanner("/tmp", MagicMock())
|
||||||
|
# Neither S01E1 nor S01E2 are missing now
|
||||||
|
await scanner._sync_episodes_to_db(
|
||||||
|
mock_session, 1, {} # No episodes missing
|
||||||
|
)
|
||||||
|
|
||||||
|
# Neither should be deleted since both are downloaded
|
||||||
|
mock_delete.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_removes_missing_episodes_when_no_longer_missing(self):
|
||||||
|
"""Verify episodes removed from DB if file now exists."""
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
|
||||||
|
existing_eps = [
|
||||||
|
MagicMock(id=1, season=1, episode_number=1, is_downloaded=False),
|
||||||
|
MagicMock(id=2, season=1, episode_number=2, is_downloaded=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.EpisodeService.get_by_series",
|
||||||
|
return_value=existing_eps
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.EpisodeService.delete_by_series",
|
||||||
|
new_callable=AsyncMock
|
||||||
|
) as mock_delete:
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.EpisodeService.create",
|
||||||
|
new_callable=AsyncMock
|
||||||
|
):
|
||||||
|
scanner = SerieScanner("/tmp", MagicMock())
|
||||||
|
await scanner._sync_episodes_to_db(
|
||||||
|
mock_session, 1, {1: [1]} # Only S01E01 now missing
|
||||||
|
)
|
||||||
|
|
||||||
|
# S01E02 should be deleted since no longer missing
|
||||||
|
mock_delete.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_adds_new_missing_episodes(self):
|
||||||
|
"""Verify new missing episodes are added."""
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
|
||||||
|
existing_eps = [
|
||||||
|
MagicMock(id=1, season=1, episode_number=1, is_downloaded=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.EpisodeService.get_by_series",
|
||||||
|
return_value=existing_eps
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.EpisodeService.create",
|
||||||
|
new_callable=AsyncMock
|
||||||
|
) as mock_create:
|
||||||
|
scanner = SerieScanner("/tmp", MagicMock())
|
||||||
|
await scanner._sync_episodes_to_db(
|
||||||
|
mock_session, 1, {1: [1, 2, 3]} # S01E01, S01E02, S01E03
|
||||||
|
)
|
||||||
|
|
||||||
|
# S01E02 and S01E03 should be created
|
||||||
|
assert mock_create.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestPersistSerieToDbErrorHandling:
|
||||||
|
"""Test error handling in _persist_serie_to_db."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_logs_error_when_db_unavailable(self, sample_serie):
|
||||||
|
"""Verify DB unavailability is logged but doesn't crash."""
|
||||||
|
with patch(
|
||||||
|
"src.server.database.connection.get_async_session_factory",
|
||||||
|
side_effect=RuntimeError("DB not initialized")
|
||||||
|
):
|
||||||
|
scanner = SerieScanner("/tmp", MagicMock())
|
||||||
|
# Should not raise
|
||||||
|
await scanner._persist_serie_to_db(sample_serie)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rollback_on_failure(self, mock_session_factory, sample_serie):
|
||||||
|
"""Verify rollback on DB failure."""
|
||||||
|
mock_factory, mock_session = mock_session_factory
|
||||||
|
|
||||||
|
mock_existing = MagicMock()
|
||||||
|
mock_existing.id = 1
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.server.database.connection.get_async_session_factory",
|
||||||
|
return_value=mock_session
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.AnimeSeriesService.get_by_key",
|
||||||
|
return_value=mock_existing
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"src.server.database.service.AnimeSeriesService.update",
|
||||||
|
side_effect=Exception("DB error")
|
||||||
|
):
|
||||||
|
scanner = SerieScanner("/tmp", MagicMock())
|
||||||
|
# Should not raise but should rollback
|
||||||
|
await scanner._persist_serie_to_db(sample_serie)
|
||||||
|
mock_session.rollback.assert_called_once()
|
||||||
135
tests/unit/test_startup_health_checks.py
Normal file
135
tests/unit/test_startup_health_checks.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"""Unit tests for startup health checks in fastapi_app.py."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartupHealthChecks:
|
||||||
|
"""Test startup health check function."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ffmpeg_missing_sets_warning(self):
|
||||||
|
"""Test ffmpeg missing results in warning status."""
|
||||||
|
mock_logger = MagicMock()
|
||||||
|
|
||||||
|
with patch("shutil.which", return_value=None):
|
||||||
|
from src.server.fastapi_app import _run_startup_health_checks
|
||||||
|
result = await _run_startup_health_checks(mock_logger)
|
||||||
|
|
||||||
|
assert result["ffmpeg"]["status"] == "warning"
|
||||||
|
assert "not found in PATH" in result["ffmpeg"]["message"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ffmpeg_present_sets_ok(self):
|
||||||
|
"""Test ffmpeg present results in ok status."""
|
||||||
|
mock_logger = MagicMock()
|
||||||
|
|
||||||
|
with patch("shutil.which", return_value="/usr/bin/ffmpeg"):
|
||||||
|
from src.server.fastapi_app import _run_startup_health_checks
|
||||||
|
result = await _run_startup_health_checks(mock_logger)
|
||||||
|
|
||||||
|
assert result["ffmpeg"]["status"] == "ok"
|
||||||
|
assert "Found at" in result["ffmpeg"]["message"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_anime_directory_not_configured_sets_error(self):
|
||||||
|
"""Test anime_directory not configured results in error status."""
|
||||||
|
mock_logger = MagicMock()
|
||||||
|
|
||||||
|
with patch("src.config.settings.settings") as mock_settings:
|
||||||
|
mock_settings.anime_directory = ""
|
||||||
|
|
||||||
|
from src.server.fastapi_app import _run_startup_health_checks
|
||||||
|
result = await _run_startup_health_checks(mock_logger)
|
||||||
|
|
||||||
|
assert result["anime_directory"]["status"] == "error"
|
||||||
|
assert result["anime_directory"]["path"] is None
|
||||||
|
assert "not configured" in result["anime_directory"]["message"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_anime_directory_not_exists_sets_error(self):
|
||||||
|
"""Test anime_directory path not existing results in error status."""
|
||||||
|
mock_logger = MagicMock()
|
||||||
|
|
||||||
|
with patch("src.config.settings.settings") as mock_settings:
|
||||||
|
mock_settings.anime_directory = "/nonexistent/path"
|
||||||
|
|
||||||
|
with patch("os.path.isdir", return_value=False):
|
||||||
|
from src.server.fastapi_app import _run_startup_health_checks
|
||||||
|
result = await _run_startup_health_checks(mock_logger)
|
||||||
|
|
||||||
|
assert result["anime_directory"]["status"] == "error"
|
||||||
|
assert "does not exist" in result["anime_directory"]["message"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_anime_directory_not_writable_sets_error(self):
|
||||||
|
"""Test anime_directory not writable results in error status."""
|
||||||
|
mock_logger = MagicMock()
|
||||||
|
|
||||||
|
with patch("src.config.settings.settings") as mock_settings:
|
||||||
|
mock_settings.anime_directory = "/some/path"
|
||||||
|
|
||||||
|
with patch("os.path.isdir", return_value=True):
|
||||||
|
with patch("os.access", return_value=False):
|
||||||
|
from src.server.fastapi_app import _run_startup_health_checks
|
||||||
|
result = await _run_startup_health_checks(mock_logger)
|
||||||
|
|
||||||
|
assert result["anime_directory"]["status"] == "error"
|
||||||
|
assert "not writable" in result["anime_directory"]["message"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_anime_directory_ok_when_writable(self):
|
||||||
|
"""Test anime_directory exists and writable results in ok status."""
|
||||||
|
mock_logger = MagicMock()
|
||||||
|
|
||||||
|
with patch("src.config.settings.settings") as mock_settings:
|
||||||
|
mock_settings.anime_directory = "/valid/path"
|
||||||
|
|
||||||
|
with patch("os.path.isdir", return_value=True):
|
||||||
|
with patch("os.access", return_value=True):
|
||||||
|
from src.server.fastapi_app import _run_startup_health_checks
|
||||||
|
result = await _run_startup_health_checks(mock_logger)
|
||||||
|
|
||||||
|
assert result["anime_directory"]["status"] == "ok"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dns_aniworld_failure_sets_warning(self):
|
||||||
|
"""Test DNS failure for aniworld.to sets warning status."""
|
||||||
|
mock_logger = MagicMock()
|
||||||
|
|
||||||
|
import socket
|
||||||
|
with patch("socket.gethostbyname", side_effect=socket.gaierror("DNS failed")):
|
||||||
|
from src.server.fastapi_app import _run_startup_health_checks
|
||||||
|
result = await _run_startup_health_checks(mock_logger)
|
||||||
|
|
||||||
|
assert result["dns_aniworld"]["status"] == "warning"
|
||||||
|
assert "DNS resolution failed" in result["dns_aniworld"]["message"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dns_tmdb_failure_sets_warning(self):
|
||||||
|
"""Test DNS failure for api.themoviedb.org sets warning status."""
|
||||||
|
mock_logger = MagicMock()
|
||||||
|
|
||||||
|
import socket
|
||||||
|
with patch("socket.gethostbyname", side_effect=socket.gaierror("DNS failed")):
|
||||||
|
from src.server.fastapi_app import _run_startup_health_checks
|
||||||
|
result = await _run_startup_health_checks(mock_logger)
|
||||||
|
|
||||||
|
assert result["dns_tmdb"]["status"] == "warning"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_all_checks_returned(self):
|
||||||
|
"""Test all health checks are present in result."""
|
||||||
|
mock_logger = MagicMock()
|
||||||
|
|
||||||
|
with patch("src.config.settings.settings") as mock_settings:
|
||||||
|
mock_settings.anime_directory = ""
|
||||||
|
|
||||||
|
from src.server.fastapi_app import _run_startup_health_checks
|
||||||
|
result = await _run_startup_health_checks(mock_logger)
|
||||||
|
|
||||||
|
assert "ffmpeg" in result
|
||||||
|
assert "dns_aniworld" in result
|
||||||
|
assert "dns_tmdb" in result
|
||||||
|
assert "anime_directory" in result
|
||||||
@@ -23,6 +23,8 @@ async def test_system_settings_integration():
|
|||||||
assert settings.initial_scan_completed is False
|
assert settings.initial_scan_completed is False
|
||||||
assert settings.initial_nfo_scan_completed is False
|
assert settings.initial_nfo_scan_completed is False
|
||||||
assert settings.initial_media_scan_completed is False
|
assert settings.initial_media_scan_completed is False
|
||||||
|
assert settings.migration_legacy_files_completed is False
|
||||||
|
assert settings.legacy_key_cleanup_completed is False
|
||||||
|
|
||||||
# Test checking individual flags
|
# Test checking individual flags
|
||||||
async with get_db_session() as db:
|
async with get_db_session() as db:
|
||||||
@@ -34,6 +36,12 @@ async def test_system_settings_integration():
|
|||||||
|
|
||||||
is_media_done = await SystemSettingsService.is_initial_media_scan_completed(db)
|
is_media_done = await SystemSettingsService.is_initial_media_scan_completed(db)
|
||||||
assert is_media_done is False
|
assert is_media_done is False
|
||||||
|
|
||||||
|
is_migration_done = await SystemSettingsService.is_migration_legacy_files_completed(db)
|
||||||
|
assert is_migration_done is False
|
||||||
|
|
||||||
|
is_key_cleanup_done = await SystemSettingsService.is_legacy_key_cleanup_completed(db)
|
||||||
|
assert is_key_cleanup_done is False
|
||||||
|
|
||||||
# Test marking scans as completed
|
# Test marking scans as completed
|
||||||
async with get_db_session() as db:
|
async with get_db_session() as db:
|
||||||
@@ -56,6 +64,8 @@ async def test_system_settings_integration():
|
|||||||
assert settings.initial_scan_completed is False
|
assert settings.initial_scan_completed is False
|
||||||
assert settings.initial_nfo_scan_completed is False
|
assert settings.initial_nfo_scan_completed is False
|
||||||
assert settings.initial_media_scan_completed is False
|
assert settings.initial_media_scan_completed is False
|
||||||
|
assert settings.migration_legacy_files_completed is False
|
||||||
|
assert settings.legacy_key_cleanup_completed is False
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user