Compare commits

..

2 Commits

Author SHA1 Message Date
5f66a28bed Mark migration task as complete, remove old task list 2025-12-01 17:46:42 +01:00
17754a86f0 Add database migration from legacy data files
- Create DataMigrationService for migrating data files to SQLite
- Add sync database methods to AnimeSeriesService
- Update SerieScanner to save to database with file fallback
- Update anime API endpoints to use database with fallback
- Add delete endpoint for anime series
- Add automatic migration on startup in fastapi_app.py lifespan
- Add 28 unit tests for migration service
- Add 14 integration tests for migration flow
- Update infrastructure.md and database README docs

Migration runs automatically on startup, legacy data files preserved.
2025-12-01 17:42:09 +01:00
347 changed files with 12696 additions and 83274 deletions

BIN
.coverage Normal file

Binary file not shown.

View File

@@ -1,34 +0,0 @@
__pycache__/
*.pyc
*.pyo
*.egg-info/
.git/
.github/
.gitignore
.vscode/
.vs/
.idea/
.mypy_cache/
.pytest_cache/
.coverage
.env
*.log
# Docker files (not needed inside the image)
Docker/
# Test and dev files
tests/
Temp/
test_data/
docs/
diagrams/
# Runtime data (mounted as volumes)
data/aniworld.db
data/config_backups/
logs/
# Frontend tooling
node_modules/
package.json

26
.gitignore vendored
View File

@@ -4,7 +4,6 @@
/src/__pycache__/*
/src/__pycache__/
/.vs/*
/.venv/*
/src/Temp/*
/src/Loaders/__pycache__/*
/src/Loaders/provider/__pycache__/*
@@ -52,35 +51,12 @@ wheels/
.installed.cfg
*.egg
# Database files (including SQLite journal/WAL files)
# Database
*.db
*.db-shm
*.db-wal
*.db-journal
*.sqlite
*.sqlite3
*.sqlite-shm
*.sqlite-wal
*.sqlite-journal
data/*.db*
data/aniworld.db*
# Configuration files (exclude from git, keep backups local)
data/config.json
data/config_backups/
config.json
*.config
# Logs
*.log
logs/
src/cli/logs/
*.log.*
# Temp folders
Temp/
temp/
tmp/
*.tmp
.coverage
.venv/bin/dotenv

4
.gitmodules vendored Normal file
View File

@@ -0,0 +1,4 @@
[submodule "src/AniWorld-Downloader"]
path = src/AniWorld-Downloader
url = https://github.com/lukaspupkalipinski/AniWorld-Downloader.git
branch = next

View File

@@ -1,11 +1,8 @@
{
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
"python.defaultInterpreterPath": "C:\\Users\\lukas\\anaconda3\\envs\\AniWorld\\python.exe",
"python.terminal.activateEnvironment": true,
"python.condaPath": "C:\\Users\\lukas\\anaconda3\\Scripts\\conda.exe",
"python.terminal.activateEnvInCurrentTerminal": true,
"terminal.integrated.env.linux": {
"VIRTUAL_ENV": "${workspaceFolder}/.venv",
"PATH": "${workspaceFolder}/.venv/bin:${env:PATH}"
},
"python.linting.enabled": true,
"python.linting.flake8Enabled": true,
"python.linting.pylintEnabled": true,

View File

@@ -1,24 +0,0 @@
FROM alpine:3.19
RUN apk add --no-cache \
wireguard-tools \
iptables \
ip6tables \
bash \
curl \
iputils-ping \
iproute2 \
openresolv
# Create wireguard config directory (config is mounted at runtime)
RUN mkdir -p /etc/wireguard
# Copy entrypoint
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Health check: can we reach the internet through the VPN?
HEALTHCHECK --interval=30s --timeout=10s --retries=5 \
CMD curl -sf --max-time 5 http://1.1.1.1 || exit 1
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -1,33 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
# Install system dependencies for compiled Python packages
RUN apt-get update && \
apt-get install -y --no-install-recommends \
gcc \
g++ \
libffi-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies (cached layer)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy the full application
COPY src/ ./src/
COPY run_server.py .
COPY pyproject.toml .
COPY data/config.json ./data/config.json
# Create runtime directories
RUN mkdir -p /app/data/config_backups /app/logs
EXPOSE 8000
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/app
# Bind to 0.0.0.0 so the app is reachable from the VPN container's network
CMD ["python", "-m", "uvicorn", "src.server.fastapi_app:app", \
"--host", "0.0.0.0", "--port", "8000"]

View File

@@ -1,91 +0,0 @@
#!/bin/bash
# === Configuration ===
LOGFILE="/tmp/dispatcher.log"
BACKUP="/tmp/dispatcher.log.1"
MAXSIZE=$((1024 * 1024)) # 1 MB
VPN_IFACE="nl"
GATEWAY="192.168.178.1"
LOCAL_IFACE="wlp4s0f0"
ROUTE1="185.183.34.149"
ROUTE2="192.168.178.0/24"
# === Log Rotation ===
if [ -f "$LOGFILE" ] && [ "$(stat -c%s "$LOGFILE")" -ge "$MAXSIZE" ]; then
echo "[$(date)] Log file exceeded 1MB, rotating..." >> "$LOGFILE"
mv "$LOGFILE" "$BACKUP"
touch "$LOGFILE"
fi
# === Logging Setup ===
exec >> "$LOGFILE" 2>&1
echo "[$(date)] Running dispatcher for $1 with status $2"
IFACE="$1"
STATUS="$2"
log_and_run() {
echo "[$(date)] Executing: $*"
if ! output=$("$@" 2>&1); then
echo "[$(date)] ERROR: Command failed: $*"
echo "[$(date)] Output: $output"
else
echo "[$(date)] Success: $*"
fi
}
# === VPN Routing Logic ===
if [ "$IFACE" = "$VPN_IFACE" ]; then
case "$STATUS" in
up)
echo "[$(date)] VPN interface is up. Preparing routes..."
# === Wait for local interface and gateway ===
echo "[$(date)] Waiting for $LOCAL_IFACE (state UP) and gateway $GATEWAY (reachable)..."
until ip link show "$LOCAL_IFACE" | grep -q "state UP" && ip route get "$GATEWAY" &>/dev/null; do
echo "[$(date)] Waiting for $LOCAL_IFACE and $GATEWAY..."
sleep 1
done
echo "[$(date)] Local interface and gateway are ready."
# === End Wait ===
# === APPLY ROUTES (Corrected Order) ===
# 1. Add the route for the local network FIRST
log_and_run /sbin/ip route replace "$ROUTE2" dev "$LOCAL_IFACE"
# 2. Add the route to the VPN endpoint via the gateway SECOND
log_and_run /sbin/ip route replace "$ROUTE1" via "$GATEWAY" dev "$LOCAL_IFACE"
# === END APPLY ROUTES ===
# Log interface and WireGuard status
echo "[$(date)] --- ip addr show $VPN_IFACE ---"
ip addr show "$VPN_IFACE"
echo "[$(date)] --- wg show $VPN_IFACE ---"
wg show "$VPN_IFACE"
;;
down)
echo "[$(date)] VPN interface is down. Verifying before removing routes..."
# Log interface and WireGuard status
echo "[$(date)] --- ip addr show $VPN_IFACE ---"
ip addr show "$VPN_IFACE"
echo "[$(date)] --- wg show $VPN_IFACE ---"
wg show "$VPN_IFACE"
# Delay and confirm interface is still down
sleep 5
if ip link show "$VPN_IFACE" | grep -q "state UP"; then
echo "[$(date)] VPN interface is still up. Skipping route removal."
else
echo "[$(date)] Confirmed VPN is down. Removing routes..."
# It's good practice to remove them in reverse order, too.
log_and_run /sbin/ip route del "$ROUTE1" via "$GATEWAY" dev "$LOCAL_IFACE"
log_and_run /sbin/ip route del "$ROUTE2" dev "$LOCAL_IFACE"
fi
;;
esac
fi

View File

@@ -1,228 +0,0 @@
#!/bin/bash
set -e
INTERFACE="wg0"
MOUNT_CONFIG="/etc/wireguard/${INTERFACE}.conf"
CONFIG_DIR="/run/wireguard"
CONFIG_FILE="${CONFIG_DIR}/${INTERFACE}.conf"
CHECK_INTERVAL="${HEALTH_CHECK_INTERVAL:-10}"
CHECK_HOST="${HEALTH_CHECK_HOST:-1.1.1.1}"
# ──────────────────────────────────────────────
# Validate config exists, copy to writable location
# ──────────────────────────────────────────────
if [ ! -f "$MOUNT_CONFIG" ]; then
echo "[error] WireGuard config not found at ${MOUNT_CONFIG}"
echo "[error] Mount your config file: -v /path/to/your.conf:/etc/wireguard/wg0.conf:ro"
exit 1
fi
mkdir -p "$CONFIG_DIR"
cp "$MOUNT_CONFIG" "$CONFIG_FILE"
chmod 600 "$CONFIG_FILE"
# Extract endpoint IP and port from the config
VPN_ENDPOINT=$(grep -i '^Endpoint' "$CONFIG_FILE" | head -1 | sed 's/.*= *//;s/:.*//;s/ //g')
VPN_PORT=$(grep -i '^Endpoint' "$CONFIG_FILE" | head -1 | sed 's/.*://;s/ //g')
# Extract address
VPN_ADDRESS=$(grep -i '^Address' "$CONFIG_FILE" | head -1 | sed 's/.*= *//;s/ //g')
if [ -z "$VPN_ENDPOINT" ] || [ -z "$VPN_PORT" ]; then
echo "[error] Could not parse Endpoint from ${CONFIG_FILE}"
exit 1
fi
echo "[init] Config: ${CONFIG_FILE}"
echo "[init] Endpoint: ${VPN_ENDPOINT}:${VPN_PORT}"
echo "[init] Address: ${VPN_ADDRESS}"
# ──────────────────────────────────────────────
# Kill switch: only allow traffic through wg0
# ──────────────────────────────────────────────
setup_killswitch() {
echo "[killswitch] Setting up iptables kill switch..."
# Flush existing rules
iptables -F
iptables -X
iptables -t nat -F
# Default policy: DROP everything
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT DROP
# Allow loopback
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT
# Allow traffic to/from VPN endpoint (needed to establish tunnel)
iptables -A OUTPUT -d "$VPN_ENDPOINT" -p udp --dport "$VPN_PORT" -j ACCEPT
iptables -A INPUT -s "$VPN_ENDPOINT" -p udp --sport "$VPN_PORT" -j ACCEPT
# Allow all traffic through the WireGuard interface
iptables -A INPUT -i "$INTERFACE" -j ACCEPT
iptables -A OUTPUT -o "$INTERFACE" -j ACCEPT
# Allow DNS to the VPN DNS server (through wg0)
iptables -A OUTPUT -o "$INTERFACE" -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -o "$INTERFACE" -p tcp --dport 53 -j ACCEPT
# Allow DHCP (for container networking)
iptables -A OUTPUT -p udp --dport 67:68 -j ACCEPT
iptables -A INPUT -p udp --sport 67:68 -j ACCEPT
# Allow established/related connections
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# ── Allow incoming connections to exposed service ports (e.g. app on 8000) ──
# LOCAL_PORTS can be set as env var, e.g. "8000,8080,3000"
if [ -n "${LOCAL_PORTS:-}" ]; then
for port in $(echo "$LOCAL_PORTS" | tr ',' ' '); do
echo "[killswitch] Allowing incoming traffic on port ${port}"
iptables -A INPUT -p tcp --dport "$port" -j ACCEPT
iptables -A OUTPUT -p tcp --sport "$port" -j ACCEPT
done
fi
# ── FORWARDING (so other containers can use this VPN) ──
iptables -A FORWARD -i eth0 -o "$INTERFACE" -j ACCEPT
iptables -A FORWARD -i "$INTERFACE" -o eth0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# NAT: masquerade traffic from other containers going out through wg0
iptables -t nat -A POSTROUTING -o "$INTERFACE" -j MASQUERADE
echo "[killswitch] Kill switch active. Traffic blocked if VPN drops."
}
# ──────────────────────────────────────────────
# Enable IP forwarding so other containers can route through us
# ──────────────────────────────────────────────
enable_forwarding() {
echo "[init] Enabling IP forwarding..."
if cat /proc/sys/net/ipv4/ip_forward 2>/dev/null | grep -q 1; then
echo "[init] IP forwarding already enabled."
elif echo 1 > /proc/sys/net/ipv4/ip_forward 2>/dev/null; then
echo "[init] IP forwarding enabled via /proc."
else
echo "[init] /proc read-only — relying on --sysctl net.ipv4.ip_forward=1"
fi
}
# ──────────────────────────────────────────────
# Start WireGuard manually (no wg-quick, avoids sysctl issues)
# ──────────────────────────────────────────────
start_vpn() {
echo "[vpn] Starting WireGuard interface ${INTERFACE}..."
# Create the interface
ip link add "$INTERFACE" type wireguard
# Apply the WireGuard config (keys, peer, endpoint)
wg setconf "$INTERFACE" <(grep -v -i '^\(Address\|DNS\|MTU\|Table\|PreUp\|PostUp\|PreDown\|PostDown\|SaveConfig\)' "$CONFIG_FILE")
# Assign the address
ip -4 address add "$VPN_ADDRESS" dev "$INTERFACE"
# Set MTU
ip link set mtu 1420 up dev "$INTERFACE"
# Find default gateway/interface for the endpoint route
DEFAULT_GW=$(ip route | grep '^default' | head -1 | awk '{print $3}')
DEFAULT_IF=$(ip route | grep '^default' | head -1 | awk '{print $5}')
# Route VPN endpoint through the container's default gateway
if [ -n "$DEFAULT_GW" ] && [ -n "$DEFAULT_IF" ]; then
ip route add "$VPN_ENDPOINT/32" via "$DEFAULT_GW" dev "$DEFAULT_IF" 2>/dev/null || true
fi
# Route all traffic through the WireGuard tunnel
ip route add 0.0.0.0/1 dev "$INTERFACE"
ip route add 128.0.0.0/1 dev "$INTERFACE"
# ── Policy routing: ensure responses to incoming LAN traffic go back via eth0 ──
if [ -n "$DEFAULT_GW" ] && [ -n "$DEFAULT_IF" ]; then
# Get the container's eth0 IP address (BusyBox-compatible, no grep -P)
ETH0_IP=$(ip -4 addr show "$DEFAULT_IF" | awk '/inet / {split($2, a, "/"); print a[1]}' | head -1)
ETH0_SUBNET=$(ip -4 route show dev "$DEFAULT_IF" | grep -v default | head -1 | awk '{print $1}')
if [ -n "$ETH0_IP" ] && [ -n "$ETH0_SUBNET" ]; then
echo "[vpn] Setting up policy routing for incoming traffic (${ETH0_IP} on ${DEFAULT_IF})"
ip route add default via "$DEFAULT_GW" dev "$DEFAULT_IF" table 100 2>/dev/null || true
ip route add "$ETH0_SUBNET" dev "$DEFAULT_IF" table 100 2>/dev/null || true
ip rule add from "$ETH0_IP" table 100 priority 100 2>/dev/null || true
echo "[vpn] Policy routing active — incoming connections will be routed back via ${DEFAULT_IF}"
fi
fi
# Set up DNS
VPN_DNS=$(grep -i '^DNS' "$CONFIG_FILE" | head -1 | sed 's/.*= *//;s/ //g')
if [ -n "$VPN_DNS" ]; then
echo "nameserver $VPN_DNS" > /etc/resolv.conf
echo "[vpn] DNS set to ${VPN_DNS}"
fi
echo "[vpn] WireGuard interface ${INTERFACE} is up."
}
# ──────────────────────────────────────────────
# Stop WireGuard manually
# ──────────────────────────────────────────────
stop_vpn() {
echo "[vpn] Stopping WireGuard interface ${INTERFACE}..."
ip link del "$INTERFACE" 2>/dev/null || true
}
# ──────────────────────────────────────────────
# Health check loop — restarts VPN if tunnel dies
# ──────────────────────────────────────────────
health_loop() {
local failures=0
local max_failures=3
echo "[health] Starting health check (every ${CHECK_INTERVAL}s, target ${CHECK_HOST})..."
while true; do
sleep "$CHECK_INTERVAL"
if curl -sf --max-time 5 "http://$CHECK_HOST" > /dev/null 2>&1; then
if [ "$failures" -gt 0 ]; then
echo "[health] VPN recovered."
failures=0
fi
else
failures=$((failures + 1))
echo "[health] Ping failed ($failures/$max_failures)"
if [ "$failures" -ge "$max_failures" ]; then
echo "[health] VPN appears down. Restarting WireGuard..."
stop_vpn
sleep 2
start_vpn
failures=0
echo "[health] WireGuard restarted."
fi
fi
done
}
# ──────────────────────────────────────────────
# Graceful shutdown
# ──────────────────────────────────────────────
cleanup() {
echo "[shutdown] Stopping WireGuard..."
stop_vpn
echo "[shutdown] Flushing iptables..."
iptables -F
iptables -t nat -F
echo "[shutdown] Done."
exit 0
}
trap cleanup SIGTERM SIGINT
# ── Main ──
enable_forwarding
setup_killswitch
start_vpn
health_loop

View File

@@ -1,17 +0,0 @@
[Interface]
PrivateKey = iO5spIue/6ciwUoR95hYtuxdtQxV/Q9EOoQ/jHe18kM=
Address = 10.2.0.2/32
DNS = 10.2.0.1
# 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 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 192.168.178.0/24 via 192.168.178.1 dev wlp4s0f0
[Peer]
PublicKey = J4XVdtoBVc/EoI2Yk673Oes97WMnQSH5KfamZNjtM2s=
AllowedIPs = 0.0.0.0/1, 128.0.0.0/1
Endpoint = 185.183.34.149:51820

View File

@@ -1,54 +0,0 @@
# Production compose — pulls pre-built images from Gitea registry.
#
# Usage:
# podman login git.lpl-mind.de
# podman-compose -f podman-compose.prod.yml pull
# podman-compose -f podman-compose.prod.yml up -d
#
# Required files:
# - wg0.conf (WireGuard configuration in the same directory)
services:
vpn:
image: git.lpl-mind.de/lukas.pupkalipinski/aniworld/vpn:latest
container_name: vpn-wireguard
cap_add:
- NET_ADMIN
- SYS_MODULE
sysctls:
- net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1
volumes:
- /server/server_aniworld/wg0.conf:/etc/wireguard/wg0.conf:ro
- /lib/modules:/lib/modules:ro
ports:
- "2000:8000"
environment:
- HEALTH_CHECK_INTERVAL=10
- HEALTH_CHECK_HOST=1.1.1.1
- LOCAL_PORTS=8000
- PUID=1013
- PGID=1001
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-sf", "--max-time", "5", "http://1.1.1.1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
app:
image: git.lpl-mind.de/lukas.pupkalipinski/aniworld/app:latest
container_name: aniworld-app
network_mode: "service:vpn"
depends_on:
vpn:
condition: service_healthy
environment:
- PYTHONUNBUFFERED=1
- PUID=1013
- PGID=1001
volumes:
- /server/server_aniworld/data:/app/data
- /server/server_aniworld/logs:/app/logs
restart: unless-stopped

View File

@@ -1,47 +0,0 @@
services:
vpn:
build:
context: .
dockerfile: Containerfile
container_name: vpn-wireguard
cap_add:
- NET_ADMIN
- SYS_MODULE
sysctls:
- net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1
volumes:
- ./wg0.conf:/etc/wireguard/wg0.conf:ro
- /lib/modules:/lib/modules:ro
ports:
- "8000:8000"
environment:
- HEALTH_CHECK_INTERVAL=10
- HEALTH_CHECK_HOST=1.1.1.1
- LOCAL_PORTS=8000
restart: unless-stopped
healthcheck:
test: ["CMD", "ping", "-c", "1", "-W", "5", "1.1.1.1"]
interval: 30s
timeout: 10s
retries: 3
app:
build:
context: ..
dockerfile: Docker/Dockerfile.app
container_name: aniworld-app
network_mode: "service:vpn"
depends_on:
vpn:
condition: service_healthy
environment:
- PYTHONUNBUFFERED=1
volumes:
- app-data:/app/data
- app-logs:/app/logs
restart: unless-stopped
volumes:
app-data:
app-logs:

View File

@@ -1,97 +0,0 @@
#!/usr/bin/env bash
# filepath: /home/lukas/Volume/repo/Aniworld/Docker/push.sh
#
# Build and push Aniworld container images to the Gitea registry.
#
# Usage:
# ./push.sh # builds & pushes with tag "latest"
# ./push.sh v1.2.3 # builds & pushes with tag "v1.2.3"
# ./push.sh v1.2.3 --no-build # pushes existing images only
#
# Prerequisites:
# podman login git.lpl-mind.de
set -euo pipefail
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
REGISTRY="git.lpl-mind.de"
NAMESPACE="lukas.pupkalipinski"
PROJECT="aniworld"
APP_IMAGE="${REGISTRY}/${NAMESPACE}/${PROJECT}/app"
VPN_IMAGE="${REGISTRY}/${NAMESPACE}/${PROJECT}/vpn"
TAG="${1:-latest}"
SKIP_BUILD=false
if [[ "${2:-}" == "--no-build" ]]; then
SKIP_BUILD=true
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
log() { echo -e "\n>>> $*"; }
err() { echo -e "\n❌ ERROR: $*" >&2; exit 1; }
# ---------------------------------------------------------------------------
# Pre-flight checks
# ---------------------------------------------------------------------------
echo "============================================"
echo " Aniworld — Build & Push"
echo " Registry : ${REGISTRY}"
echo " Tag : ${TAG}"
echo "============================================"
command -v podman &>/dev/null || err "podman is not installed."
if ! podman login --get-login "${REGISTRY}" &>/dev/null; then
err "Not logged in. Run:\n podman login ${REGISTRY}"
fi
# ---------------------------------------------------------------------------
# Build
# ---------------------------------------------------------------------------
if [[ "${SKIP_BUILD}" == false ]]; then
log "Building app image → ${APP_IMAGE}:${TAG}"
podman build \
-t "${APP_IMAGE}:${TAG}" \
-f "${SCRIPT_DIR}/Dockerfile.app" \
"${PROJECT_ROOT}"
log "Building VPN image → ${VPN_IMAGE}:${TAG}"
podman build \
-t "${VPN_IMAGE}:${TAG}" \
-f "${SCRIPT_DIR}/Containerfile" \
"${SCRIPT_DIR}"
fi
# ---------------------------------------------------------------------------
# Push
# ---------------------------------------------------------------------------
log "Pushing ${APP_IMAGE}:${TAG}"
podman push "${APP_IMAGE}:${TAG}"
log "Pushing ${VPN_IMAGE}:${TAG}"
podman push "${VPN_IMAGE}:${TAG}"
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
echo ""
echo "============================================"
echo " ✅ Push complete!"
echo ""
echo " Images:"
echo " ${APP_IMAGE}:${TAG}"
echo " ${VPN_IMAGE}:${TAG}"
echo ""
echo " Deploy on server:"
echo " podman login ${REGISTRY}"
echo " podman-compose -f podman-compose.prod.yml pull"
echo " podman-compose -f podman-compose.prod.yml up -d"
echo "============================================"

View File

@@ -1,185 +0,0 @@
"""
Integration test for the WireGuard VPN Podman image.
Verifies:
1. The image builds successfully.
2. The container starts and becomes healthy.
3. The public IP inside the VPN differs from the host IP.
4. Kill switch blocks traffic when WireGuard is down.
Requirements:
- podman installed
- Root/sudo (NET_ADMIN capability)
- A valid WireGuard config at ./wg0.conf (or ./nl.conf)
Usage:
sudo python3 -m pytest test_vpn.py -v
# or
sudo python3 test_vpn.py
"""
import subprocess
import time
import unittest
import os
IMAGE_NAME = "vpn-wireguard-test"
CONTAINER_NAME = "vpn-test-container"
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "wg0.conf")
BUILD_DIR = os.path.dirname(os.path.abspath(__file__))
IP_CHECK_URL = "https://ifconfig.me"
STARTUP_TIMEOUT = 30 # seconds to wait for VPN to come up
HEALTH_POLL_INTERVAL = 2 # seconds between health checks
def run(cmd: list[str], timeout: int = 30, check: bool = True) -> subprocess.CompletedProcess:
"""Run a command and return the result."""
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=check)
def get_host_ip() -> str:
"""Get the public IP of the host machine."""
result = run(["curl", "-s", "--max-time", "10", IP_CHECK_URL])
return result.stdout.strip()
def podman_exec(container: str, cmd: list[str], timeout: int = 15) -> subprocess.CompletedProcess:
"""Execute a command inside a running container."""
return run(["podman", "exec", container] + cmd, timeout=timeout, check=False)
class TestVPNImage(unittest.TestCase):
"""Test suite for the WireGuard VPN container."""
host_ip: str = ""
@classmethod
def setUpClass(cls):
"""Build image, get host IP, start container, wait for VPN."""
# Clean up any leftover container from a previous run
subprocess.run(
["podman", "rm", "-f", CONTAINER_NAME],
capture_output=True, check=False,
)
# ── 1. Get host public IP before VPN ──
print("\n[setup] Fetching host public IP...")
cls.host_ip = get_host_ip()
print(f"[setup] Host public IP: {cls.host_ip}")
assert cls.host_ip, "Could not determine host public IP"
# ── 2. Build the image ──
print(f"[setup] Building image '{IMAGE_NAME}'...")
result = run(
["podman", "build", "-t", IMAGE_NAME, BUILD_DIR],
timeout=180,
)
print(result.stdout[-500:] if len(result.stdout) > 500 else result.stdout)
assert result.returncode == 0, f"Build failed:\n{result.stderr}"
print("[setup] Image built successfully.")
# ── 3. Start the container ──
print(f"[setup] Starting container '{CONTAINER_NAME}'...")
result = run(
[
"podman", "run", "-d",
"--name", CONTAINER_NAME,
"--cap-add=NET_ADMIN",
"--cap-add=SYS_MODULE",
"--sysctl", "net.ipv4.ip_forward=1",
"-v", f"{CONFIG_FILE}:/etc/wireguard/wg0.conf:ro",
"-v", "/lib/modules:/lib/modules:ro",
IMAGE_NAME,
],
timeout=30,
check=False,
)
assert result.returncode == 0, f"Container failed to start:\n{result.stderr}"
cls.container_id = result.stdout.strip()
print(f"[setup] Container started: {cls.container_id[:12]}")
# Verify it's running
inspect = run(
["podman", "inspect", "-f", "{{.State.Running}}", CONTAINER_NAME],
check=False,
)
assert inspect.stdout.strip() == "true", "Container is not running"
# ── 4. Wait for VPN to come up ──
print(f"[setup] Waiting up to {STARTUP_TIMEOUT}s for VPN tunnel...")
vpn_up = cls._wait_for_vpn_cls(STARTUP_TIMEOUT)
assert vpn_up, f"VPN did not come up within {STARTUP_TIMEOUT}s"
print("[setup] VPN tunnel is up. Running tests.\n")
@classmethod
def tearDownClass(cls):
"""Stop and remove the container."""
print("\n[teardown] Cleaning up...")
subprocess.run(["podman", "rm", "-f", CONTAINER_NAME], capture_output=True, check=False)
print("[teardown] Done.")
@classmethod
def _wait_for_vpn_cls(cls, timeout: int = STARTUP_TIMEOUT) -> bool:
"""Wait until the VPN tunnel is up (can reach the internet)."""
deadline = time.time() + timeout
while time.time() < deadline:
result = podman_exec(CONTAINER_NAME, ["ping", "-c", "1", "-W", "3", "1.1.1.1"])
if result.returncode == 0:
return True
time.sleep(HEALTH_POLL_INTERVAL)
return False
def _get_vpn_ip(self) -> str:
"""Get the public IP as seen from inside the container."""
result = podman_exec(
CONTAINER_NAME,
["curl", "-s", "--max-time", "10", IP_CHECK_URL],
timeout=20,
)
return result.stdout.strip()
# ── Tests ────────────────────────────────────────────────
def test_01_ip_differs_from_host(self):
"""Public IP inside VPN is different from host IP."""
vpn_ip = self._get_vpn_ip()
print(f"\n[test] VPN public IP: {vpn_ip}")
print(f"[test] Host public IP: {self.host_ip}")
self.assertTrue(vpn_ip, "Could not fetch IP from inside the container")
self.assertNotEqual(
vpn_ip,
self.host_ip,
f"VPN IP ({vpn_ip}) is the same as host IP — VPN is not working!",
)
def test_02_wireguard_interface_exists(self):
"""The wg0 interface is present in the container."""
result = podman_exec(CONTAINER_NAME, ["wg", "show", "wg0"])
self.assertEqual(result.returncode, 0, f"wg show failed:\n{result.stderr}")
self.assertIn("peer", result.stdout.lower(), "No peer information in wg show output")
def test_03_kill_switch_blocks_traffic(self):
"""When WireGuard is down, traffic is blocked (kill switch)."""
# Bring down the WireGuard interface by deleting it
down_result = podman_exec(CONTAINER_NAME, ["ip", "link", "del", "wg0"], timeout=10)
self.assertEqual(down_result.returncode, 0, f"ip link del wg0 failed:\n{down_result.stderr}")
# Give iptables a moment
time.sleep(2)
# Try to reach the internet — should fail due to kill switch
result = podman_exec(
CONTAINER_NAME,
["curl", "-s", "--max-time", "5", IP_CHECK_URL],
timeout=10,
)
self.assertNotEqual(
result.returncode, 0,
"Traffic went through even with WireGuard down — kill switch is NOT working!",
)
print("\n[test] Kill switch confirmed: traffic blocked with VPN down")
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@@ -1,10 +0,0 @@
[Interface]
PrivateKey = iO5spIue/6ciwUoR95hYtuxdtQxV/Q9EOoQ/jHe18kM=
Address = 10.2.0.2/32
DNS = 10.2.0.1
[Peer]
PublicKey = J4XVdtoBVc/EoI2Yk673Oes97WMnQSH5KfamZNjtM2s=
AllowedIPs = 0.0.0.0/0
Endpoint = 185.183.34.149:51820
PersistentKeepalive = 25

202
README.md
View File

@@ -1,202 +0,0 @@
# Aniworld Download Manager
A web-based anime download manager with REST API, WebSocket real-time updates, and a modern web interface.
## Features
- Web interface for managing anime library
- REST API for programmatic access
- WebSocket real-time progress updates
- Download queue with priority management
- Automatic library scanning for missing episodes
- **NFO metadata management with TMDB integration**
- **Automatic poster/fanart/logo downloads**
- JWT-based authentication
- SQLite database for persistence
- **Comprehensive test coverage** (1,070+ tests, 91.3% coverage)
## Quick Start
### Prerequisites
- Python 3.10+
- Conda (recommended) or virtualenv
### Installation
1. Clone the repository:
```bash
git clone https://github.com/your-repo/aniworld.git
cd aniworld
```
2. Create and activate conda environment:
```bash
conda create -n AniWorld python=3.10
conda activate AniWorld
```
3. Install dependencies:
```bash
pip install -r requirements.txt
```
4. Start the server:
```bash
python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000
```
5. Open http://127.0.0.1:8000 in your browser
### First-Time Setup
1. Navigate to http://127.0.0.1:8000/setup
2. Set a master password (minimum 8 characters, mixed case, number, special character)
3. Configure your anime directory path
4. **(Optional)** Configure NFO settings with your TMDB API key
5. Login with your master password
### NFO Metadata Setup (Optional)
For automatic NFO file generation with metadata and images:
1. Get a free TMDB API key from https://www.themoviedb.org/settings/api
2. Go to Configuration → NFO Settings in the web interface
3. Enter your TMDB API key and click "Test Connection"
4. Enable auto-creation and select which images to download
5. NFO files will be created automatically during downloads
## Documentation
| Document | Description |
| ---------------------------------------------- | -------------------------------- |
| [docs/API.md](docs/API.md) | REST API and WebSocket reference |
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System architecture and design |
| [docs/CONFIGURATION.md](docs/CONFIGURATION.md) | Configuration options |
| [docs/DATABASE.md](docs/DATABASE.md) | Database schema |
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Developer setup guide |
| [docs/TESTING.md](docs/TESTING.md) | Testing guidelines |
## Project Structure
```
src/
+-- cli/ # CLI interface (legacy)
+-- config/ # Application settings
+-- core/ # Domain logic
| +-- SeriesApp.py # Main application facade
| +-- SerieScanner.py # Directory scanning
| +-- entities/ # Domain entities
| +-- providers/ # External provider adapters
+-- server/ # FastAPI web server
+-- api/ # REST API endpoints
+-- services/ # Business logic
+-- models/ # Pydantic models
+-- database/ # SQLAlchemy ORM
+-- middleware/ # Auth, rate limiting
```
## API Endpoints
| Endpoint | Description |
| ------------------------------ | -------------------------------- |
| `POST /api/auth/login` | Authenticate and get JWT token |
| `GET /api/anime` | List anime with missing episodes |
| `GET /api/anime/search?query=` | Search for anime |
| `POST /api/queue/add` | Add episodes to download queue |
| `POST /api/queue/start` | Start queue processing |
| `GET /api/queue/status` | Get queue status |
| `GET /api/nfo/check` | Check NFO status for anime |
| `POST /api/nfo/create` | Create NFO files |
| `WS /ws/connect` | WebSocket for real-time updates |
See [docs/API.md](docs/API.md) for complete API reference.
## Configuration
Environment variables (via `.env` file):
| Variable | Default | Description |
| ----------------- | ------------------------------ | ------------------------- |
| `JWT_SECRET_KEY` | (random) | Secret for JWT signing |
| `DATABASE_URL` | `sqlite:///./data/aniworld.db` | Database connection |
| `ANIME_DIRECTORY` | (empty) | Path to anime library |
| `TMDB_API_KEY` | (empty) | TMDB API key for metadata |
| `LOG_LEVEL` | `INFO` | Logging level |
See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) for all options.
## Running Tests
The project includes a comprehensive test suite with **1,070+ tests** and **91.3% coverage** across all critical systems:
```bash
# Run all Python tests
conda run -n AniWorld python -m pytest tests/ -v
# Run unit tests only
conda run -n AniWorld python -m pytest tests/unit/ -v
# Run integration tests
conda run -n AniWorld python -m pytest tests/integration/ -v
# Run with coverage report
conda run -n AniWorld python -m pytest tests/ --cov --cov-report=html
# Run JavaScript/E2E tests (requires Node.js)
npm test # Unit tests (Vitest)
npm run test:e2e # E2E tests (Playwright)
```
**Test Coverage:**
- ✅ 1,070+ tests across 4 priority tiers (644 Python tests passing, 426 JavaScript/E2E tests)
- ✅ 91.3% code coverage
-**TIER 1 Critical**: 159/159 tests - Scheduler, NFO batch, download queue, persistence
-**TIER 2 High Priority**: 390/390 tests - Frontend UI, WebSocket, dark mode, settings
-**TIER 3 Medium Priority**: 95/156 tests - Performance, edge cases (core scenarios complete)
-**TIER 4 Polish**: 426 tests - Internationalization, accessibility, media server compatibility
- ✅ Security: Complete coverage (authentication, authorization, CSRF, XSS, SQL injection)
- ✅ Performance: Validated (200+ concurrent WebSocket clients, batch operations)
See [docs/TESTING_COMPLETE.md](docs/TESTING_COMPLETE.md) for comprehensive testing documentation.
## Technology Stack
- **Web Framework**: FastAPI 0.104.1
- **Database**: SQLite + SQLAlchemy 2.0
- **Auth**: JWT (python-jose) + passlib
- **Validation**: Pydantic 2.5
- **Logging**: structlog
- **Testing**: pytest + pytest-asyncio
## Application Lifecycle
### Initialization
On first startup, the application performs a one-time sync of series from data files to the database:
1. FastAPI lifespan starts
2. Database is initialized
3. `sync_series_from_data_files()` reads all data files from the anime directory (creates temporary SeriesApp)
4. Series metadata is synced to the database
5. DownloadService initializes (triggers main `SeriesApp` creation)
6. `SeriesApp` loads series from database via service layer (not from files)
On subsequent startups, the same flow applies but the sync finds no new series. `SeriesApp` always initializes with an empty series list (`skip_load=True`) and loads data from the database on demand, avoiding redundant file system scans.
### Adding New Series
When adding a new series:
1. Series is added to the database via `AnimeService`
2. Data file is created in the anime directory
3. In-memory `SerieList` is updated via `load_series_from_list()`
## License
MIT License

215
SERVER_COMMANDS.md Normal file
View File

@@ -0,0 +1,215 @@
# Server Management Commands
Quick reference for starting, stopping, and managing the Aniworld server.
## Start Server
### Using the start script (Recommended)
```bash
./start_server.sh
```
### Using conda directly
```bash
conda run -n AniWorld python run_server.py
```
### Using uvicorn directly
```bash
conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000 --reload
```
## Stop Server
### Using the stop script (Recommended)
```bash
./stop_server.sh
```
### Manual commands
**Kill uvicorn processes:**
```bash
pkill -f "uvicorn.*fastapi_app:app"
```
**Kill process on port 8000:**
```bash
lsof -ti:8000 | xargs kill -9
```
**Kill run_server.py processes:**
```bash
pkill -f "run_server.py"
```
## Check Server Status
**Check if port 8000 is in use:**
```bash
lsof -i:8000
```
**Check for running uvicorn processes:**
```bash
ps aux | grep uvicorn
```
**Check server is responding:**
```bash
curl http://127.0.0.1:8000/api/health
```
## Restart Server
```bash
./stop_server.sh && ./start_server.sh
```
## Common Issues
### "Address already in use" Error
**Problem:** Port 8000 is already occupied
**Solution:**
```bash
./stop_server.sh
# or
lsof -ti:8000 | xargs kill -9
```
### Server not responding
**Check logs:**
```bash
tail -f logs/app.log
```
**Check if process is running:**
```bash
ps aux | grep uvicorn
```
### Cannot connect to server
**Verify server is running:**
```bash
curl http://127.0.0.1:8000/api/health
```
**Check firewall:**
```bash
sudo ufw status
```
## Development Mode
**Run with auto-reload:**
```bash
./start_server.sh # Already includes --reload
```
**Run with custom port:**
```bash
conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8080 --reload
```
**Run with debug logging:**
```bash
export LOG_LEVEL=DEBUG
./start_server.sh
```
## Production Mode
**Run without auto-reload:**
```bash
conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 0.0.0.0 --port 8000 --workers 4
```
**Run with systemd (Linux):**
```bash
sudo systemctl start aniworld
sudo systemctl stop aniworld
sudo systemctl restart aniworld
sudo systemctl status aniworld
```
## URLs
- **Web Interface:** http://127.0.0.1:8000
- **API Documentation:** http://127.0.0.1:8000/api/docs
- **Login Page:** http://127.0.0.1:8000/login
- **Queue Management:** http://127.0.0.1:8000/queue
- **Health Check:** http://127.0.0.1:8000/api/health
## Default Credentials
- **Password:** `Hallo123!`
## Log Files
- **Application logs:** `logs/app.log`
- **Download logs:** `logs/downloads/`
- **Error logs:** Check console output or systemd journal
## Quick Troubleshooting
| Symptom | Solution |
| ------------------------ | ------------------------------------ |
| Port already in use | `./stop_server.sh` |
| Server won't start | Check `logs/app.log` |
| 404 errors | Verify URL and check routing |
| WebSocket not connecting | Check server is running and firewall |
| Slow responses | Check system resources (`htop`) |
| Database errors | Check `data/` directory permissions |
## Environment Variables
```bash
# Set log level
export LOG_LEVEL=DEBUG|INFO|WARNING|ERROR
# Set server port
export PORT=8000
# Set host
export HOST=127.0.0.1
# Set workers (production)
export WORKERS=4
```
## Related Scripts
- `start_server.sh` - Start the server
- `stop_server.sh` - Stop the server
- `run_server.py` - Python server runner
- `scripts/setup.py` - Initial setup
## More Information
- [User Guide](docs/user_guide.md)
- [API Reference](docs/api_reference.md)
- [Deployment Guide](docs/deployment.md)

Binary file not shown.

24
data/config.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "Aniworld",
"data_dir": "data",
"scheduler": {
"enabled": true,
"interval_minutes": 60
},
"logging": {
"level": "INFO",
"file": null,
"max_bytes": null,
"backup_count": 3
},
"backup": {
"enabled": false,
"path": "data/backups",
"keep_days": 30
},
"other": {
"master_password_hash": "$pbkdf2-sha256$29000$854zxnhvzXmPsVbqvXduTQ$G0HVRAt3kyO5eFwvo.ILkpX9JdmyXYJ9MNPTS/UxAGk",
"anime_directory": "/mnt/server/serien/Serien/"
},
"version": "1.0.0"
}

View File

@@ -0,0 +1,24 @@
{
"name": "Aniworld",
"data_dir": "data",
"scheduler": {
"enabled": true,
"interval_minutes": 60
},
"logging": {
"level": "INFO",
"file": null,
"max_bytes": null,
"backup_count": 3
},
"backup": {
"enabled": false,
"path": "data/backups",
"keep_days": 30
},
"other": {
"master_password_hash": "$pbkdf2-sha256$29000$VCqllLL2vldKyTmHkJIyZg$jNllpzlpENdgCslmS.tG.PGxRZ9pUnrqFEQFveDEcYk",
"anime_directory": "/mnt/server/serien/Serien/"
},
"version": "1.0.0"
}

View File

@@ -0,0 +1,24 @@
{
"name": "Aniworld",
"data_dir": "data",
"scheduler": {
"enabled": true,
"interval_minutes": 60
},
"logging": {
"level": "INFO",
"file": null,
"max_bytes": null,
"backup_count": 3
},
"backup": {
"enabled": false,
"path": "data/backups",
"keep_days": 30
},
"other": {
"master_password_hash": "$pbkdf2-sha256$29000$3/t/7733PkdoTckZQyildA$Nz9SdX2ZgqBwyzhQ9FGNcnzG1X.TW9oce3sDxJbVSdY",
"anime_directory": "/mnt/server/serien/Serien/"
},
"version": "1.0.0"
}

327
data/download_queue.json Normal file
View File

@@ -0,0 +1,327 @@
{
"pending": [
{
"id": "ae6424dc-558b-4946-9f07-20db1a09bf33",
"serie_id": "test-series-2",
"serie_folder": "Another Series (2024)",
"serie_name": "Another Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "HIGH",
"added_at": "2025-11-28T17:54:38.593236Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "011c2038-9fe3-41cb-844f-ce50c40e415f",
"serie_id": "series-high",
"serie_folder": "Series High (2024)",
"serie_name": "Series High",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "HIGH",
"added_at": "2025-11-28T17:54:38.632289Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "0eee56e0-414d-4cd7-8da7-b5a139abd8b5",
"serie_id": "series-normal",
"serie_folder": "Series Normal (2024)",
"serie_name": "Series Normal",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:38.635082Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "eea9f4f3-98e5-4041-9fc6-92e3d4c6fee6",
"serie_id": "series-low",
"serie_folder": "Series Low (2024)",
"serie_name": "Series Low",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "LOW",
"added_at": "2025-11-28T17:54:38.637038Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "b6f84ea9-86c8-4cc9-90e5-c7c6ce10c593",
"serie_id": "test-series",
"serie_folder": "Test Series (2024)",
"serie_name": "Test Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:38.801266Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "412aa28d-9763-41ef-913d-3d63919f9346",
"serie_id": "test-series",
"serie_folder": "Test Series (2024)",
"serie_name": "Test Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:38.867939Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "3a036824-2d14-41dd-81b8-094dd322a137",
"serie_id": "invalid-series",
"serie_folder": "Invalid Series (2024)",
"serie_name": "Invalid Series",
"episode": {
"season": 99,
"episode": 99,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:38.935125Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "1f4108ed-5488-4f46-ad5b-fe27e3b04790",
"serie_id": "test-series",
"serie_folder": "Test Series (2024)",
"serie_name": "Test Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:38.968296Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "5e880954-1a9f-450a-8008-5b9d6ac07d66",
"serie_id": "series-2",
"serie_folder": "Series 2 (2024)",
"serie_name": "Series 2",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.055885Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "2415ac21-509b-4d71-b5b9-b824116d6785",
"serie_id": "series-0",
"serie_folder": "Series 0 (2024)",
"serie_name": "Series 0",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.056795Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "716f9823-d59a-4b04-863b-c75fd54bc464",
"serie_id": "series-1",
"serie_folder": "Series 1 (2024)",
"serie_name": "Series 1",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.057486Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "36ad4323-daa9-49c4-97e8-a0aec0cca7a1",
"serie_id": "series-4",
"serie_folder": "Series 4 (2024)",
"serie_name": "Series 4",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.058179Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "695ee7a9-42bb-4953-9a8a-10bd7f533369",
"serie_id": "series-3",
"serie_folder": "Series 3 (2024)",
"serie_name": "Series 3",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.058816Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "aa948908-c410-42ec-85d6-a0298d7d95a5",
"serie_id": "persistent-series",
"serie_folder": "Persistent Series (2024)",
"serie_name": "Persistent Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.152427Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "2537f20e-f394-4c68-81d5-48be3c0c402a",
"serie_id": "ws-series",
"serie_folder": "WebSocket Series (2024)",
"serie_name": "WebSocket Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "NORMAL",
"added_at": "2025-11-28T17:54:39.219061Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
},
{
"id": "aaaf3b05-cce8-47d5-b350-59c5d72533ad",
"serie_id": "workflow-series",
"serie_folder": "Workflow Test Series (2024)",
"serie_name": "Workflow Test Series",
"episode": {
"season": 1,
"episode": 1,
"title": null
},
"status": "pending",
"priority": "HIGH",
"added_at": "2025-11-28T17:54:39.254462Z",
"started_at": null,
"completed_at": null,
"progress": null,
"error": null,
"retry_count": 0,
"source_url": null
}
],
"active": [],
"failed": [],
"timestamp": "2025-11-28T17:54:39.259761+00:00"
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,814 +0,0 @@
# Architecture Documentation
## Document Purpose
This document describes the system architecture of the Aniworld anime download manager.
---
## 1. System Overview
Aniworld is a web-based anime download manager built with Python, FastAPI, and SQLite. It provides a REST API and WebSocket interface for managing anime libraries, downloading episodes, and tracking progress.
### High-Level Architecture
```
+------------------+ +------------------+ +------------------+
| Web Browser | | CLI Client | | External |
| (Frontend) | | (Main.py) | | Providers |
+--------+---------+ +--------+---------+ +--------+---------+
| | |
| HTTP/WebSocket | Direct | HTTP
| | |
+--------v---------+ +--------v---------+ +--------v---------+
| | | | | |
| FastAPI <-----> Core Layer <-----> Provider |
| Server Layer | | (SeriesApp) | | Adapters |
| | | | | |
+--------+---------+ +--------+---------+ +------------------+
| |
| |
+--------v---------+ +--------v---------+
| | | |
| SQLite DB | | File System |
| (aniworld.db) | | (anime/*/) |
| - Series data | | - Video files |
| - Episodes | | - NFO files |
| - Queue state | | - Media files |
+------------------+ +------------------+
```
Source: [src/server/fastapi_app.py](../src/server/fastapi_app.py#L1-L252)
---
## 2. Architectural Layers
### 2.1 CLI Layer (`src/cli/`)
Legacy command-line interface for direct interaction with the core layer.
| Component | File | Purpose |
| --------- | ----------------------------- | --------------- |
| Main | [Main.py](../src/cli/Main.py) | CLI entry point |
### 2.2 Server Layer (`src/server/`)
FastAPI-based REST API and WebSocket server.
```
src/server/
+-- fastapi_app.py # Application entry point, lifespan management
+-- api/ # API route handlers
| +-- anime.py # /api/anime/* endpoints
| +-- auth.py # /api/auth/* endpoints
| +-- config.py # /api/config/* endpoints
| +-- download.py # /api/queue/* endpoints
| +-- scheduler.py # /api/scheduler/* endpoints
| +-- nfo.py # /api/nfo/* endpoints
| +-- websocket.py # /ws/* WebSocket handlers
| +-- health.py # /health/* endpoints
+-- controllers/ # Page controllers for HTML rendering
| +-- page_controller.py # UI page routes
| +-- health_controller.py# Health check route
| +-- error_controller.py # Error pages (404, 500)
+-- services/ # Business logic
| +-- anime_service.py # Anime operations
| +-- auth_service.py # Authentication
| +-- config_service.py # Configuration management
| +-- download_service.py # Download queue management
| +-- progress_service.py # Progress tracking
| +-- websocket_service.py# WebSocket broadcasting
| +-- queue_repository.py # Database persistence
| +-- nfo_service.py # NFO metadata management
+-- models/ # Pydantic models
| +-- auth.py # Auth request/response models
| +-- config.py # Configuration models
| +-- download.py # Download queue models
| +-- websocket.py # WebSocket message models
+-- middleware/ # Request processing
| +-- auth.py # JWT validation, rate limiting
| +-- error_handler.py # Exception handlers
| +-- setup_redirect.py # Setup flow redirect
+-- database/ # SQLAlchemy ORM
| +-- connection.py # Database connection
| +-- models.py # ORM models
| +-- service.py # Database service
+-- utils/ # Utility modules
| +-- filesystem.py # Folder sanitization, path safety
| +-- validators.py # Input validation utilities
| +-- dependencies.py # FastAPI dependency injection
+-- web/ # Static files and templates
+-- static/ # CSS, JS, images
+-- templates/ # Jinja2 templates
```
Source: [src/server/](../src/server/)
### 2.2.1 Frontend Architecture (`src/server/web/static/`)
The frontend uses a modular architecture with no build step required. CSS and JavaScript files are organized by responsibility.
#### CSS Structure
```
src/server/web/static/css/
+-- styles.css # Entry point with @import statements
+-- base/
| +-- variables.css # CSS custom properties (colors, fonts, spacing)
| +-- reset.css # CSS reset and normalize styles
| +-- typography.css # Font styles, headings, text utilities
+-- components/
| +-- buttons.css # All button styles
| +-- cards.css # Card and panel components
| +-- forms.css # Form inputs, labels, validation styles
| +-- modals.css # Modal and overlay styles
| +-- navigation.css # Header, nav, sidebar styles
| +-- progress.css # Progress bars, loading indicators
| +-- notifications.css # Toast, alerts, messages
| +-- tables.css # Table and list styles
| +-- status.css # Status badges and indicators
+-- pages/
| +-- login.css # Login page specific styles
| +-- index.css # Index/library page specific styles
| +-- queue.css # Queue page specific styles
+-- utilities/
+-- animations.css # Keyframes and animation classes
+-- responsive.css # Media queries and breakpoints
+-- helpers.css # Utility classes (hidden, flex, spacing)
```
#### JavaScript Structure
JavaScript uses the IIFE pattern with a shared `AniWorld` namespace for browser compatibility without build tools.
```
src/server/web/static/js/
+-- shared/ # Shared utilities used by all pages
| +-- constants.js # API endpoints, localStorage keys, defaults
| +-- auth.js # Token management (getToken, setToken, checkAuth)
| +-- api-client.js # Fetch wrapper with auto-auth headers
| +-- theme.js # Dark/light theme toggle
| +-- ui-utils.js # Toast notifications, format helpers
| +-- websocket-client.js # Socket.IO wrapper
+-- index/ # Index page modules
| +-- series-manager.js # Series list rendering and filtering
| +-- selection-manager.js# Multi-select and bulk download
| +-- search.js # Series search functionality
| +-- scan-manager.js # Library rescan operations
| +-- scheduler-config.js # Scheduler configuration
| +-- logging-config.js # Logging configuration
| +-- advanced-config.js # Advanced settings
| +-- main-config.js # Main configuration and backup
| +-- config-manager.js # Config modal orchestrator
| +-- socket-handler.js # WebSocket event handlers
| +-- app-init.js # Application initialization
+-- queue/ # Queue page modules
+-- queue-api.js # Queue API interactions
+-- queue-renderer.js # Queue list rendering
+-- progress-handler.js # Download progress updates
+-- queue-socket-handler.js # WebSocket events for queue
+-- queue-init.js # Queue page initialization
```
#### Module Pattern
All JavaScript modules follow the IIFE pattern with namespace:
```javascript
var AniWorld = window.AniWorld || {};
AniWorld.ModuleName = (function () {
"use strict";
// Private variables and functions
// Public API
return {
init: init,
publicMethod: publicMethod,
};
})();
```
Source: [src/server/web/static/](../src/server/web/static/)
### 2.3 Core Layer (`src/core/`)
Domain logic for anime series management.
```
src/core/
+-- SeriesApp.py # Main application facade
+-- SerieScanner.py # Directory scanning, targeted single-series scan
+-- entities/ # Domain entities
| +-- series.py # Serie class with sanitized_folder property
| +-- SerieList.py # SerieList collection with sanitized folder support
| +-- nfo_models.py # Pydantic models for tvshow.nfo (TVShowNFO, ActorInfo…)
+-- services/ # Domain services
| +-- nfo_service.py # NFO lifecycle: create / update tvshow.nfo
| +-- nfo_repair_service.py # Detect & repair incomplete tvshow.nfo files
| | # (parse_nfo_tags, find_missing_tags, NfoRepairService)
| +-- tmdb_client.py # Async TMDB API client
+-- utils/ # Utility helpers (no side-effects)
| +-- nfo_generator.py # TVShowNFO → XML serialiser
| +-- nfo_mapper.py # TMDB API dict → TVShowNFO (tmdb_to_nfo_model,
| | # _extract_rating_by_country, _extract_fsk_rating)
| +-- image_downloader.py # TMDB image downloader
+-- providers/ # External provider adapters
| +-- base_provider.py # Loader interface
| +-- provider_factory.py # Provider registry
+-- interfaces/ # Abstract interfaces
| +-- callbacks.py # Progress callback system
+-- exceptions/ # Domain exceptions
+-- Exceptions.py # Custom exceptions
```
**Key Components:**
| Component | Purpose |
| -------------- | -------------------------------------------------------------------------- |
| `SeriesApp` | Main application facade for anime operations |
| `SerieScanner` | Scans directories for anime; `scan_single_series()` for targeted scans |
| `Serie` | Domain entity with `sanitized_folder` property for filesystem-safe names |
| `SerieList` | Collection management with automatic folder creation using sanitized names |
**Initialization:**
`SeriesApp` is initialized with `skip_load=True` passed to `SerieList`, preventing automatic loading of series from data files on every instantiation. Series data is loaded once during application setup via `sync_series_from_data_files()` in the FastAPI lifespan, which reads data files and syncs them to the database. Subsequent operations load series from the database through the service layer.
Source: [src/core/](../src/core/)
### 2.4 Infrastructure Layer (`src/infrastructure/`)
Cross-cutting concerns.
```
src/infrastructure/
+-- logging/ # Structured logging setup
+-- security/ # Security utilities
```
### 2.5 Configuration Layer (`src/config/`)
Application settings management.
| Component | File | Purpose |
| --------- | ---------------------------------------- | ------------------------------- |
| Settings | [settings.py](../src/config/settings.py) | Environment-based configuration |
Source: [src/config/settings.py](../src/config/settings.py#L1-L96)
---
## 12. Startup Sequence
The FastAPI lifespan function (`src/server/fastapi_app.py`) runs the following steps on every server start.
### 12.1 Startup Order
```
1. Logging configured
2. Temp folder purged ← cleans leftover partial download files
+-- Iterate ./Temp/ and delete every file and sub-directory
+-- Create ./Temp/ if it does not exist
+-- Errors are logged as warnings; startup continues regardless
3. Database initialised (required abort on failure)
+-- SQLite file created / migrated via init_db()
4. Configuration loaded from data/config.json
+-- Synced to settings (ENV vars take precedence)
5. Progress & WebSocket services wired up
6. Series loaded from database into memory
7. Download service initialised (queue restored from DB)
8. Background loader service started
9. Scheduler service started
10. NFO repair scan (queue incomplete tvshow.nfo files for background reload)
```
### 12.2 Temp Folder Guarantee
Every server start begins with a clean `./Temp/` directory. This ensures that partial `.part` files or stale temp videos from a crashed or force-killed previous session are never left behind before new downloads start.
Source: [src/server/fastapi_app.py](../src/server/fastapi_app.py)
---
## 11. Graceful Shutdown
The application implements a comprehensive graceful shutdown mechanism that ensures data integrity and proper cleanup when the server is stopped via Ctrl+C (SIGINT) or SIGTERM.
### 11.1 Shutdown Sequence
```
1. SIGINT/SIGTERM received
+-- Uvicorn catches signal
+-- Stops accepting new requests
2. FastAPI lifespan shutdown triggered
+-- 30 second total timeout
3. WebSocket shutdown (5s timeout)
+-- Broadcast {"type": "server_shutdown"} to all clients
+-- Close each connection with code 1001 (Going Away)
+-- Clear connection tracking data
4. Download service stop (10s timeout)
+-- Set shutdown flag
+-- Persist active download as "pending" in database
+-- Cancel active download task
+-- Shutdown ThreadPoolExecutor with wait
5. Progress service cleanup
+-- Clear event subscribers
+-- Clear active progress tracking
6. Database cleanup (10s timeout)
+-- SQLite: Run PRAGMA wal_checkpoint(TRUNCATE)
+-- Dispose async engine
+-- Dispose sync engine
7. Process exits cleanly
```
Source: [src/server/fastapi_app.py](../src/server/fastapi_app.py#L142-L210)
### 11.2 Key Components
| Component | File | Shutdown Method |
| ------------------- | ------------------------------------------------------------------- | ------------------------------ |
| WebSocket Service | [websocket_service.py](../src/server/services/websocket_service.py) | `shutdown(timeout=5.0)` |
| Download Service | [download_service.py](../src/server/services/download_service.py) | `stop(timeout=10.0)` |
| Database Connection | [connection.py](../src/server/database/connection.py) | `close_db()` |
| Uvicorn Config | [run_server.py](../run_server.py) | `timeout_graceful_shutdown=30` |
| Stop Script | [stop_server.sh](../stop_server.sh) | SIGTERM with fallback |
### 11.3 Data Integrity Guarantees
1. **Active downloads preserved**: In-progress downloads are saved as "pending" and can resume on restart.
2. **Database WAL flushed**: SQLite WAL checkpoint ensures all writes are in the main database file.
3. **WebSocket clients notified**: Clients receive shutdown message before connection closes.
4. **Thread pool cleanup**: Background threads complete or are gracefully cancelled.
### 11.4 Manual Stop
```bash
# Graceful stop via script (sends SIGTERM, waits up to 30s)
./stop_server.sh
# Or press Ctrl+C in terminal running the server
```
Source: [stop_server.sh](../stop_server.sh#L1-L80)
---
## 3. Component Interactions
### 3.1 Request Flow (REST API)
```
1. Client sends HTTP request
2. AuthMiddleware validates JWT token (if required)
3. Rate limiter checks request frequency
4. FastAPI router dispatches to endpoint handler
5. Endpoint calls service layer
6. Service layer uses core layer or database
7. Response returned as JSON
```
Source: [src/server/middleware/auth.py](../src/server/middleware/auth.py#L1-L209)
### 3.2 Download Flow
```
1. POST /api/queue/add
+-- DownloadService.add_to_queue()
+-- QueueRepository.save_item() -> SQLite
2. POST /api/queue/start
+-- DownloadService.start_queue_processing()
+-- Process pending items sequentially
+-- ProgressService emits events
+-- WebSocketService broadcasts to clients
3. During download:
+-- Provider writes to ./Temp/<filename> (+ ./Temp/<filename>.part fragments)
+-- ProgressService.emit("progress_updated")
+-- WebSocketService.broadcast_to_room()
+-- Client receives WebSocket message
4. After download attempt (success OR failure):
+-- _cleanup_temp_file() removes ./Temp/<filename> and all .part fragments
+-- On success: file was already moved to final destination before cleanup
+-- On failure / exception: no partial files remain in ./Temp/
```
#### Temp Directory Contract
| Situation | Outcome |
| -------------------------------- | ------------------------------------------------------------------- |
| Server start | Entire `./Temp/` directory is purged before any service initialises |
| Successful download | Temp file moved to destination, then removed from `./Temp/` |
| Failed download (provider error) | Temp + `.part` fragments removed by `_cleanup_temp_file()` |
| Exception / cancellation | Temp + `.part` fragments removed in `except` block |
Source: [src/server/services/download_service.py](../src/server/services/download_service.py#L1-L150),
[src/core/providers/aniworld_provider.py](../src/core/providers/aniworld_provider.py),
[src/core/providers/enhanced_provider.py](../src/core/providers/enhanced_provider.py)
### 3.3 WebSocket Event Flow
```
1. Client connects to /ws/connect
2. Server sends "connected" message
3. Client joins room: {"action": "join", "data": {"room": "downloads"}}
4. ProgressService emits events
5. WebSocketService broadcasts to room subscribers
6. Client receives real-time updates
```
Source: [src/server/api/websocket.py](../src/server/api/websocket.py#L1-L260)
---
## 4. Design Patterns
### 4.1 Repository Pattern (Service Layer as Repository)
**Architecture Decision**: The Service Layer serves as the Repository layer for database access.
Database access is abstracted through service classes in `src/server/database/service.py` that provide CRUD operations and act as the repository layer. This eliminates the need for a separate repository layer while maintaining clean separation of concerns.
**Service Layer Classes** (acting as repositories):
- `AnimeSeriesService` - CRUD operations for anime series
- `EpisodeService` - CRUD operations for episodes
- `DownloadQueueService` - CRUD operations for download queue
- `UserSessionService` - CRUD operations for user sessions
- `SystemSettingsService` - CRUD operations for system settings
**Key Principles**:
1. **No Direct Database Queries**: Controllers and business logic services MUST use service layer methods
2. **Service Layer Encapsulation**: All SQLAlchemy queries are encapsulated in service methods
3. **Consistent Interface**: Services provide consistent async methods for all database operations
4. **Single Responsibility**: Each service manages one entity type
**Example Usage**:
```python
# CORRECT: Use service layer
from src.server.database.service import AnimeSeriesService
async with get_db_session() as db:
series = await AnimeSeriesService.get_by_key(db, "attack-on-titan")
await AnimeSeriesService.update(db, series.id, has_nfo=True)
# INCORRECT: Direct database query
result = await db.execute(select(AnimeSeries).filter(...)) # ❌ Never do this
```
**Special Case - Queue Repository Adapter**:
The `QueueRepository` in `src/server/services/queue_repository.py` is an adapter that wraps `DownloadQueueService` to provide domain model conversion between Pydantic models and SQLAlchemy models:
```python
# QueueRepository provides CRUD with model conversion
class QueueRepository:
async def save_item(self, item: DownloadItem) -> None: ... # Converts Pydantic → SQLAlchemy
async def get_all_items(self) -> List[DownloadItem]: ... # Converts SQLAlchemy → Pydantic
async def delete_item(self, item_id: str) -> bool: ...
```
Source: [src/server/database/service.py](../src/server/database/service.py), [src/server/services/queue_repository.py](../src/server/services/queue_repository.py)
### 4.2 Dependency Injection
FastAPI's `Depends()` provides constructor injection.
```python
@router.get("/status")
async def get_status(
download_service: DownloadService = Depends(get_download_service),
):
...
```
Source: [src/server/utils/dependencies.py](../src/server/utils/dependencies.py)
### 4.3 Event-Driven Architecture
Progress updates use an event subscription model.
```python
# ProgressService publishes events
progress_service.emit("progress_updated", event)
# WebSocketService subscribes
progress_service.subscribe("progress_updated", ws_handler)
```
Source: [src/server/fastapi_app.py](../src/server/fastapi_app.py#L98-L108)
### 4.4 Singleton Pattern
Services use module-level singletons for shared state.
```python
# In download_service.py
_download_service_instance: Optional[DownloadService] = None
def get_download_service() -> DownloadService:
global _download_service_instance
if _download_service_instance is None:
_download_service_instance = DownloadService(...)
return _download_service_instance
```
### 4.5 Error Handling Pattern
**Architecture Decision**: Dual error handling approach based on exception source.
The application uses two complementary error handling mechanisms:
1. **FastAPI HTTPException** - For simple validation and HTTP-level errors
2. **Custom Exception Hierarchy** - For business logic and service-level errors with rich context
#### Exception Hierarchy
```python
# Base exception with HTTP status mapping
AniWorldAPIException(message, status_code, error_code, details)
AuthenticationError (401)
AuthorizationError (403)
ValidationError (422)
NotFoundError (404)
ConflictError (409)
BadRequestError (400)
RateLimitError (429)
ServerError (500)
DownloadError
ConfigurationError
ProviderError
DatabaseError
```
#### When to Use Each
**Use HTTPException for:**
- Simple parameter validation (missing fields, wrong type)
- Direct HTTP-level errors (401, 403, 404 without business context)
- Quick endpoint-specific failures
**Use Custom Exceptions for:**
- Service-layer business logic errors (AnimeServiceError, ConfigServiceError)
- Errors needing rich context (details dict, error codes)
- Errors that should be logged with specific categorization
- Cross-cutting concerns (authentication, authorization, rate limiting)
**Example:**
```python
# Simple validation - Use HTTPException
if not series_key:
raise HTTPException(status_code=400, detail="series_key required")
# Business logic error - Use custom exception
try:
await anime_service.add_series(series_key)
except AnimeServiceError as e:
raise ServerError(
message=f"Failed to add series: {e}",
error_code="ANIME_ADD_FAILED",
details={"series_key": series_key}
)
```
#### Global Exception Handlers
All custom exceptions are automatically handled by global middleware that:
- Converts exceptions to structured JSON responses
- Logs errors with appropriate severity
- Includes request ID for tracking
- Provides consistent error format
**Source**: [src/server/exceptions/\_\_init\_\_.py](../src/server/exceptions/__init__.py), [src/server/middleware/error_handler.py](../src/server/middleware/error_handler.py)
Source: [src/server/services/download_service.py](../src/server/services/download_service.py)
---
## 5. Data Flow
### 5.1 Series Identifier Convention
The system uses two identifier fields:
| Field | Type | Purpose | Example |
| -------- | -------- | -------------------------------------- | -------------------------- |
| `key` | Primary | Provider-assigned, URL-safe identifier | `"attack-on-titan"` |
| `folder` | Metadata | Filesystem folder name (display only) | `"Attack on Titan (2013)"` |
All API operations use `key`. The `folder` is for filesystem operations only.
Source: [src/server/database/models.py](../src/server/database/models.py#L26-L50)
### 5.2 Database Schema
```
+----------------+ +----------------+ +--------------------+
| anime_series | | episodes | | download_queue_item|
+----------------+ +----------------+ +--------------------+
| id (PK) |<--+ | id (PK) | +-->| id (PK) |
| key (unique) | | | series_id (FK) |---+ | series_id (FK) |
| name | +---| season | | status |
| site | | episode_number | | priority |
| folder | | title | | progress_percent |
| created_at | | is_downloaded | | added_at |
| updated_at | | file_path | | started_at |
+----------------+ +----------------+ +--------------------+
```
Source: [src/server/database/models.py](../src/server/database/models.py#L1-L200)
### 5.3 Configuration Storage
Configuration is stored in `data/config.json`:
```json
{
"name": "Aniworld",
"data_dir": "data",
"scheduler": {
"enabled": true,
"schedule_time": "03:00",
"schedule_days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"],
"auto_download_after_rescan": false
},
"logging": { "level": "INFO" },
"backup": { "enabled": false, "path": "data/backups" },
"other": {
"master_password_hash": "$pbkdf2-sha256$...",
"anime_directory": "/path/to/anime"
}
}
```
Source: [data/config.json](../data/config.json)
---
## 6. Technology Stack
| Layer | Technology | Version | Purpose |
| ------------- | ------------------- | ------- | ---------------------- |
| Web Framework | FastAPI | 0.104.1 | REST API, WebSocket |
| ASGI Server | Uvicorn | 0.24.0 | HTTP server |
| Database | SQLite + SQLAlchemy | 2.0.35 | Persistence |
| Auth | python-jose | 3.3.0 | JWT tokens |
| Password | passlib | 1.7.4 | bcrypt hashing |
| Validation | Pydantic | 2.5.0 | Data models |
| Templates | Jinja2 | 3.1.2 | HTML rendering |
| Logging | structlog | 24.1.0 | Structured logging |
| Testing | pytest | 7.4.3 | Unit/integration tests |
Source: [requirements.txt](../requirements.txt)
---
## 7. Scalability Considerations
### Current Limitations
1. **Single-process deployment**: In-memory rate limiting and session state are not shared across processes.
2. **SQLite database**: Not suitable for high concurrency. Consider PostgreSQL for production.
3. **Sequential downloads**: Only one download processes at a time by design.
### Recommended Improvements for Scale
| Concern | Current | Recommended |
| -------------- | --------------- | ----------------- |
| Rate limiting | In-memory dict | Redis |
| Session store | In-memory | Redis or database |
| Database | SQLite | PostgreSQL |
| Task queue | In-memory deque | Celery + Redis |
| Load balancing | None | Nginx/HAProxy |
---
## 8. Integration Points
### 8.1 External Providers
The system integrates with anime streaming providers via the Loader interface.
```python
class Loader(ABC):
@abstractmethod
def search(self, query: str) -> List[Serie]: ...
@abstractmethod
def get_episodes(self, serie: Serie) -> Dict[int, List[int]]: ...
```
Source: [src/core/providers/base_provider.py](../src/core/providers/base_provider.py)
### 8.2 Filesystem Integration
The scanner reads anime directories to detect downloaded episodes.
```python
SerieScanner(
basePath="/path/to/anime", # Anime library directory
loader=provider, # Provider for metadata
db_session=session # Optional database
)
```
Source: [src/core/SerieScanner.py](../src/core/SerieScanner.py#L59-L96)
---
## 9. Security Architecture
### 9.1 Authentication Flow
```
1. User sets master password via POST /api/auth/setup
2. Password hashed with pbkdf2_sha256 (via passlib)
3. Hash stored in config.json
4. Login validates password, returns JWT token
5. JWT contains: session_id, user, created_at, expires_at
6. Subsequent requests include: Authorization: Bearer <token>
```
Source: [src/server/services/auth_service.py](../src/server/services/auth_service.py#L1-L150)
### 9.2 Password Requirements
- Minimum 8 characters
- Mixed case (upper and lower)
- At least one number
- At least one special character
Source: [src/server/services/auth_service.py](../src/server/services/auth_service.py#L97-L125)
### 9.3 Rate Limiting
| Endpoint | Limit | Window |
| ----------------- | ----------- | ---------- |
| `/api/auth/login` | 5 requests | 60 seconds |
| `/api/auth/setup` | 5 requests | 60 seconds |
| All origins | 60 requests | 60 seconds |
Source: [src/server/middleware/auth.py](../src/server/middleware/auth.py#L54-L68)
---
## 10. Deployment Modes
### 10.1 Development
```bash
# Run with hot reload
python -m uvicorn src.server.fastapi_app:app --reload
```
### 10.2 Production
```bash
# Via conda environment
conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app \
--host 127.0.0.1 --port 8000
```
### 10.3 Configuration
Environment variables (via `.env` or shell):
| Variable | Default | Description |
| ----------------- | ------------------------------ | ---------------------- |
| `JWT_SECRET_KEY` | Random | Secret for JWT signing |
| `DATABASE_URL` | `sqlite:///./data/aniworld.db` | Database connection |
| `ANIME_DIRECTORY` | (empty) | Path to anime library |
| `LOG_LEVEL` | `INFO` | Logging level |
| `CORS_ORIGINS` | `localhost:3000,8000` | Allowed CORS origins |
Source: [src/config/settings.py](../src/config/settings.py#L1-L96)

View File

@@ -1,220 +0,0 @@
# Changelog
## Document Purpose
This document tracks all notable changes to the Aniworld project.
### What This Document Contains
- **Version History**: All released versions with dates
- **Added Features**: New functionality in each release
- **Changed Features**: Modifications to existing features
- **Deprecated Features**: Features marked for removal
- **Removed Features**: Features removed from the codebase
- **Fixed Bugs**: Bug fixes with issue references
- **Security Fixes**: Security-related changes
- **Breaking Changes**: Changes requiring user action
### What This Document Does NOT Contain
- Internal refactoring details (unless user-facing)
- Commit-level changes
- Work-in-progress features
- Roadmap or planned features
### Target Audience
- All users and stakeholders
- Operators planning upgrades
- Developers tracking changes
- Support personnel
---
## Format
This changelog follows [Keep a Changelog](https://keepachangelog.com/) principles and adheres to [Semantic Versioning](https://semver.org/).
---
## [1.3.1] - 2026-02-22
### Added
- **Temp file cleanup after every download** (`src/core/providers/aniworld_provider.py`,
`src/core/providers/enhanced_provider.py`): Module-level helper
`_cleanup_temp_file()` removes the working temp file and any yt-dlp `.part`
fragments after each download attempt — on success, on failure, and on
exceptions (including `BrokenPipeError` and cancellation). Ensures that no
partial files accumulate in `./Temp/` across multiple runs.
- **Temp folder purge on server start** (`src/server/fastapi_app.py`): The
FastAPI lifespan startup now iterates `./Temp/` and deletes every file and
sub-directory before the rest of the initialisation sequence runs. If the
folder does not exist it is created. Errors are caught and logged as warnings
so that they never abort startup.
---
## [1.3.0] - 2026-02-22
### Added
- **NFO tag completeness (`nfo_mapper.py`)**: All 17 required NFO tags are now
explicitly populated during creation: `originaltitle`, `sorttitle`, `year`,
`plot`, `outline`, `tagline`, `runtime`, `premiered`, `status`, `imdbid`,
`genre`, `studio`, `country`, `actor`, `watched`, `dateadded`, `mpaa`.
- **`src/core/utils/nfo_mapper.py`**: New module containing
`tmdb_to_nfo_model()`, `_extract_rating_by_country()`, and
`_extract_fsk_rating()`. Extracted from `NFOService` to keep files under
500 lines and isolate pure mapping logic.
- **US MPAA rating**: `_extract_rating_by_country(ratings, "US")` now maps the
US TMDB content rating to the `<mpaa>` NFO tag.
- **`NfoRepairService` (`src/core/services/nfo_repair_service.py`)**: New service
that detects incomplete `tvshow.nfo` files and triggers TMDB re-fetch.
Provides `parse_nfo_tags()`, `find_missing_tags()`, `nfo_needs_repair()`, and
`NfoRepairService.repair_series()`. 13 required tags are checked.
- **`perform_nfo_repair_scan()` startup hook
(`src/server/services/initialization_service.py`)**: New async function
called during application startup. Iterates every series directory, checks
whether `tvshow.nfo` is missing required tags using `nfo_needs_repair()`, and
either queues the series for background reload (when a `background_loader` is
provided) or calls `NfoRepairService.repair_series()` directly. Skips
gracefully when `tmdb_api_key` or `anime_directory` is not configured.
- **NFO repair wired into startup lifespan (`src/server/fastapi_app.py`)**:
`perform_nfo_repair_scan(background_loader)` is called at the end of the
FastAPI lifespan startup, after `perform_media_scan_if_needed`, ensuring
every existing series NFO is checked and repaired on each server start.
### Changed
- `NFOService._tmdb_to_nfo_model()` and `NFOService._extract_fsk_rating()` moved
to `src/core/utils/nfo_mapper.py` as module-level functions
`tmdb_to_nfo_model()` and `_extract_fsk_rating()`.
- `src/core/services/nfo_service.py` reduced from 640 → 471 lines.
---
## [Unreleased] - 2026-01-18
### Added
- **Cron-based Scheduler**: Replaced the asyncio sleep-loop with APScheduler's `AsyncIOScheduler + CronTrigger`
- Schedule rescans at a specific **time of day** (`HH:MM`) on selected **days of the week**
- New `SchedulerConfig` fields: `schedule_time` (default `"03:00"`), `schedule_days` (default all 7), `auto_download_after_rescan` (default `false`)
- Old `interval_minutes` field retained for backward compatibility
- **Auto-download after rescan**: When `auto_download_after_rescan` is enabled, missing episodes are automatically queued for download after each scheduled rescan
- **Day-of-week UI**: New day-of-week pill toggles (MonSun) in the Settings → Scheduler section
- **Live config reload**: POST `/api/scheduler/config` reschedules the APScheduler job without restarting the application
- **Enriched API response**: GET/POST `/api/scheduler/config` now returns `{"success", "config", "status"}` envelope including `next_run`, `last_run`, and `scan_in_progress`
### Changed
- Scheduler API response format: previously returned flat config; now returns `{"success": true, "config": {...}, "status": {...}}`
- `reload_config()` is now a synchronous method accepting a `SchedulerConfig` argument (previously async, no arguments)
- Dependencies: added `APScheduler>=3.10.4` to `requirements.txt`
### Fixed
- **Series Visibility**: Fixed issue where series added to the database weren't appearing in the API/UI
- Series are now loaded from database into SeriesApp's in-memory cache on startup
- Added `_load_series_from_db()` call after initial database sync in FastAPI lifespan
- **Episode Tracking**: Fixed missing episodes not being saved to database when adding new series
- Missing episodes are now persisted to the `episodes` table after the targeted scan
- Episodes are properly synced during rescan operations (added/removed based on filesystem state)
- **Database Synchronization**: Improved data consistency between database and in-memory cache
- Rescan process properly updates episodes: adds new missing episodes, removes downloaded ones
- All series operations now maintain database and cache synchronization
### Technical Details
- Modified `src/server/fastapi_app.py` to load series from database after sync
- Modified `src/server/api/anime.py` to save scanned episodes to database
- Episodes table properly tracks missing episodes with automatic cleanup
---
## Sections for Each Release
```markdown
## [Version] - YYYY-MM-DD
### Added
- New features
### Changed
- Changes to existing functionality
### Deprecated
- Features that will be removed in future versions
### Removed
- Features removed in this release
### Fixed
- Bug fixes
### Security
- Security-related fixes
```
---
## Unreleased
_Changes that are in development but not yet released._
### Added
- **Comprehensive Test Suite**: Created 1,070+ tests across 4 priority tiers
- **TIER 1 (Critical)**: 159 tests - Scheduler, NFO batch operations, download queue, persistence
- **TIER 2 (High Priority)**: 390 tests - JavaScript framework, dark mode, setup page, settings modal, WebSocket, queue UI
- **TIER 3 (Medium Priority)**: 156 tests - WebSocket load, concurrent operations, retry logic, NFO performance, series parsing, TMDB integration
- **TIER 4 (Polish)**: 426 tests - Internationalization (89), user preferences (68), accessibility (250+), media server compatibility (19)
- **Frontend Testing Infrastructure**: Vitest for unit tests, Playwright for E2E tests
- **Security Test Coverage**: Complete testing for authentication, authorization, CSRF, XSS, SQL injection
- **Performance Validation**: WebSocket load (200+ concurrent clients), batch operations, concurrent access
- **Accessibility Tests**: WCAG 2.1 AA compliance testing (keyboard navigation, ARIA labels, screen readers)
- **Media Server Compatibility**: NFO format validation for Kodi, Plex, Jellyfin, and Emby
### Changed
- Updated testing documentation (TESTING_COMPLETE.md, instructions.md) to reflect 100% completion of all test tiers
### Fixed
- **Enhanced Anime Add Flow**: Automatic database persistence, targeted episode scanning, and folder creation with sanitized names
- Filesystem utility module (`src/server/utils/filesystem.py`) with `sanitize_folder_name()`, `is_safe_path()`, and `create_safe_folder()` functions
- `Serie.sanitized_folder` property for generating filesystem-safe folder names from display names
- `SerieScanner.scan_single_series()` method for targeted scanning of individual anime without full library rescan
- Add series API response now includes `missing_episodes` list and `total_missing` count
- Database transaction support with `@transactional` decorator and `atomic()` context manager
- Transaction propagation modes (REQUIRED, REQUIRES_NEW, NESTED) for fine-grained control
- Savepoint support for nested transactions with partial rollback capability
- `TransactionManager` helper class for manual transaction control
- Bulk operations: `bulk_mark_downloaded`, `bulk_delete`, `clear_all` for batch processing
- `rotate_session` atomic operation for secure session rotation
- Transaction utilities: `is_session_in_transaction`, `get_session_transaction_depth`
- `get_transactional_session` for sessions without auto-commit
### Changed
- `QueueRepository.save_item()` now uses atomic transactions for data consistency
- `QueueRepository.clear_all()` now uses atomic transactions for all-or-nothing behavior
- Service layer documentation updated to reflect transaction-aware design
### Fixed
- Scan status indicator now correctly shows running state after page reload during active scan
- Improved reliability of process status updates in the UI header
---
## Version History
_To be documented as versions are released._

View File

@@ -1,370 +0,0 @@
# Configuration Reference
## Document Purpose
This document provides a comprehensive reference for all configuration options in the Aniworld application.
---
## 1. Configuration Overview
### Configuration Sources
Aniworld uses a layered configuration system with **explicit precedence rules**:
1. **Environment Variables** (highest priority) - Takes precedence over all other sources
2. **`.env` file** in project root - Loaded as environment variables
3. **`data/config.json`** file - Persistent file-based configuration
4. **Default values** (lowest priority) - Built-in fallback values
### Precedence Rules
**Critical Principle**: `ENV VARS > config.json > defaults`
- **Environment variables always win**: If a value is set via environment variable, it will NOT be overridden by config.json
- **config.json as fallback**: If an ENV var is not set (or is empty/default), the value from config.json is used
- **Defaults as last resort**: Built-in default values are used only if neither ENV var nor config.json provide a value
### Loading Mechanism
Configuration is loaded at application startup in `src/server/fastapi_app.py`:
1. **Pydantic Settings** loads ENV vars and .env file with defaults
2. **config.json** is loaded via `ConfigService`
3. **Selective sync**: config.json values sync to settings **only if** ENV var not set
4. **Runtime access**: Code uses `settings` object (which has final merged values)
**Example**:
```bash
# If ENV var is set:
ANIME_DIRECTORY=/env/path # This takes precedence
# config.json has:
{"other": {"anime_directory": "/config/path"}} # This is ignored
# Result: settings.anime_directory = "/env/path"
```
**Source**: [src/config/settings.py](../src/config/settings.py#L1-L96), [src/server/fastapi_app.py](../src/server/fastapi_app.py#L139-L185)
---
## 2. Environment Variables
### Authentication Settings
| Variable | Type | Default | Description |
| ----------------------- | ------ | ---------------- | ------------------------------------------------------------------- |
| `JWT_SECRET_KEY` | string | (random) | Secret key for JWT token signing. Auto-generated if not set. |
| `PASSWORD_SALT` | string | `"default-salt"` | Salt for password hashing. |
| `MASTER_PASSWORD_HASH` | string | (none) | Pre-hashed master password. Loaded from config.json if not set. |
| `MASTER_PASSWORD` | string | (none) | **DEVELOPMENT ONLY** - Plaintext password. Never use in production. |
| `SESSION_TIMEOUT_HOURS` | int | `24` | JWT token expiry time in hours. |
Source: [src/config/settings.py](../src/config/settings.py#L13-L42)
### Server Settings
| Variable | Type | Default | Description |
| ----------------- | ------ | -------------------------------- | --------------------------------------------------------------------- |
| `ANIME_DIRECTORY` | string | `""` | Path to anime library directory. |
| `LOG_LEVEL` | string | `"INFO"` | Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL. |
| `DATABASE_URL` | string | `"sqlite:///./data/aniworld.db"` | Database connection string. |
| `CORS_ORIGINS` | string | `"http://localhost:3000"` | Comma-separated allowed CORS origins. Use `*` for localhost defaults. |
| `API_RATE_LIMIT` | int | `100` | Maximum API requests per minute. |
Source: [src/config/settings.py](../src/config/settings.py#L43-L68)
### Provider Settings
| Variable | Type | Default | Description |
| ------------------ | ------ | --------------- | --------------------------------------------- |
| `DEFAULT_PROVIDER` | string | `"aniworld.to"` | Default anime provider. |
| `PROVIDER_TIMEOUT` | int | `30` | HTTP timeout for provider requests (seconds). |
| `RETRY_ATTEMPTS` | int | `3` | Number of retry attempts for failed requests. |
Source: [src/config/settings.py](../src/config/settings.py#L69-L79)
### NFO Settings
| Variable | Type | Default | Description |
| --------------------- | ------ | -------- | -------------------------------------------------- |
| `TMDB_API_KEY` | string | `""` | The Movie Database (TMDB) API key for metadata. |
| `NFO_AUTO_CREATE` | bool | `true` | Automatically create NFO files during downloads. |
| `NFO_UPDATE_ON_SCAN` | bool | `false` | Update existing NFO files when scanning library. |
| `NFO_DOWNLOAD_POSTER` | bool | `true` | Download poster images along with NFO files. |
| `NFO_DOWNLOAD_LOGO` | bool | `false` | Download logo images along with NFO files. |
| `NFO_DOWNLOAD_FANART` | bool | `false` | Download fanart images along with NFO files. |
| `NFO_IMAGE_SIZE` | string | `"w500"` | Image size for TMDB images (w500, w780, original). |
Source: [src/server/models/config.py](../src/server/models/config.py#L109-L132)
---
## 3. Configuration File (config.json)
Location: `data/config.json`
### File Structure
```json
{
"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": false
},
"logging": {
"level": "INFO",
"file": null,
"max_bytes": null,
"backup_count": 3
},
"backup": {
"enabled": false,
"path": "data/backups",
"keep_days": 30
},
"nfo": {
"tmdb_api_key": "",
"auto_create": true,
"update_on_scan": false,
"download_poster": true,
"download_logo": false,
"download_fanart": false,
"image_size": "w500"
},
"other": {
"master_password_hash": "$pbkdf2-sha256$...",
"anime_directory": "/path/to/anime"
},
"version": "1.0.0"
}
```
Source: [data/config.json](../data/config.json)
---
## 4. Configuration Sections
### 4.1 General Settings
| Field | Type | Default | Description |
| ---------- | ------ | ------------ | ------------------------------ |
| `name` | string | `"Aniworld"` | Application name. |
| `data_dir` | string | `"data"` | Base directory for data files. |
Source: [src/server/models/config.py](../src/server/models/config.py#L62-L66)
### 4.2 Scheduler Settings
Controls automatic cron-based library rescanning (powered by APScheduler).
| Field | Type | Default | Description |
| -------------------------------------- | ------------ | --------------------------------------------- | -------------------------------------------------------------------- |
| `scheduler.enabled` | bool | `true` | Enable/disable automatic scans. |
| `scheduler.interval_minutes` | int | `60` | Legacy field kept for backward compatibility. Minimum: 1. |
| `scheduler.schedule_time` | string | `"03:00"` | Daily run time in 24-h `HH:MM` format. |
| `scheduler.schedule_days` | list[string] | `["mon","tue","wed","thu","fri","sat","sun"]` | Days of the week to run the scan. Empty list disables the cron job. |
| `scheduler.auto_download_after_rescan` | bool | `false` | Automatically queue missing episodes for download after each rescan. |
Valid day abbreviations: `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, `sun`.
Source: [src/server/models/config.py](../src/server/models/config.py#L5-L12)
### 4.3 Logging Settings
| Field | Type | Default | Description |
| ---------------------- | ------ | -------- | ------------------------------------------------- |
| `logging.level` | string | `"INFO"` | Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL. |
| `logging.file` | string | `null` | Optional log file path. |
| `logging.max_bytes` | int | `null` | Maximum log file size for rotation. |
| `logging.backup_count` | int | `3` | Number of rotated log files to keep. |
Source: [src/server/models/config.py](../src/server/models/config.py#L27-L46)
### 4.4 Backup Settings
| Field | Type | Default | Description |
| ------------------ | ------ | ---------------- | -------------------------------- |
| `backup.enabled` | bool | `false` | Enable automatic config backups. |
| `backup.path` | string | `"data/backups"` | Directory for backup files. |
| `backup.keep_days` | int | `30` | Days to retain backups. |
Source: [src/server/models/config.py](../src/server/models/config.py#L15-L24)
### 4.5 NFO Settings
| Field | Type | Default | Description |
| --------------------- | ------ | -------- | ------------------------------------------------------------- |
| `nfo.tmdb_api_key` | string | `""` | The Movie Database (TMDB) API key for fetching metadata. |
| `nfo.auto_create` | bool | `true` | Automatically create NFO files when downloading episodes. |
| `nfo.update_on_scan` | bool | `false` | Update existing NFO files during library scan operations. |
| `nfo.download_poster` | bool | `true` | Download poster images (poster.jpg) along with NFO files. |
| `nfo.download_logo` | bool | `false` | Download logo images (logo.png) along with NFO files. |
| `nfo.download_fanart` | bool | `false` | Download fanart images (fanart.jpg) along with NFO files. |
| `nfo.image_size` | string | `"w500"` | TMDB image size: `w500` (recommended), `w780`, or `original`. |
**Notes:**
- Obtain a TMDB API key from https://www.themoviedb.org/settings/api
- `auto_create` creates NFO files during the download process
- `update_on_scan` refreshes metadata when scanning existing anime
- Image downloads require valid `tmdb_api_key`
- Larger image sizes (`w780`, `original`) consume more storage space
Source: [src/server/models/config.py](../src/server/models/config.py#L109-L132)
### 4.6 Other Settings (Dynamic)
The `other` field stores arbitrary settings.
| Key | Type | Description |
| ---------------------- | ------ | --------------------------------------- |
| `master_password_hash` | string | Hashed master password (pbkdf2-sha256). |
| `anime_directory` | string | Path to anime library. |
| `advanced` | object | Advanced configuration options. |
---
## 5. Configuration Precedence
Settings are resolved in this order (first match wins):
1. Environment variable (e.g., `ANIME_DIRECTORY`)
2. `.env` file in project root
3. `data/config.json` (for dynamic settings)
4. Code defaults in `Settings` class
---
## 6. Validation Rules
### Password Requirements
Master password must meet all criteria:
- Minimum 8 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one digit
- At least one special character
Source: [src/server/services/auth_service.py](../src/server/services/auth_service.py#L97-L125)
### Logging Level Validation
Must be one of: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`
Source: [src/server/models/config.py](../src/server/models/config.py#L43-L47)
### Backup Path Validation
If `backup.enabled` is `true`, `backup.path` must be set.
Source: [src/server/models/config.py](../src/server/models/config.py#L87-L91)
---
## 7. Example Configurations
### Minimal Development Setup
**.env file:**
```
LOG_LEVEL=DEBUG
ANIME_DIRECTORY=/home/user/anime
```
### Production Setup
**.env file:**
```
JWT_SECRET_KEY=your-secure-random-key-here
DATABASE_URL=postgresql+asyncpg://user:pass@localhost/aniworld
LOG_LEVEL=WARNING
CORS_ORIGINS=https://your-domain.com
API_RATE_LIMIT=60
```
### Docker Setup
```yaml
# docker-compose.yml
environment:
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
- DATABASE_URL=sqlite:///./data/aniworld.db
- ANIME_DIRECTORY=/media/anime
- LOG_LEVEL=INFO
volumes:
- ./data:/app/data
- /media/anime:/media/anime:ro
```
---
## 8. Configuration Backup Management
### Automatic Backups
Backups are created automatically before config changes when `backup.enabled` is `true`.
Location: `data/config_backups/`
Naming: `config_backup_YYYYMMDD_HHMMSS.json`
### Manual Backup via API
```bash
# Create backup
curl -X POST http://localhost:8000/api/config/backups \
-H "Authorization: Bearer $TOKEN"
# List backups
curl http://localhost:8000/api/config/backups \
-H "Authorization: Bearer $TOKEN"
# Restore backup
curl -X POST http://localhost:8000/api/config/backups/config_backup_20251213.json/restore \
-H "Authorization: Bearer $TOKEN"
```
Source: [src/server/api/config.py](../src/server/api/config.py#L67-L142)
---
## 9. Troubleshooting
### Configuration Not Loading
1. Check file permissions on `data/config.json`
2. Verify JSON syntax with a validator
3. Check logs for Pydantic validation errors
### Environment Variable Not Working
1. Ensure variable name matches exactly (case-sensitive)
2. Check `.env` file location (project root)
3. Restart application after changes
### Master Password Issues
1. Password hash is stored in `config.json` under `other.master_password_hash`
2. Delete this field to reset (requires re-setup)
3. Check hash format starts with `$pbkdf2-sha256$`
---
## 10. Related Documentation
- [API.md](API.md) - Configuration API endpoints
- [DEVELOPMENT.md](DEVELOPMENT.md) - Development environment setup
- [ARCHITECTURE.md](ARCHITECTURE.md) - Configuration service architecture

View File

@@ -1,450 +0,0 @@
# Database Documentation
## Document Purpose
This document describes the database schema, models, and data layer of the Aniworld application.
---
## 1. Database Overview
### Technology
- **Database Engine**: SQLite 3 (default), PostgreSQL supported
- **ORM**: SQLAlchemy 2.0 with async support (aiosqlite)
- **Location**: `data/aniworld.db` (configurable via `DATABASE_URL`)
Source: [src/config/settings.py](../src/config/settings.py#L53-L55)
### Connection Configuration
```python
# Default connection string
DATABASE_URL = "sqlite+aiosqlite:///./data/aniworld.db"
# PostgreSQL alternative
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/aniworld"
```
Source: [src/server/database/connection.py](../src/server/database/connection.py)
---
## 2. Entity Relationship Diagram
```
+---------------------+ +-------------------+ +-------------------+ +------------------------+
| system_settings | | anime_series | | episodes | | download_queue_item |
+---------------------+ +-------------------+ +-------------------+ +------------------------+
| id (PK) | | id (PK) |<--+ | id (PK) | +-->| id (PK, VARCHAR) |
| initial_scan_... | | key (UNIQUE) | | | series_id (FK)----+---+ | series_id (FK)---------+
| initial_nfo_scan... | | name | +---| | | status |
| initial_media_... | | site | | season | | priority |
| last_scan_timestamp | | folder | | episode_number | | season |
| created_at | | created_at | | title | | episode |
| updated_at | | updated_at | | file_path | | progress_percent |
+---------------------+ +-------------------+ | is_downloaded | | error_message |
| created_at | | retry_count |
| updated_at | | added_at |
+-------------------+ | started_at |
| completed_at |
| created_at |
| updated_at |
+------------------------+
```
---
## 3. Table Schemas
### 3.1 system_settings
Stores application-wide system settings and initialization state.
| Column | Type | Constraints | Description |
| ------------------------------ | -------- | -------------------------- | --------------------------------------------- |
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Internal database ID (only one row) |
| `initial_scan_completed` | BOOLEAN | NOT NULL, DEFAULT FALSE | Whether initial anime folder scan is complete |
| `initial_nfo_scan_completed` | BOOLEAN | NOT NULL, DEFAULT FALSE | Whether initial NFO scan is complete |
| `initial_media_scan_completed` | BOOLEAN | NOT NULL, DEFAULT FALSE | Whether initial media scan is complete |
| `last_scan_timestamp` | DATETIME | NULLABLE | Timestamp of last completed scan |
| `created_at` | DATETIME | NOT NULL, DEFAULT NOW | Record creation timestamp |
| `updated_at` | DATETIME | NOT NULL, ON UPDATE NOW | Last update timestamp |
**Purpose:**
This table tracks the initialization status of the application to ensure that expensive one-time setup operations (like scanning the entire anime directory) only run on the first startup, not on every restart.
- Only one row exists in this table
- The `initial_scan_completed` flag prevents redundant full directory scans on each startup
- The NFO and media scan flags similarly track completion of those setup tasks
Source: [src/server/database/models.py](../src/server/database/models.py), [src/server/database/system_settings_service.py](../src/server/database/system_settings_service.py)
### 3.2 anime_series
Stores anime series metadata.
| Column | Type | Constraints | Description |
| ------------ | ------------- | -------------------------- | ------------------------------------------------------- |
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Internal database ID |
| `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 |
| `site` | VARCHAR(500) | NOT NULL | Provider site URL |
| `folder` | VARCHAR(1000) | NOT NULL | Filesystem folder name (metadata only) |
| `created_at` | DATETIME | NOT NULL, DEFAULT NOW | Record creation timestamp |
| `updated_at` | DATETIME | NOT NULL, ON UPDATE NOW | Last update timestamp |
**Identifier Convention:**
- `key` is the **primary identifier** for all operations (e.g., `"attack-on-titan"`)
- `folder` is **metadata only** for filesystem operations (e.g., `"Attack on Titan (2013)"`)
- `id` is used only for database relationships
Source: [src/server/database/models.py](../src/server/database/models.py#L23-L87)
### 3.3 episodes
Stores **missing episodes** that need to be downloaded. Episodes are automatically managed during scans:
- New missing episodes are added to the database
- Episodes that are no longer missing (files now exist) are removed from the database
- When an episode is downloaded, it can be marked with `is_downloaded=True` or removed from tracking
| Column | Type | Constraints | Description |
| ---------------- | ------------- | ---------------------------- | ----------------------------- |
| `id` | INTEGER | PRIMARY KEY, AUTOINCREMENT | Internal database ID |
| `series_id` | INTEGER | FOREIGN KEY, NOT NULL, INDEX | Reference to anime_series.id |
| `season` | INTEGER | NOT NULL | Season number (1-based) |
| `episode_number` | INTEGER | NOT NULL | Episode number within season |
| `title` | VARCHAR(500) | NULLABLE | Episode title if known |
| `file_path` | VARCHAR(1000) | NULLABLE | Local file path if downloaded |
| `is_downloaded` | BOOLEAN | NOT NULL, DEFAULT FALSE | Download status flag |
| `created_at` | DATETIME | NOT NULL, DEFAULT NOW | Record creation timestamp |
| `updated_at` | DATETIME | NOT NULL, ON UPDATE NOW | Last update timestamp |
**Foreign Key:**
- `series_id` -> `anime_series.id` (ON DELETE CASCADE)
Source: [src/server/database/models.py](../src/server/database/models.py#L122-L181)
### 3.4 download_queue_item
Stores download queue items with status tracking.
| Column | Type | Constraints | Description |
| ------------------ | ------------- | --------------------------- | ------------------------------ |
| `id` | VARCHAR(36) | PRIMARY KEY | UUID identifier |
| `series_id` | INTEGER | FOREIGN KEY, NOT NULL | Reference to anime_series.id |
| `season` | INTEGER | NOT NULL | Season number |
| `episode` | INTEGER | NOT NULL | Episode number |
| `status` | VARCHAR(20) | NOT NULL, DEFAULT 'pending' | Download status |
| `priority` | VARCHAR(10) | NOT NULL, DEFAULT 'NORMAL' | Queue priority |
| `progress_percent` | FLOAT | NULLABLE | Download progress (0-100) |
| `error_message` | TEXT | NULLABLE | Error description if failed |
| `retry_count` | INTEGER | NOT NULL, DEFAULT 0 | Number of retry attempts |
| `source_url` | VARCHAR(2000) | NULLABLE | Download source URL |
| `added_at` | DATETIME | NOT NULL, DEFAULT NOW | When added to queue |
| `started_at` | DATETIME | NULLABLE | When download started |
| `completed_at` | DATETIME | NULLABLE | When download completed/failed |
| `created_at` | DATETIME | NOT NULL, DEFAULT NOW | Record creation timestamp |
| `updated_at` | DATETIME | NOT NULL, ON UPDATE NOW | Last update timestamp |
**Status Values:** `pending`, `downloading`, `paused`, `completed`, `failed`, `cancelled`
**Priority Values:** `LOW`, `NORMAL`, `HIGH`
**Foreign Key:**
- `series_id` -> `anime_series.id` (ON DELETE CASCADE)
Source: [src/server/database/models.py](../src/server/database/models.py#L200-L300)
---
## 4. Indexes
| Table | Index Name | Columns | Purpose |
| --------------------- | ----------------------- | ----------- | --------------------------------- |
| `system_settings` | N/A (single row) | N/A | Only one row, no indexes needed |
| `anime_series` | `ix_anime_series_key` | `key` | Fast lookup by primary identifier |
| `anime_series` | `ix_anime_series_name` | `name` | Search by name |
| `episodes` | `ix_episodes_series_id` | `series_id` | Join with series |
| `download_queue_item` | `ix_download_series_id` | `series_id` | Filter by series |
| `download_queue_item` | `ix_download_status` | `status` | Filter by status |
---
## 5. Model Layer
### 5.1 SQLAlchemy ORM Models
```python
# src/server/database/models.py
class AnimeSeries(Base, TimestampMixin):
__tablename__ = "anime_series"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
key: Mapped[str] = mapped_column(String(255), unique=True, index=True)
name: Mapped[str] = mapped_column(String(500), index=True)
site: Mapped[str] = mapped_column(String(500))
folder: Mapped[str] = mapped_column(String(1000))
episodes: Mapped[List["Episode"]] = relationship(
"Episode", back_populates="series", cascade="all, delete-orphan"
)
```
Source: [src/server/database/models.py](../src/server/database/models.py#L23-L87)
### 5.2 Pydantic API Models
```python
# src/server/models/download.py
class DownloadItem(BaseModel):
id: str
serie_id: str # Maps to anime_series.key
serie_folder: str # Metadata only
serie_name: str
episode: EpisodeIdentifier
status: DownloadStatus
priority: DownloadPriority
```
Source: [src/server/models/download.py](../src/server/models/download.py#L63-L118)
### 5.3 Model Mapping
| API Field | Database Column | Notes |
| -------------- | --------------------- | ------------------ |
| `serie_id` | `anime_series.key` | Primary identifier |
| `serie_folder` | `anime_series.folder` | Metadata only |
| `serie_name` | `anime_series.name` | Display name |
---
## 6. Transaction Support
### 6.1 Overview
The database layer provides comprehensive transaction support to ensure data consistency across compound operations. All write operations can be wrapped in explicit transactions.
Source: [src/server/database/transaction.py](../src/server/database/transaction.py)
### 6.2 Transaction Utilities
| Component | Type | Description |
| ------------------------- | ----------------- | ---------------------------------------- |
| `@transactional` | Decorator | Wraps function in transaction boundary |
| `atomic()` | Async context mgr | Provides atomic operation block |
| `atomic_sync()` | Sync context mgr | Sync version of atomic() |
| `TransactionContext` | Class | Explicit sync transaction control |
| `AsyncTransactionContext` | Class | Explicit async transaction control |
| `TransactionManager` | Class | Helper for manual transaction management |
### 6.3 Transaction Propagation Modes
| Mode | Behavior |
| -------------- | ------------------------------------------------ |
| `REQUIRED` | Use existing transaction or create new (default) |
| `REQUIRES_NEW` | Always create new transaction |
| `NESTED` | Create savepoint within existing transaction |
### 6.4 Usage Examples
**Using @transactional decorator:**
```python
from src.server.database.transaction import transactional
@transactional()
async def compound_operation(db: AsyncSession, data: dict):
# All operations commit together or rollback on error
series = await AnimeSeriesService.create(db, ...)
episode = await EpisodeService.create(db, series_id=series.id, ...)
return series, episode
```
**Using atomic() context manager:**
```python
from src.server.database.transaction import atomic
async def some_function(db: AsyncSession):
async with atomic(db) as tx:
await operation1(db)
await operation2(db)
# Auto-commits on success, rolls back on exception
```
**Using savepoints for partial rollback:**
```python
async with atomic(db) as tx:
await outer_operation(db)
async with tx.savepoint() as sp:
await risky_operation(db)
if error_condition:
await sp.rollback() # Only rollback nested ops
await final_operation(db) # Still executes
```
Source: [src/server/database/transaction.py](../src/server/database/transaction.py)
### 6.5 Connection Module Additions
| Function | Description |
| ------------------------------- | -------------------------------------------- |
| `get_transactional_session` | Session without auto-commit for transactions |
| `TransactionManager` | Helper class for manual transaction control |
| `is_session_in_transaction` | Check if session is in active transaction |
| `get_session_transaction_depth` | Get nesting depth of transactions |
Source: [src/server/database/connection.py](../src/server/database/connection.py)
---
## 7. Repository Pattern
The `QueueRepository` class provides data access abstraction.
```python
class QueueRepository:
async def save_item(self, item: DownloadItem) -> None:
"""Save or update a download item (atomic operation)."""
async def get_all_items(self) -> List[DownloadItem]:
"""Get all items from database."""
async def delete_item(self, item_id: str) -> bool:
"""Delete item by ID."""
async def clear_all(self) -> int:
"""Clear all items (atomic operation)."""
```
Note: Compound operations (`save_item`, `clear_all`) are wrapped in `atomic()` transactions.
Source: [src/server/services/queue_repository.py](../src/server/services/queue_repository.py)
---
## 8. Database Service
The `AnimeSeriesService` provides async CRUD operations.
```python
class AnimeSeriesService:
@staticmethod
async def create(
db: AsyncSession,
key: str,
name: str,
site: str,
folder: str
) -> AnimeSeries:
"""Create a new anime series."""
@staticmethod
async def get_by_key(
db: AsyncSession,
key: str
) -> Optional[AnimeSeries]:
"""Get series by primary key identifier."""
```
### Bulk Operations
Services provide bulk operations for transaction-safe batch processing:
| Service | Method | Description |
| ---------------------- | ---------------------- | ------------------------------ |
| `EpisodeService` | `bulk_mark_downloaded` | Mark multiple episodes at once |
| `DownloadQueueService` | `bulk_delete` | Delete multiple queue items |
| `DownloadQueueService` | `clear_all` | Clear entire queue |
| `UserSessionService` | `rotate_session` | Revoke old + create new atomic |
| `UserSessionService` | `cleanup_expired` | Bulk delete expired sessions |
Source: [src/server/database/service.py](../src/server/database/service.py)
---
## 9. Data Integrity Rules
### Validation Constraints
| Field | Rule | Error Message |
| ------------------------- | ------------------------ | ------------------------------------- |
| `anime_series.key` | Non-empty, max 255 chars | "Series key cannot be empty" |
| `anime_series.name` | Non-empty, max 500 chars | "Series name cannot be empty" |
| `episodes.season` | 0-1000 | "Season number must be non-negative" |
| `episodes.episode_number` | 0-10000 | "Episode number must be non-negative" |
Source: [src/server/database/models.py](../src/server/database/models.py#L89-L119)
### Cascade Rules
- Deleting `anime_series` deletes all related `episodes` and `download_queue_item`
---
## 10. Migration Strategy
Currently, SQLAlchemy's `create_all()` is used for schema creation.
```python
# src/server/database/connection.py
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
```
For production migrations, Alembic is recommended but not yet implemented.
Source: [src/server/database/connection.py](../src/server/database/connection.py)
---
## 11. Common Query Patterns
### Get all series with missing episodes
```python
series = await db.execute(
select(AnimeSeries).options(selectinload(AnimeSeries.episodes))
)
for serie in series.scalars():
downloaded = [e for e in serie.episodes if e.is_downloaded]
```
### Get pending downloads ordered by priority
```python
items = await db.execute(
select(DownloadQueueItem)
.where(DownloadQueueItem.status == "pending")
.order_by(
case(
(DownloadQueueItem.priority == "HIGH", 1),
(DownloadQueueItem.priority == "NORMAL", 2),
(DownloadQueueItem.priority == "LOW", 3),
),
DownloadQueueItem.added_at
)
)
```
---
## 12. Database Location
| Environment | Default Location |
| ----------- | ------------------------------------------------- |
| Development | `./data/aniworld.db` |
| Production | Via `DATABASE_URL` environment variable |
| Testing | In-memory SQLite (`sqlite+aiosqlite:///:memory:`) |

View File

@@ -1,64 +0,0 @@
# Development Guide
## Document Purpose
This document provides guidance for developers working on the Aniworld project.
### What This Document Contains
- **Prerequisites**: Required software and tools
- **Environment Setup**: Step-by-step local development setup
- **Project Structure**: Source code organization explanation
- **Development Workflow**: Branch strategy, commit conventions
- **Coding Standards**: Style guide, linting, formatting
- **Running the Application**: Development server, CLI usage
- **Debugging Tips**: Common debugging approaches
- **IDE Configuration**: VS Code settings, recommended extensions
- **Contributing Guidelines**: How to submit changes
- **Code Review Process**: Review checklist and expectations
### What This Document Does NOT Contain
- Production deployment (see [DEPLOYMENT.md](DEPLOYMENT.md))
- API reference (see [API.md](API.md))
- Architecture decisions (see [ARCHITECTURE.md](ARCHITECTURE.md))
- Test writing guides (see [TESTING.md](TESTING.md))
- Security guidelines (see [SECURITY.md](SECURITY.md))
### Target Audience
- New Developers joining the project
- Contributors (internal and external)
- Anyone setting up a development environment
---
## Sections to Document
1. Prerequisites
- Python version
- Conda environment
- Node.js (if applicable)
- Git
2. Getting Started
- Clone repository
- Setup conda environment
- Install dependencies
- Configuration setup
3. Project Structure Overview
4. Development Server
- Starting FastAPI server
- Hot reload configuration
- Debug mode
5. CLI Development
6. Code Style
- PEP 8 compliance
- Type hints requirements
- Docstring format
- Import organization
7. Git Workflow
- Branch naming
- Commit message format
- Pull request process
8. Common Development Tasks
9. Troubleshooting Development Issues

View File

@@ -1,758 +0,0 @@
# NFO Metadata Guide
## Document Purpose
This guide explains how to use the NFO metadata feature to enrich your anime library with TMDB metadata and artwork for Plex, Jellyfin, Emby, and Kodi.
---
## 1. Overview
### What are NFO Files?
NFO files are XML documents that contain metadata about TV shows and episodes. Media servers like Plex, Jellyfin, Emby, and Kodi use these files to display information about your library without needing to scrape external sources.
### Features
- **Automatic NFO Creation**: Generate NFO files during downloads
- **TMDB Integration**: Fetch metadata from The Movie Database
- **Image Downloads**: Poster, fanart, and logo images
- **Batch Operations**: Create/update NFO files for multiple anime
- **Web UI**: Manage NFO settings and operations
- **API Access**: Programmatic NFO management
---
## 2. Getting Started
### 2.1 Obtain TMDB API Key
1. Create a free account at https://www.themoviedb.org
2. Navigate to https://www.themoviedb.org/settings/api
3. Request an API key (select "Developer" option)
4. Copy your API key (v3 auth)
### 2.2 Configure NFO Settings
#### Via Web Interface
1. Open http://127.0.0.1:8000
2. Click **Configuration** button
3. Scroll to **NFO Settings** section
4. Enter your TMDB API key
5. Click **Test Connection** to verify
6. Configure options:
- **Auto-create during downloads**: Enable to create NFO files automatically
- **Update on library scan**: Enable to refresh existing NFO files
- **Download poster**: Episode and show poster images (poster.jpg)
- **Download logo**: Show logo images (logo.png)
- **Download fanart**: Background artwork (fanart.jpg)
- **Image size**: Select w500 (recommended), w780, or original
7. Click **Save**
#### Via Environment Variables
Add to your `.env` file:
```bash
TMDB_API_KEY=your_api_key_here
NFO_AUTO_CREATE=true
NFO_UPDATE_ON_SCAN=false
NFO_DOWNLOAD_POSTER=true
NFO_DOWNLOAD_LOGO=false
NFO_DOWNLOAD_FANART=false
NFO_IMAGE_SIZE=w500
```
#### Via config.json
Edit `data/config.json`:
```json
{
"nfo": {
"tmdb_api_key": "your_api_key_here",
"auto_create": true,
"update_on_scan": false,
"download_poster": true,
"download_logo": false,
"download_fanart": false,
"image_size": "w500"
}
}
```
---
## 3. Using NFO Features
### 3.1 Automatic NFO Creation
With `auto_create` enabled, NFO files are created automatically when downloading episodes:
1. Add episodes to download queue
2. Start queue processing
3. NFO files are created after successful downloads
4. Images are downloaded based on configuration
### 3.2 Manual NFO Creation
#### Via Web Interface
1. Navigate to the main page
2. Click **Create NFO** button next to an anime
3. Wait for completion notification
#### Via API
```bash
curl -X POST "http://127.0.0.1:8000/api/nfo/create" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"anime_id": 123,
"folder_path": "/path/to/anime/Attack on Titan"
}'
```
### 3.3 Batch NFO Creation
Create NFO files for multiple anime at once:
```bash
curl -X POST "http://127.0.0.1:8000/api/nfo/batch/create" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"anime_ids": [123, 456, 789]
}'
```
### 3.4 Update Existing NFO Files
Update NFO files with latest TMDB metadata:
```bash
curl -X POST "http://127.0.0.1:8000/api/nfo/update" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"anime_id": 123,
"folder_path": "/path/to/anime/Attack on Titan",
"force": true
}'
```
### 3.5 Check NFO Status
Check which anime have NFO files:
```bash
curl -X GET "http://127.0.0.1:8000/api/nfo/check?folder_path=/path/to/anime" \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
Response:
```json
{
"has_tvshow_nfo": true,
"episode_nfos": [
{
"season": 1,
"episode": 1,
"has_nfo": true,
"file_path": "/path/to/anime/Season 1/S01E01.nfo"
}
],
"missing_episodes": [],
"total_episodes": 25,
"nfo_count": 25
}
```
---
## 4. File Structure
### 4.1 NFO File Locations
NFO files are created in the anime directory:
```
/path/to/anime/Attack on Titan/
├── tvshow.nfo # Show metadata
├── poster.jpg # Show poster (optional)
├── logo.png # Show logo (optional)
├── fanart.jpg # Show fanart (optional)
├── Season 1/
│ ├── S01E01.mkv
│ ├── S01E01.nfo # Episode metadata
│ ├── S01E01-thumb.jpg # Episode thumbnail (optional)
│ ├── S01E02.mkv
│ └── S01E02.nfo
└── Season 2/
├── S02E01.mkv
└── S02E01.nfo
```
### 4.2 tvshow.nfo Format
```xml
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<tvshow>
<title>Attack on Titan</title>
<originaltitle>進撃の巨人</originaltitle>
<showtitle>Attack on Titan</showtitle>
<sorttitle>Attack on Titan</sorttitle>
<rating>8.5</rating>
<year>2013</year>
<plot>Humans are nearly exterminated by giant creatures...</plot>
<runtime>24</runtime>
<mpaa>TV-MA</mpaa>
<premiered>2013-04-07</premiered>
<status>Ended</status>
<studio>Wit Studio</studio>
<genre>Animation</genre>
<genre>Action</genre>
<genre>Sci-Fi & Fantasy</genre>
<uniqueid type="tmdb">1429</uniqueid>
<thumb aspect="poster">https://image.tmdb.org/t/p/w500/...</thumb>
<fanart>
<thumb>https://image.tmdb.org/t/p/original/...</thumb>
</fanart>
</tvshow>
```
### 4.3 Episode NFO Format
```xml
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<episodedetails>
<title>To You, in 2000 Years: The Fall of Shiganshina, Part 1</title>
<showtitle>Attack on Titan</showtitle>
<season>1</season>
<episode>1</episode>
<displayseason>1</displayseason>
<displayepisode>1</displayepisode>
<plot>After a hundred years of peace...</plot>
<runtime>24</runtime>
<aired>2013-04-07</aired>
<rating>8.2</rating>
<uniqueid type="tmdb">63056</uniqueid>
<thumb>https://image.tmdb.org/t/p/w500/...</thumb>
</episodedetails>
```
---
## 5. API Reference
### 5.1 Check NFO Status
**Endpoint**: `GET /api/nfo/check`
**Query Parameters**:
- `folder_path` (required): Absolute path to anime directory
**Response**:
```json
{
"has_tvshow_nfo": true,
"episode_nfos": [
{
"season": 1,
"episode": 1,
"has_nfo": true,
"file_path": "/path/to/S01E01.nfo"
}
],
"missing_episodes": [],
"total_episodes": 25,
"nfo_count": 25
}
```
### 5.2 Create NFO Files
**Endpoint**: `POST /api/nfo/create`
**Request Body**:
```json
{
"anime_id": 123,
"folder_path": "/path/to/anime/Attack on Titan"
}
```
**Response**:
```json
{
"success": true,
"message": "NFO files created successfully",
"files_created": ["tvshow.nfo", "S01E01.nfo", "S01E02.nfo"],
"images_downloaded": ["poster.jpg", "S01E01-thumb.jpg"]
}
```
### 5.3 Update NFO Files
**Endpoint**: `POST /api/nfo/update`
**Request Body**:
```json
{
"anime_id": 123,
"folder_path": "/path/to/anime",
"force": false
}
```
**Response**:
```json
{
"success": true,
"message": "NFO files updated successfully",
"files_updated": ["tvshow.nfo", "S01E01.nfo"]
}
```
### 5.4 View NFO Content
**Endpoint**: `GET /api/nfo/view`
**Query Parameters**:
- `file_path` (required): Absolute path to NFO file
**Response**:
```json
{
"content": "<?xml version=\"1.0\"...?>",
"file_path": "/path/to/tvshow.nfo",
"exists": true
}
```
### 5.5 Get Media Status
**Endpoint**: `GET /api/nfo/media/status`
**Query Parameters**:
- `folder_path` (required): Absolute path to anime directory
**Response**:
```json
{
"poster_exists": true,
"poster_path": "/path/to/poster.jpg",
"logo_exists": false,
"logo_path": null,
"fanart_exists": true,
"fanart_path": "/path/to/fanart.jpg",
"episode_thumbs": [
{
"season": 1,
"episode": 1,
"exists": true,
"path": "/path/to/S01E01-thumb.jpg"
}
]
}
```
### 5.6 Download Media
**Endpoint**: `POST /api/nfo/media/download`
**Request Body**:
```json
{
"folder_path": "/path/to/anime",
"anime_id": 123,
"download_poster": true,
"download_logo": false,
"download_fanart": false,
"image_size": "w500"
}
```
**Response**:
```json
{
"success": true,
"message": "Media downloaded successfully",
"downloaded": ["poster.jpg", "S01E01-thumb.jpg"]
}
```
### 5.7 Batch Create NFO
**Endpoint**: `POST /api/nfo/batch/create`
**Request Body**:
```json
{
"anime_ids": [123, 456, 789]
}
```
**Response**:
```json
{
"success": true,
"results": [
{
"anime_id": 123,
"success": true,
"message": "Created successfully"
},
{
"anime_id": 456,
"success": false,
"error": "Folder not found"
}
]
}
```
### 5.8 Find Missing NFOs
**Endpoint**: `GET /api/nfo/missing`
**Response**:
```json
{
"anime_list": [
{
"anime_id": 123,
"title": "Attack on Titan",
"folder_path": "/path/to/anime/Attack on Titan",
"missing_tvshow_nfo": false,
"missing_episode_count": 3,
"total_episodes": 25
}
]
}
```
---
## 6. Troubleshooting
### 6.1 NFO Files Not Created
**Problem**: NFO files are not being created during downloads.
**Solutions**:
1. Verify TMDB API key is configured correctly
2. Check `auto_create` is enabled in settings
3. Ensure anime directory has write permissions
4. Check logs for error messages
5. Test TMDB connection using "Test Connection" button
### 6.2 Invalid TMDB API Key
**Problem**: TMDB validation fails with "Invalid API key".
**Solutions**:
1. Verify API key is copied correctly (no extra spaces)
2. Ensure you're using the v3 API key (not v4)
3. Check API key is active on TMDB website
4. Try regenerating API key on TMDB
### 6.3 Images Not Downloading
**Problem**: NFO files are created but images are missing.
**Solutions**:
1. Enable image downloads in settings (poster/logo/fanart)
2. Verify TMDB API key is valid
3. Check network connectivity to TMDB servers
4. Ensure sufficient disk space
5. Check file permissions in anime directory
### 6.4 Incorrect Metadata
**Problem**: NFO contains wrong show information.
**Solutions**:
1. Verify anime title matches TMDB exactly
2. Use TMDB ID if available for accurate matching
3. Update NFO files with `force=true` to refresh metadata
4. Check TMDB website for correct show information
### 6.5 Permission Errors
**Problem**: "Permission denied" when creating NFO files.
**Solutions**:
1. Check anime directory permissions: `chmod 755 /path/to/anime`
2. Ensure application user has write access
3. Verify directory ownership: `chown -R user:group /path/to/anime`
4. Check parent directories are accessible
### 6.6 Slow NFO Creation
**Problem**: NFO creation takes a long time.
**Solutions**:
1. Reduce image size (use w500 instead of original)
2. Disable unnecessary images (logo, fanart)
3. Create NFOs in batches during off-peak hours
4. Check network speed to TMDB servers
5. Verify disk I/O performance
---
## 7. Best Practices
### 7.1 Configuration Recommendations
- **Image Size**: Use `w500` for optimal balance of quality and storage
- **Auto-create**: Enable for new downloads
- **Update on scan**: Disable to avoid unnecessary TMDB API calls
- **Poster**: Always enable for show and episode thumbnails
- **Logo/Fanart**: Enable only if your media server supports them
### 7.2 Maintenance
- **Regular Updates**: Update NFO files quarterly to get latest metadata
- **Backup**: Include NFO files in your backup strategy
- **Validation**: Periodically check missing NFOs using `/api/nfo/missing`
- **API Rate Limits**: Be mindful of TMDB API rate limits when batch processing
### 7.3 Performance
- **Batch Operations**: Use batch endpoints for multiple anime
- **Off-Peak Processing**: Create NFOs during low-activity periods
- **Image Optimization**: Use smaller image sizes for large libraries
- **Selective Updates**: Only update NFOs when metadata changes
### 7.4 Media Server Integration
#### Plex
- Use "Personal Media Shows" agent
- Enable "Local Media Assets" scanner
- Place NFO files in anime directories
- Refresh metadata after creating NFOs
#### Jellyfin
- Use "NFO" metadata provider
- Enable in Library settings
- Order providers: NFO first, then online sources
- Scan library after NFO creation
#### Emby
- Enable "NFO" metadata reader
- Configure in Library advanced settings
- Use "Prefer embedded metadata" option
- Refresh metadata after updates
#### Kodi
- NFO files are automatically detected
- No additional configuration needed
- Update library to see changes
---
## 8. Advanced Usage
### 8.1 Custom NFO Templates
You can customize NFO generation by modifying the NFO service:
```python
# src/core/services/nfo_creator.py
def generate_tvshow_nfo(self, metadata: dict) -> str:
# Add custom fields or modify structure
pass
```
### 8.2 Bulk Operations
Create NFOs for entire library:
```bash
# Get all anime without NFOs
curl -X GET "http://127.0.0.1:8000/api/nfo/missing" \
-H "Authorization: Bearer $TOKEN" \
| jq -r '.anime_list[].anime_id' \
| xargs -I{} curl -X POST "http://127.0.0.1:8000/api/nfo/batch/create" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"anime_ids": [{}]}'
```
### 8.3 Scheduled Updates
Use the scheduler API to refresh NFOs automatically:
```bash
# Schedule weekly NFO updates (rescan runs Sunday at 03:00)
curl -X POST "http://127.0.0.1:8000/api/scheduler/config" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"enabled": true,
"schedule_time": "03:00",
"schedule_days": ["sun"],
"auto_download_after_rescan": false
}'
```
---
## 9. Related Documentation
- [API.md](API.md) - Complete API reference
- [CONFIGURATION.md](CONFIGURATION.md) - All configuration options
- [ARCHITECTURE.md](ARCHITECTURE.md) - System architecture
- [DEVELOPMENT.md](DEVELOPMENT.md) - Development guide
---
## 10. Tag Reference
The table below lists every XML tag written to `tvshow.nfo` and its source in
the TMDB API response. All tags are written whenever the NFO is created or
updated via `create_tvshow_nfo()` / `update_tvshow_nfo()`.
| NFO tag | TMDB source field | Required |
| --------------- | ----------------------------------------------------- | -------- |
| `title` | `name` | ✅ |
| `originaltitle` | `original_name` | ✅ |
| `showtitle` | `name` (same as `title`) | ✅ |
| `sorttitle` | `name` (same as `title`) | ✅ |
| `year` | First 4 chars of `first_air_date` | ✅ |
| `plot` | `overview` | ✅ |
| `outline` | `overview` (same as `plot`) | ✅ |
| `tagline` | `tagline` | optional |
| `runtime` | `episode_run_time[0]` | ✅ |
| `premiered` | `first_air_date` | ✅ |
| `status` | `status` | ✅ |
| `mpaa` | US content rating from `content_ratings.results` | optional |
| `fsk` | DE content rating (written as `mpaa` when preferred) | optional |
| `imdbid` | `external_ids.imdb_id` | ✅ |
| `tmdbid` | `id` | ✅ |
| `tvdbid` | `external_ids.tvdb_id` | optional |
| `genre` | `genres[].name` (one element per genre) | ✅ |
| `studio` | `networks[].name` (one element per network) | ✅ |
| `country` | `origin_country[]` or `production_countries[].name` | ✅ |
| `actor` | `credits.cast[]` (top 10, with name/role/thumb) | ✅ |
| `watched` | Always `false` on creation | ✅ |
| `dateadded` | System clock at creation time (`YYYY-MM-DD HH:MM:SS`) | ✅ |
The mapping logic lives in `src/core/utils/nfo_mapper.py` (`tmdb_to_nfo_model`).
The XML serialisation lives in `src/core/utils/nfo_generator.py`
(`generate_tvshow_nfo`).
---
## 11. Automatic NFO Repair
Every time the server starts, Aniworld scans all existing `tvshow.nfo` files and
automatically repairs any that are missing required tags.
### How It Works
1. **Scan**`perform_nfo_repair_scan()` in
`src/server/services/initialization_service.py` is called from the FastAPI
lifespan after `perform_media_scan_if_needed()`.
2. **Detect**`nfo_needs_repair(nfo_path)` from
`src/core/services/nfo_repair_service.py` parses each `tvshow.nfo` with
`lxml` and checks for the 13 required tags listed below.
3. **Repair** — Series whose NFO is incomplete are queued for background reload
via `BackgroundLoaderService.add_series_loading_task()`. The background
loader re-fetches metadata from TMDB and rewrites the NFO with all tags
populated.
### Tags Checked (13 required)
| XPath | Tag name |
| ----------------- | --------------- |
| `./title` | `title` |
| `./originaltitle` | `originaltitle` |
| `./year` | `year` |
| `./plot` | `plot` |
| `./runtime` | `runtime` |
| `./premiered` | `premiered` |
| `./status` | `status` |
| `./imdbid` | `imdbid` |
| `./genre` | `genre` |
| `./studio` | `studio` |
| `./country` | `country` |
| `./actor/name` | `actor/name` |
| `./watched` | `watched` |
### Log Messages
| Message | Meaning |
| ----------------------------------------------------------- | ------------------------------------------------- |
| `NFO repair scan complete: 0 of N series queued for repair` | All NFOs are complete — no action needed |
| `NFO repair scan complete: X of N series queued for repair` | X series had incomplete NFOs and have been queued |
| `NFO repair scan skipped: TMDB API key not configured` | Set `tmdb_api_key` in `data/config.json` |
| `NFO repair scan skipped: anime directory not configured` | Set `anime_directory` in `data/config.json` |
### Triggering a Manual Repair
You can also repair a single series on demand via the API:
```http
POST /api/nfo/update/{series_key}
```
This calls `NFOService.update_tvshow_nfo()` directly and overwrites the existing
`tvshow.nfo` with fresh data from TMDB.
### Source Files
| File | Purpose |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `src/core/services/nfo_repair_service.py` | `REQUIRED_TAGS`, `parse_nfo_tags`, `find_missing_tags`, `nfo_needs_repair`, `NfoRepairService` |
| `src/server/services/initialization_service.py` | `perform_nfo_repair_scan` startup hook |
| `src/server/fastapi_app.py` | Wires `perform_nfo_repair_scan` into the lifespan |
---
## 12. Support
### Getting Help
- Check logs in `logs/` directory for error details
- Review [TESTING.md](TESTING.md) for test coverage
- Consult [DATABASE.md](DATABASE.md) for NFO status schema
### Common Issues
See section 6 (Troubleshooting) for solutions to common problems.
### TMDB Resources
- TMDB API Documentation: https://developers.themoviedb.org/3
- TMDB Support: https://www.themoviedb.org/talk
- TMDB API Status: https://status.themoviedb.org/

View File

@@ -1,39 +0,0 @@
# Aniworld Documentation
## Overview
This directory contains all documentation for the Aniworld anime download manager project.
## Documentation Structure
| Document | Purpose | Target Audience |
| ---------------------------------------- | ---------------------------------------------- | ---------------------------------- |
| [ARCHITECTURE.md](ARCHITECTURE.md) | System architecture and design decisions | Architects, Senior Developers |
| [API.md](API.md) | REST API reference and WebSocket documentation | Frontend Developers, API Consumers |
| [DEVELOPMENT.md](DEVELOPMENT.md) | Developer setup and contribution guide | All Developers |
| [DEPLOYMENT.md](DEPLOYMENT.md) | Deployment and operations guide | DevOps, System Administrators |
| [DATABASE.md](DATABASE.md) | Database schema and data models | Backend Developers |
| [TESTING.md](TESTING.md) | Testing strategy and guidelines | QA Engineers, Developers |
| [SECURITY.md](SECURITY.md) | Security considerations and guidelines | Security Engineers, All Developers |
| [CONFIGURATION.md](CONFIGURATION.md) | Configuration options reference | Operators, Developers |
| [CHANGELOG.md](CHANGELOG.md) | Version history and changes | All Stakeholders |
| [TROUBLESHOOTING.md](TROUBLESHOOTING.md) | Common issues and solutions | Support, Operators |
| [features.md](features.md) | Feature list and capabilities | Product Owners, Users |
| [instructions.md](instructions.md) | AI agent development instructions | AI Agents, Developers |
## Documentation Standards
- All documentation uses Markdown format
- Keep documentation up-to-date with code changes
- Include code examples where applicable
- Use clear, concise language
- Include diagrams for complex concepts (use Mermaid syntax)
## Contributing to Documentation
When adding or updating documentation:
1. Follow the established format in each document
2. Update the README.md if adding new documents
3. Ensure cross-references are valid
4. Review for spelling and grammar

View File

@@ -1,71 +0,0 @@
# Testing Documentation
## Document Purpose
This document describes the testing strategy, guidelines, and practices for the Aniworld project.
### What This Document Contains
- **Testing Strategy**: Overall approach to quality assurance
- **Test Categories**: Unit, integration, API, performance, security tests
- **Test Structure**: Organization of test files and directories
- **Writing Tests**: Guidelines for writing effective tests
- **Fixtures and Mocking**: Shared test utilities and mock patterns
- **Running Tests**: Commands and configurations
- **Coverage Requirements**: Minimum coverage thresholds
- **CI/CD Integration**: How tests run in automation
- **Test Data Management**: Managing test fixtures and data
- **Best Practices**: Do's and don'ts for testing
### What This Document Does NOT Contain
- Production deployment (see [DEPLOYMENT.md](DEPLOYMENT.md))
- Security audit procedures (see [SECURITY.md](SECURITY.md))
- Bug tracking and issue management
- Performance benchmarking results
### Target Audience
- Developers writing tests
- QA Engineers
- CI/CD Engineers
- Code reviewers
---
## Sections to Document
1. Testing Philosophy
- Test pyramid approach
- Quality gates
2. Test Categories
- Unit Tests (`tests/unit/`)
- Integration Tests (`tests/integration/`)
- API Tests (`tests/api/`)
- Frontend Tests (`tests/frontend/`)
- Performance Tests (`tests/performance/`)
- Security Tests (`tests/security/`)
3. Test Structure and Naming
- File naming conventions
- Test function naming
- Test class organization
4. Running Tests
- pytest commands
- Running specific tests
- Verbose output
- Coverage reports
5. Fixtures and Conftest
- Shared fixtures
- Database fixtures
- Mock services
6. Mocking Guidelines
- What to mock
- Mock patterns
- External service mocks
7. Coverage Requirements
8. CI/CD Integration
9. Writing Good Tests
- Arrange-Act-Assert pattern
- Test isolation
- Edge cases
10. Common Pitfalls to Avoid

View File

@@ -1,23 +0,0 @@
# Architecture Diagrams
This directory contains architecture diagram source files for the Aniworld documentation.
## Diagrams
### System Architecture (Mermaid)
See [system-architecture.mmd](system-architecture.mmd) for the system overview diagram.
### Rendering
Diagrams can be rendered using:
- Mermaid Live Editor: https://mermaid.live/
- VS Code Mermaid extension
- GitHub/GitLab native Mermaid support
## Formats
- `.mmd` - Mermaid diagram source files
- `.svg` - Exported vector graphics (add when needed)
- `.png` - Exported raster graphics (add when needed)

View File

@@ -1,44 +0,0 @@
%%{init: {'theme': 'base'}}%%
sequenceDiagram
participant Client
participant FastAPI
participant AuthMiddleware
participant DownloadService
participant ProgressService
participant WebSocketService
participant SeriesApp
participant Database
Note over Client,Database: Download Flow
%% Add to queue
Client->>FastAPI: POST /api/queue/add
FastAPI->>AuthMiddleware: Validate JWT
AuthMiddleware-->>FastAPI: OK
FastAPI->>DownloadService: add_to_queue()
DownloadService->>Database: save_item()
Database-->>DownloadService: item_id
DownloadService-->>FastAPI: [item_ids]
FastAPI-->>Client: 201 Created
%% Start queue
Client->>FastAPI: POST /api/queue/start
FastAPI->>AuthMiddleware: Validate JWT
AuthMiddleware-->>FastAPI: OK
FastAPI->>DownloadService: start_queue_processing()
loop For each pending item
DownloadService->>SeriesApp: download_episode()
loop Progress updates
SeriesApp->>ProgressService: emit("progress_updated")
ProgressService->>WebSocketService: broadcast_to_room()
WebSocketService-->>Client: WebSocket message
end
SeriesApp-->>DownloadService: completed
DownloadService->>Database: update_status()
end
DownloadService-->>FastAPI: OK
FastAPI-->>Client: 200 OK

View File

@@ -1,82 +0,0 @@
%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#4a90d9'}}}%%
flowchart TB
subgraph Clients["Client Layer"]
Browser["Web Browser<br/>(HTML/CSS/JS)"]
CLI["CLI Client<br/>(Main.py)"]
end
subgraph Server["Server Layer (FastAPI)"]
direction TB
Middleware["Middleware<br/>Auth, Rate Limit, Error Handler"]
subgraph API["API Routers"]
AuthAPI["/api/auth"]
AnimeAPI["/api/anime"]
QueueAPI["/api/queue"]
ConfigAPI["/api/config"]
SchedulerAPI["/api/scheduler"]
HealthAPI["/health"]
WebSocketAPI["/ws"]
end
subgraph Services["Services"]
AuthService["AuthService"]
AnimeService["AnimeService"]
DownloadService["DownloadService"]
ConfigService["ConfigService"]
ProgressService["ProgressService"]
WebSocketService["WebSocketService"]
end
end
subgraph Core["Core Layer"]
SeriesApp["SeriesApp"]
SerieScanner["SerieScanner"]
SerieList["SerieList"]
end
subgraph Data["Data Layer"]
SQLite[(SQLite<br/>aniworld.db)]
ConfigJSON[(config.json)]
FileSystem[(File System<br/>Anime Directory)]
end
subgraph External["External"]
Provider["Anime Provider<br/>(aniworld.to)"]
end
%% Client connections
Browser -->|HTTP/WebSocket| Middleware
CLI -->|Direct| SeriesApp
%% Middleware to API
Middleware --> API
%% API to Services
AuthAPI --> AuthService
AnimeAPI --> AnimeService
QueueAPI --> DownloadService
ConfigAPI --> ConfigService
SchedulerAPI --> AnimeService
WebSocketAPI --> WebSocketService
%% Services to Core
AnimeService --> SeriesApp
DownloadService --> SeriesApp
%% Services to Data
AuthService --> ConfigJSON
ConfigService --> ConfigJSON
DownloadService --> SQLite
AnimeService --> SQLite
%% Core to Data
SeriesApp --> SerieScanner
SeriesApp --> SerieList
SerieScanner --> FileSystem
SerieScanner --> Provider
%% Event flow
ProgressService -.->|Events| WebSocketService
DownloadService -.->|Progress| ProgressService
WebSocketService -.->|Broadcast| Browser

View File

@@ -1,110 +0,0 @@
# Aniworld Web Application Features
## Recent Updates
### Enhanced Setup and Settings Pages (Latest)
The application now features a comprehensive configuration system that allows users to configure all settings during initial setup or modify them later through the settings modal:
**Setup Page Enhancements:**
- Single-page setup with all configuration options organized into clear sections
- Real-time password strength indicator for security
- Form validation with helpful error messages
- Comprehensive settings including: general, security, scheduler, logging, backup, and NFO metadata
**Settings Modal Enhancements:**
- All configuration fields are now editable through the main application's config modal
- Organized into logical sections with clear labels and help text
- Real-time saving with immediate feedback
- Configuration validation to prevent invalid settings
- Full control over cron-based scheduler (time, days of week, auto-download), logging options, and backup settings
---
## Authentication & Security
- **Master Password Login**: Secure access to the application with a master password system
- **JWT Token Sessions**: Stateless authentication with JSON Web Tokens
- **Rate Limiting**: Built-in protection against brute force attacks
## Configuration Management
- **Enhanced Setup Page**: Comprehensive initial configuration interface with all settings in one place:
- General Settings: Application name and data directory configuration
- Security Settings: Master password setup with strength indicator
- Anime Directory: Primary directory path for anime storage
- Scheduler Settings: Enable/disable scheduler, configure daily run time, select days of week, and optionally auto-download missing episodes after rescan
- Logging Settings: Configure log level, file path, file size limits, and backup count
- Backup Settings: Enable automatic backups with configurable path and retention period
- NFO Settings: TMDB API key, auto-creation options, and media file download preferences
- **Enhanced Settings/Config Modal**: Comprehensive configuration interface accessible from main page:
- General Settings: Edit application name and data directory
- Anime Directory: Modify anime storage location with browse functionality
- Scheduler Configuration: Enable/disable, set cron run time (`HH:MM`), select active days of the week, and toggle auto-download after rescan
- Logging Configuration: Full control over logging level, file rotation, and backup count
- Backup Configuration: Configure automatic backup settings including path and retention
- NFO Settings: Complete control over TMDB integration and media file downloads
- Configuration Validation: Validate configuration for errors before saving
- Backup Management: Create, restore, and manage configuration backups
- Export/Import: Export configuration for backup or transfer to another instance
## User Interface
- **Dark Mode**: Toggle between light and dark themes for better user experience
- **Responsive Design**: Mobile-friendly interface with touch support
- **Real-time Updates**: WebSocket-based live notifications and progress tracking
## Anime Management
- **Anime Library Page**: Display list of anime series with missing episodes
- **Database-Backed Series Storage**: All series metadata and missing episodes stored in SQLite database
- **Automatic Database Synchronization**: Series loaded from database on startup, stays in sync with filesystem
- **Series Selection**: Select individual anime series and add episodes to download queue
- **Anime Search**: Search for anime series using integrated providers
- **Library Scanning**: Automated scanning for missing episodes with database persistence
- **Episode Tracking**: Missing episodes tracked in database, automatically updated during scans
- **NFO Status Indicators**: Visual badges showing NFO and media file status for each series
## NFO Metadata Management
- **TMDB Integration**: Automatic metadata fetching from The Movie Database (TMDB)
- **Auto-Create NFO Files**: Automatically generate tvshow.nfo files during downloads
- **Media File Downloads**: Automatic download of poster.jpg, logo.png, and fanart.jpg
- **NFO Status Tracking**: Database tracking of NFO creation and update timestamps
- **Manual NFO Creation**: Create NFO files and download media for existing anime
- **NFO Updates**: Update existing NFO files with latest TMDB metadata
- **Batch Operations**: Create NFO files for multiple anime at once
- **NFO Content Viewing**: View generated NFO file content in the UI
- **Media Server Compatibility**: Kodi, Plex, Jellyfin, and Emby compatible format
- **Configuration Options**: Customize which media files to download and image quality
## Download Management
- **Download Queue Page**: View and manage the current download queue with organized sections
- **Queue Organization**: Displays downloads organized by status (pending, active, completed, failed)
- **NFO Integration**: Automatic NFO and media file creation before episode downloads
- **Manual Start/Stop Control**: User manually starts downloads one at a time with Start/Stop buttons
- **FIFO Queue Processing**: First-in, first-out queue order (no priority or reordering)
- **Single Download Mode**: Only one download active at a time, new downloads must be manually started
- **Download Status Display**: Real-time status updates and progress of current download
- **Queue Operations**: Add and remove items from the pending queue
- **Completed Downloads List**: Separate section for completed downloads with clear button
- **Failed Downloads List**: Separate section for failed downloads with retry and clear options
- **Retry Failed Downloads**: Automatically retry failed downloads with configurable limits
- **Clear Completed**: Remove completed downloads from the queue
- **Clear Failed**: Remove failed downloads from the queue
- **Queue Statistics**: Real-time counters for pending, active, completed, and failed items
## Real-time Communication
- **WebSocket Support**: Real-time notifications for download progress and queue updates
- **Progress Tracking**: Live progress updates for downloads and scans
- **System Notifications**: Real-time system messages and alerts
## Core Functionality Overview
The web application provides a complete interface for managing anime downloads with user-friendly pages for configuration, library management, search capabilities, and download monitoring. All operations are tracked in real-time with comprehensive progress reporting and error handling.
**NFO Metadata Features**: The application now includes full support for generating Kodi/Plex/Jellyfin/Emby compatible metadata files (tvshow.nfo) with automatic TMDB integration. NFO files are created automatically during downloads or can be managed manually through the UI. The system tracks NFO status in the database and provides comprehensive API endpoints for programmatic access. Media files (poster, logo, fanart) are automatically downloaded based on configuration settings.

View File

@@ -0,0 +1,426 @@
# Series Identifier Standardization - Validation Instructions
## Overview
This document provides comprehensive instructions for AI agents to validate the **Series Identifier Standardization** change across the Aniworld codebase. The change standardizes `key` as the primary identifier for series and relegates `folder` to metadata-only status.
## Summary of the Change
| Field | Purpose | Usage |
| -------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------- |
| `key` | **Primary Identifier** - Provider-assigned, URL-safe (e.g., `attack-on-titan`) | All lookups, API operations, database queries, WebSocket events |
| `folder` | **Metadata Only** - Filesystem folder name (e.g., `Attack on Titan (2013)`) | Display purposes, filesystem operations only |
| `id` | **Database Primary Key** - Internal auto-increment integer | Database relationships only |
---
## Validation Checklist
### Phase 2: Application Layer Services
**Files to validate:**
1. **`src/server/services/anime_service.py`**
- [ ] Class docstring explains `key` vs `folder` convention
- [ ] All public methods accept `key` parameter for series identification
- [ ] No methods accept `folder` as an identifier parameter
- [ ] Event handler methods document key/folder convention
- [ ] Progress tracking uses `key` in progress IDs where possible
2. **`src/server/services/download_service.py`**
- [ ] `DownloadItem` uses `serie_id` (which should be the `key`)
- [ ] `serie_folder` is documented as metadata only
- [ ] Queue operations look up series by `key` not `folder`
- [ ] Persistence format includes `serie_id` as the key identifier
3. **`src/server/services/websocket_service.py`**
- [ ] Module docstring explains key/folder convention
- [ ] Broadcast methods include `key` in message payloads
- [ ] `folder` is documented as optional/display only
- [ ] Event broadcasts use `key` as primary identifier
4. **`src/server/services/scan_service.py`**
- [ ] Scan operations use `key` for identification
- [ ] Progress events include `key` field
5. **`src/server/services/progress_service.py`**
- [ ] Progress tracking includes `key` in metadata where applicable
**Validation Commands:**
```bash
# Check service layer for folder-based lookups
grep -rn "by_folder\|folder.*=.*identifier\|folder.*lookup" src/server/services/ --include="*.py"
# Verify key is used in services
grep -rn "serie_id\|series_key\|key.*identifier" src/server/services/ --include="*.py"
```
---
### Phase 3: API Endpoints and Responses
**Files to validate:**
1. **`src/server/api/anime.py`**
- [ ] `AnimeSummary` model has `key` field with proper description
- [ ] `AnimeDetail` model has `key` field with proper description
- [ ] API docstrings explain `key` is the primary identifier
- [ ] `folder` field descriptions state "metadata only"
- [ ] Endpoint paths use `key` parameter (e.g., `/api/anime/{key}`)
- [ ] No endpoints use `folder` as path parameter for lookups
2. **`src/server/api/download.py`**
- [ ] Download endpoints use `serie_id` (key) for operations
- [ ] Request models document key/folder convention
- [ ] Response models include `key` as primary identifier
3. **`src/server/models/anime.py`**
- [ ] Module docstring explains identifier convention
- [ ] `AnimeSeriesResponse` has `key` field properly documented
- [ ] `SearchResult` has `key` field properly documented
- [ ] Field validators normalize `key` to lowercase
- [ ] `folder` fields document metadata-only purpose
4. **`src/server/models/download.py`**
- [ ] `DownloadItem` has `serie_id` documented as the key
- [ ] `serie_folder` documented as metadata only
- [ ] Field descriptions are clear about primary vs metadata
5. **`src/server/models/websocket.py`**
- [ ] Module docstring explains key/folder convention
- [ ] Message models document `key` as primary identifier
- [ ] `folder` documented as optional display metadata
**Validation Commands:**
```bash
# Check API endpoints for folder-based paths
grep -rn "folder.*Path\|/{folder}" src/server/api/ --include="*.py"
# Verify key is used in endpoints
grep -rn "/{key}\|series_key\|serie_id" src/server/api/ --include="*.py"
# Check model field descriptions
grep -rn "Field.*description.*identifier\|Field.*description.*key\|Field.*description.*folder" src/server/models/ --include="*.py"
```
---
### Phase 4: Frontend Integration
**Files to validate:**
1. **`src/server/web/static/js/app.js`**
- [ ] `selectedSeries` Set uses `key` values, not `folder`
- [ ] `seriesData` array comments indicate `key` as primary identifier
- [ ] Selection operations use `key` property
- [ ] API calls pass `key` for series identification
- [ ] WebSocket message handlers extract `key` from data
- [ ] No code uses `folder` for series lookups
2. **`src/server/web/static/js/queue.js`**
- [ ] Queue items reference series by `key` or `serie_id`
- [ ] WebSocket handlers extract `key` from messages
- [ ] UI operations use `key` for identification
- [ ] `serie_folder` used only for display
3. **`src/server/web/static/js/websocket_client.js`**
- [ ] Message handling preserves `key` field
- [ ] No transformation that loses `key` information
4. **HTML Templates** (`src/server/web/templates/`)
- [ ] Data attributes use `key` for identification (e.g., `data-key`)
- [ ] No `data-folder` used for identification purposes
- [ ] Display uses `folder` or `name` appropriately
**Validation Commands:**
```bash
# Check JavaScript for folder-based lookups
grep -rn "\.folder\s*==\|folder.*identifier\|getByFolder" src/server/web/static/js/ --include="*.js"
# Check data attributes in templates
grep -rn "data-key\|data-folder\|data-series" src/server/web/templates/ --include="*.html"
```
---
### Phase 5: Database Operations
**Files to validate:**
1. **`src/server/database/models.py`**
- [ ] `AnimeSeries` model has `key` column with unique constraint
- [ ] `key` column is indexed
- [ ] Model docstring explains identifier convention
- [ ] `folder` column docstring states "metadata only"
- [ ] Validators check `key` is not empty
- [ ] No `folder` uniqueness constraint (unless intentional)
2. **`src/server/database/service.py`**
- [ ] `AnimeSeriesService` has `get_by_key()` method
- [ ] Class docstring explains lookup convention
- [ ] No `get_by_folder()` without deprecation
- [ ] All CRUD operations use `key` for identification
- [ ] Logging uses `key` in messages
3. **`src/server/database/migrations/`**
- [ ] Migration files maintain `key` as unique, indexed column
- [ ] No migrations that use `folder` as identifier
**Validation Commands:**
```bash
# Check database models
grep -rn "unique=True\|index=True" src/server/database/models.py
# Check service lookups
grep -rn "get_by_key\|get_by_folder\|filter.*key\|filter.*folder" src/server/database/service.py
```
---
### Phase 6: WebSocket Events
**Files to validate:**
1. **All WebSocket broadcast calls** should include `key` in payload:
- `download_progress` → includes `key`
- `download_complete` → includes `key`
- `download_failed` → includes `key`
- `scan_progress` → includes `key` (where applicable)
- `queue_status` → items include `key`
2. **Message format validation:**
```json
{
"type": "download_progress",
"data": {
"key": "attack-on-titan", // PRIMARY - always present
"folder": "Attack on Titan (2013)", // OPTIONAL - display only
"progress": 45.5,
...
}
}
```
**Validation Commands:**
```bash
# Check WebSocket broadcast calls
grep -rn "broadcast.*key\|send_json.*key" src/server/services/ --include="*.py"
# Check message construction
grep -rn '"key":\|"folder":' src/server/services/ --include="*.py"
```
---
### Phase 7: Test Coverage
**Test files to validate:**
1. **`tests/unit/test_serie_class.py`**
- [ ] Tests for key validation (empty, whitespace, None)
- [ ] Tests for key as primary identifier
- [ ] Tests for folder as metadata only
2. **`tests/unit/test_anime_service.py`**
- [ ] Service tests use `key` for operations
- [ ] Mock objects have proper `key` attributes
3. **`tests/unit/test_database_models.py`**
- [ ] Tests for `key` uniqueness constraint
- [ ] Tests for `key` validation
4. **`tests/unit/test_database_service.py`**
- [ ] Tests for `get_by_key()` method
- [ ] No tests for deprecated folder lookups
5. **`tests/api/test_anime_endpoints.py`**
- [ ] API tests use `key` in requests
- [ ] Mock `FakeSerie` has proper `key` attribute
- [ ] Comments explain key/folder convention
6. **`tests/unit/test_websocket_service.py`**
- [ ] WebSocket tests verify `key` in messages
- [ ] Broadcast tests include `key` in payload
**Validation Commands:**
```bash
# Run all tests
conda run -n AniWorld python -m pytest tests/ -v --tb=short
# Run specific test files
conda run -n AniWorld python -m pytest tests/unit/test_serie_class.py -v
conda run -n AniWorld python -m pytest tests/unit/test_database_models.py -v
conda run -n AniWorld python -m pytest tests/api/test_anime_endpoints.py -v
# Search tests for identifier usage
grep -rn "key.*identifier\|folder.*metadata" tests/ --include="*.py"
```
---
## Common Issues to Check
### 1. Inconsistent Naming
Look for inconsistent parameter names:
- `serie_key` vs `series_key` vs `key`
- `serie_id` should refer to `key`, not database `id`
- `serie_folder` vs `folder`
### 2. Missing Documentation
Check that ALL models, services, and APIs document:
- What `key` is and how to use it
- That `folder` is metadata only
### 3. Legacy Code Patterns
Search for deprecated patterns:
```python
# Bad - using folder for lookup
series = get_by_folder(folder_name)
# Good - using key for lookup
series = get_by_key(series_key)
```
### 4. API Response Consistency
Verify all API responses include:
- `key` field (primary identifier)
- `folder` field (optional, for display)
### 5. Frontend Data Flow
Verify the frontend:
- Stores `key` in selection sets
- Passes `key` to API calls
- Uses `folder` only for display
---
## Deprecation Warnings
The following should have deprecation warnings (for removal in v3.0.0):
1. Any `get_by_folder()` or `GetByFolder()` methods
2. Any API endpoints that accept `folder` as a lookup parameter
3. Any frontend code that uses `folder` for identification
**Example deprecation:**
```python
import warnings
def get_by_folder(self, folder: str):
"""DEPRECATED: Use get_by_key() instead."""
warnings.warn(
"get_by_folder() is deprecated, use get_by_key(). "
"Will be removed in v3.0.0",
DeprecationWarning,
stacklevel=2
)
# ... implementation
```
---
## Automated Validation Script
Run this script to perform automated checks:
```bash
#!/bin/bash
# identifier_validation.sh
echo "=== Series Identifier Standardization Validation ==="
echo ""
echo "1. Checking core entities..."
grep -rn "PRIMARY IDENTIFIER\|metadata only" src/core/entities/ --include="*.py" | head -20
echo ""
echo "2. Checking for deprecated folder lookups..."
grep -rn "get_by_folder\|GetByFolder" src/ --include="*.py"
echo ""
echo "3. Checking API models for key field..."
grep -rn 'key.*Field\|Field.*key' src/server/models/ --include="*.py" | head -20
echo ""
echo "4. Checking database models..."
grep -rn "key.*unique\|key.*index" src/server/database/models.py
echo ""
echo "5. Checking frontend key usage..."
grep -rn "selectedSeries\|\.key\|data-key" src/server/web/static/js/ --include="*.js" | head -20
echo ""
echo "6. Running tests..."
conda run -n AniWorld python -m pytest tests/unit/test_serie_class.py -v --tb=short
echo ""
echo "=== Validation Complete ==="
```
---
## Expected Results
After validation, you should confirm:
1. ✅ All core entities use `key` as primary identifier
2. ✅ All services look up series by `key`
3. ✅ All API endpoints use `key` for operations
4. ✅ All database queries use `key` for lookups
5. ✅ Frontend uses `key` for selection and API calls
6. ✅ WebSocket events include `key` in payload
7. ✅ All tests pass
8. ✅ Documentation clearly explains the convention
9. ✅ Deprecation warnings exist for legacy patterns
---
## Sign-off
Once validation is complete, update this section:
- [x] Phase 1: Core Entities - Validated by: **AI Agent** Date: **28 Nov 2025**
- [x] Phase 2: Services - Validated by: **AI Agent** Date: **28 Nov 2025**
- [ ] Phase 3: API - Validated by: **\_\_\_** Date: **\_\_\_**
- [ ] Phase 4: Frontend - Validated by: **\_\_\_** Date: **\_\_\_**
- [ ] Phase 5: Database - Validated by: **\_\_\_** Date: **\_\_\_**
- [ ] Phase 6: WebSocket - Validated by: **\_\_\_** Date: **\_\_\_**
- [ ] Phase 7: Tests - Validated by: **\_\_\_** Date: **\_\_\_**
**Final Approval:** \***\*\*\*\*\***\_\_\_\***\*\*\*\*\*** Date: **\*\***\_**\*\***

413
docs/infrastructure.md Normal file
View File

@@ -0,0 +1,413 @@
# Aniworld Web Application Infrastructure
```bash
conda activate AniWorld
```
## Project Structure
```
src/
├── core/ # Core application logic
│ ├── SeriesApp.py # Main application class
│ ├── SerieScanner.py # Directory scanner
│ ├── entities/ # Domain entities (series.py, SerieList.py)
│ ├── interfaces/ # Abstract interfaces (providers.py, callbacks.py)
│ ├── providers/ # Content providers (aniworld, streaming)
│ └── exceptions/ # Custom exceptions
├── server/ # FastAPI web application
│ ├── fastapi_app.py # Main FastAPI application
│ ├── controllers/ # Route controllers (health, page, error)
│ ├── api/ # API routes (auth, config, anime, download, websocket)
│ ├── models/ # Pydantic models
│ ├── services/ # Business logic services
│ ├── database/ # SQLAlchemy ORM layer
│ ├── utils/ # Utilities (dependencies, templates, security)
│ └── web/ # Frontend (templates, static assets)
├── cli/ # CLI application
data/ # Config, database, queue state
logs/ # Application logs
tests/ # Test suites
```
## Technology Stack
| Layer | Technology |
| --------- | ---------------------------------------------- |
| Backend | FastAPI, Uvicorn, SQLAlchemy, SQLite, Pydantic |
| Frontend | HTML5, CSS3, Vanilla JS, Bootstrap 5, HTMX |
| Security | JWT (python-jose), bcrypt (passlib) |
| Real-time | Native WebSocket |
## Series Identifier Convention
Throughout the codebase, three identifiers are used for anime series:
| Identifier | Type | Purpose | Example |
| ---------- | --------------- | ----------------------------------------------------------- | -------------------------- |
| `key` | Unique, Indexed | **PRIMARY** - All lookups, API operations, WebSocket events | `"attack-on-titan"` |
| `folder` | String | Display/filesystem metadata only (never for lookups) | `"Attack on Titan (2013)"` |
| `id` | Primary Key | Internal database key for relationships | `1`, `42` |
### Key Format Requirements
- **Lowercase only**: No uppercase letters allowed
- **URL-safe**: Only alphanumeric characters and hyphens
- **Hyphen-separated**: Words separated by single hyphens
- **No leading/trailing hyphens**: Must start and end with alphanumeric
- **No consecutive hyphens**: `attack--titan` is invalid
**Valid examples**: `"attack-on-titan"`, `"one-piece"`, `"86-eighty-six"`, `"re-zero"`
**Invalid examples**: `"Attack On Titan"`, `"attack_on_titan"`, `"attack on titan"`
### Migration Notes
- **Backward Compatibility**: API endpoints accepting `anime_id` will check `key` first, then fall back to `folder` lookup
- **Deprecation**: Folder-based lookups are deprecated and will be removed in a future version
- **New Code**: Always use `key` for identification; `folder` is metadata only
## API Endpoints
### Authentication (`/api/auth`)
- `POST /login` - Master password authentication (returns JWT)
- `POST /logout` - Invalidate session
- `GET /status` - Check authentication status
### Configuration (`/api/config`)
- `GET /` - Get configuration
- `PUT /` - Update configuration
- `POST /validate` - Validate without applying
- `GET /backups` - List backups
- `POST /backups/{name}/restore` - Restore backup
### Anime (`/api/anime`)
- `GET /` - List anime with missing episodes (returns `key` as identifier)
- `GET /{anime_id}` - Get anime details (accepts `key` or `folder` for backward compatibility)
- `POST /search` - Search for anime (returns `key` as identifier)
- `POST /add` - Add new series (extracts `key` from link URL)
- `POST /rescan` - Trigger library rescan
**Response Models:**
- `AnimeSummary`: `key` (primary identifier), `name`, `site`, `folder` (metadata), `missing_episodes`, `link`
- `AnimeDetail`: `key` (primary identifier), `title`, `folder` (metadata), `episodes`, `description`
### Download Queue (`/api/queue`)
- `GET /status` - Queue status and statistics
- `POST /add` - Add episodes to queue
- `DELETE /{item_id}` - Remove item
- `POST /start` | `/stop` | `/pause` | `/resume` - Queue control
- `POST /retry` - Retry failed downloads
- `DELETE /completed` - Clear completed items
**Request Models:**
- `DownloadRequest`: `serie_id` (key, primary identifier), `serie_folder` (filesystem path), `serie_name` (display), `episodes`, `priority`
**Response Models:**
- `DownloadItem`: `id`, `serie_id` (key), `serie_folder` (metadata), `serie_name`, `episode`, `status`, `progress`
- `QueueStatus`: `is_running`, `is_paused`, `active_downloads`, `pending_queue`, `completed_downloads`, `failed_downloads`
### WebSocket (`/ws/connect`)
Real-time updates for downloads, scans, and queue operations.
**Rooms**: `downloads`, `download_progress`, `scan_progress`
**Message Types**: `download_progress`, `download_complete`, `download_failed`, `queue_status`, `scan_progress`, `scan_complete`, `scan_failed`
**Series Identifier in Messages:**
All series-related WebSocket events include `key` as the primary identifier in their data payload:
```json
{
"type": "download_progress",
"timestamp": "2025-10-17T10:30:00.000Z",
"data": {
"download_id": "abc123",
"key": "attack-on-titan",
"folder": "Attack on Titan (2013)",
"percent": 45.2,
"speed_mbps": 2.5,
"eta_seconds": 180
}
}
```
## Database Models
| Model | Purpose |
| ----------------- | ---------------------------------------- |
| AnimeSeries | Series metadata (key, name, folder, etc) |
| Episode | Episodes linked to series |
| DownloadQueueItem | Queue items with status and progress |
| UserSession | JWT sessions with expiry |
**Mixins**: `TimestampMixin` (created_at, updated_at), `SoftDeleteMixin`
### AnimeSeries Identifier Fields
| Field | Type | Purpose |
| -------- | --------------- | ------------------------------------------------- |
| `id` | Primary Key | Internal database key for relationships |
| `key` | Unique, Indexed | **PRIMARY IDENTIFIER** for all lookups |
| `folder` | String | Filesystem metadata only (not for identification) |
**Database Service Methods:**
- `AnimeSeriesService.get_by_key(key)` - **Primary lookup method**
- `AnimeSeriesService.get_by_id(id)` - Internal lookup by database ID
- `AnimeSeriesService.get_all(db)` - Get all series from database
- `AnimeSeriesService.create(db, key, name, site, folder, episode_dict)` - Create new series
- `AnimeSeriesService.update(db, id, **kwargs)` - Update existing series
- `AnimeSeriesService.delete(db, id)` - Delete series by ID
- `AnimeSeriesService.upsert_sync(db, key, name, site, folder, episode_dict)` - Sync upsert for scanner
No `get_by_folder()` method exists - folder is never used for lookups.
## Data Storage Migration
### Background
The application has migrated from file-based storage to SQLite database storage for anime series metadata.
**Previous Storage (Deprecated)**:
- Individual `data` files (no extension) in each anime folder
- Example: `/anime-directory/Attack on Titan (2013)/data`
**Current Storage (Database)**:
- SQLite database at `data/aniworld.db`
- Managed by `AnimeSeriesService` using SQLAlchemy
### Migration Service
The `DataMigrationService` handles automatic migration of legacy data files to the database:
```python
from src.server.services.data_migration_service import DataMigrationService
# Check for legacy files
service = DataMigrationService()
files = await service.check_for_legacy_data_files(anime_directory)
# Migrate all to database
result = await service.migrate_all_legacy_data(anime_directory, db_session)
print(result) # Migration Result: 10 migrated, 2 skipped, 0 failed
# Optional: cleanup old files with backup
await service.cleanup_migrated_files(files, backup=True)
```
### Automatic Migration on Startup
Migration runs automatically during application startup:
1. Database is initialized (`init_db()`)
2. Legacy data files are detected
3. Files are migrated to database
4. Results are logged (no files are deleted automatically)
### Migration Result
```python
@dataclass
class MigrationResult:
total_found: int # Total legacy files found
migrated: int # Successfully migrated
failed: int # Failed to migrate
skipped: int # Already in database
errors: List[str] # Error messages
```
### Deprecation Notes
- **Legacy file-based storage is deprecated** - Do not create new `data` files
- **SerieScanner**: Updated to save to database (with file fallback for CLI)
- **API endpoints**: Now use database as primary source
- **CLI**: Still uses file-based storage for backward compatibility
### Related Files
| File | Purpose |
| ---- | ------- |
| `src/server/services/data_migration_service.py` | Migration service |
| `src/server/database/service.py` | Database CRUD operations |
| `src/server/database/models.py` | SQLAlchemy models |
| `src/core/SerieScanner.py` | Scanner with DB support |
## Core Services
### SeriesApp (`src/core/SeriesApp.py`)
Main engine for anime series management with async support, progress callbacks, and cancellation.
### Callback System (`src/core/interfaces/callbacks.py`)
- `ProgressCallback`, `ErrorCallback`, `CompletionCallback`
- Context classes include `key` + optional `folder` fields
- Thread-safe `CallbackManager` for multiple callback registration
### Services (`src/server/services/`)
| Service | Purpose |
| ---------------- | ----------------------------------------- |
| AnimeService | Series management, scans (uses SeriesApp) |
| DownloadService | Queue management, download execution |
| ScanService | Library scan operations with callbacks |
| ProgressService | Centralized progress tracking + WebSocket |
| WebSocketService | Real-time connection management |
| AuthService | JWT authentication, rate limiting |
| ConfigService | Configuration persistence with backups |
## Validation Utilities (`src/server/utils/validators.py`)
Provides data validation functions for ensuring data integrity across the application.
### Series Key Validation
- **`validate_series_key(key)`**: Validates key format (URL-safe, lowercase, hyphens only)
- Valid: `"attack-on-titan"`, `"one-piece"`, `"86-eighty-six"`
- Invalid: `"Attack On Titan"`, `"attack_on_titan"`, `"attack on titan"`
- **`validate_series_key_or_folder(identifier, allow_folder=True)`**: Backward-compatible validation
- Returns tuple `(identifier, is_key)` where `is_key` indicates if it's a valid key format
- Set `allow_folder=False` to require strict key format
### Other Validators
| Function | Purpose |
| --------------------------- | ------------------------------------------ |
| `validate_series_name` | Series display name validation |
| `validate_episode_range` | Episode range validation (1-1000) |
| `validate_download_quality` | Quality setting (360p-1080p, best, worst) |
| `validate_language` | Language codes (ger-sub, ger-dub, etc.) |
| `validate_anime_url` | Aniworld.to/s.to URL validation |
| `validate_backup_name` | Backup filename validation |
| `validate_config_data` | Configuration data structure validation |
| `sanitize_filename` | Sanitize filenames for safe filesystem use |
## Template Helpers (`src/server/utils/template_helpers.py`)
Provides utilities for template rendering and series data preparation.
### Core Functions
| Function | Purpose |
| -------------------------- | --------------------------------- |
| `get_base_context` | Base context for all templates |
| `render_template` | Render template with context |
| `validate_template_exists` | Check if template file exists |
| `list_available_templates` | List all available template files |
### Series Context Helpers
All series helpers use `key` as the primary identifier:
| Function | Purpose |
| ----------------------------------- | ---------------------------------------------- |
| `prepare_series_context` | Prepare series data for templates (uses `key`) |
| `get_series_by_key` | Find series by `key` (not `folder`) |
| `filter_series_by_missing_episodes` | Filter series with missing episodes |
**Example Usage:**
```python
from src.server.utils.template_helpers import prepare_series_context
series_data = [
{"key": "attack-on-titan", "name": "Attack on Titan", "folder": "Attack on Titan (2013)"},
{"key": "one-piece", "name": "One Piece", "folder": "One Piece (1999)"}
]
prepared = prepare_series_context(series_data, sort_by="name")
# Returns sorted list using 'key' as identifier
```
## Frontend
### Static Files
- CSS: `styles.css` (Fluent UI design), `ux_features.css` (accessibility)
- JS: `app.js`, `queue.js`, `websocket_client.js`, accessibility modules
### WebSocket Client
Native WebSocket wrapper with Socket.IO-compatible API:
```javascript
const socket = io();
socket.join("download_progress");
socket.on("download_progress", (data) => {
/* ... */
});
```
### Authentication
JWT tokens stored in localStorage, included as `Authorization: Bearer <token>`.
## Testing
```bash
# All tests
conda run -n AniWorld python -m pytest tests/ -v
# Unit tests only
conda run -n AniWorld python -m pytest tests/unit/ -v
# API tests
conda run -n AniWorld python -m pytest tests/api/ -v
```
## Production Notes
### Current (Single-Process)
- SQLite with WAL mode
- In-memory WebSocket connections
- File-based config and queue persistence
### Multi-Process Deployment
- Switch to PostgreSQL/MySQL
- Move WebSocket registry to Redis
- Use distributed locking for queue operations
- Consider Redis for session/cache storage
## Code Examples
### API Usage with Key Identifier
```python
# Fetching anime list - response includes 'key' as identifier
response = requests.get("/api/anime", headers={"Authorization": f"Bearer {token}"})
anime_list = response.json()
# Each item has: key="attack-on-titan", folder="Attack on Titan (2013)", ...
# Fetching specific anime by key (preferred)
response = requests.get("/api/anime/attack-on-titan", headers={"Authorization": f"Bearer {token}"})
# Adding to download queue using key
download_request = {
"serie_id": "attack-on-titan", # Use key, not folder
"serie_folder": "Attack on Titan (2013)", # Metadata for filesystem
"serie_name": "Attack on Titan",
"episodes": ["S01E01", "S01E02"],
"priority": 1
}
response = requests.post("/api/queue/add", json=download_request, headers=headers)
```
### WebSocket Event Handling
```javascript
// WebSocket events always include 'key' as identifier
socket.on("download_progress", (data) => {
const key = data.key; // Primary identifier: "attack-on-titan"
const folder = data.folder; // Metadata: "Attack on Titan (2013)"
updateProgressBar(key, data.percent);
});
```

View File

@@ -1,120 +0,0 @@
# Aniworld Web Application Development Instructions
This document provides detailed tasks for AI agents to implement a modern web application for the Aniworld anime download manager. All tasks should follow the coding guidelines specified in the project's copilot instructions.
## Project Overview
The goal is to create a FastAPI-based web application that provides a modern interface for the existing Aniworld anime download functionality. The core anime logic should remain in `SeriesApp.py` while the web layer provides REST API endpoints and a responsive UI.
## Architecture Principles
- **Single Responsibility**: Each file/class has one clear purpose
- **Dependency Injection**: Use FastAPI's dependency system
- **Clean Separation**: Web layer calls core logic, never the reverse
- **File Size Limit**: Maximum 500 lines per file
- **Type Hints**: Use comprehensive type annotations
- **Error Handling**: Proper exception handling and logging
## Additional Implementation Guidelines
### Code Style and Standards
- **Type Hints**: Use comprehensive type annotations throughout all modules
- **Docstrings**: Follow PEP 257 for function and class documentation
- **Error Handling**: Implement custom exception classes with meaningful messages
- **Logging**: Use structured logging with appropriate log levels
- **Security**: Validate all inputs and sanitize outputs
- **Performance**: Use async/await patterns for I/O operations
## 📞 Escalation
If you encounter:
- Architecture issues requiring design decisions
- Tests that conflict with documented requirements
- Breaking changes needed
- Unclear requirements or expectations
**Document the issue and escalate rather than guessing.**
---
## <20> Credentials
**Admin Login:**
- Username: `admin`
- Password: `Hallo123!`
---
## <20>📚 Helpful Commands
```bash
# Run all tests
conda run -n AniWorld python -m pytest tests/ -v --tb=short
# Run specific test file
conda run -n AniWorld python -m pytest tests/unit/test_websocket_service.py -v
# Run specific test class
conda run -n AniWorld python -m pytest tests/unit/test_websocket_service.py::TestWebSocketService -v
# Run specific test
conda run -n AniWorld python -m pytest tests/unit/test_websocket_service.py::TestWebSocketService::test_broadcast_download_progress -v
# Run with extra verbosity
conda run -n AniWorld python -m pytest tests/ -vv
# Run with full traceback
conda run -n AniWorld python -m pytest tests/ -v --tb=long
# Run and stop at first failure
conda run -n AniWorld python -m pytest tests/ -v -x
# Run tests matching pattern
conda run -n AniWorld python -m pytest tests/ -v -k "auth"
# Show all print statements
conda run -n AniWorld python -m pytest tests/ -v -s
#Run app
conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000 --reload
```
---
## Implementation Notes
1. **Incremental Development**: Implement features incrementally, testing each component thoroughly before moving to the next
2. **Code Review**: Review all generated code for adherence to project standards
3. **Documentation**: Document all public APIs and complex logic
4. **Testing**: Maintain test coverage above 80% for all new code
5. **Performance**: Profile and optimize critical paths, especially download and streaming operations
6. **Security**: Regular security audits and dependency updates
7. **Monitoring**: Implement comprehensive monitoring and alerting
8. **Maintenance**: Plan for regular maintenance and updates
---
## Task Completion Checklist
For each task completed:
- [ ] Implementation follows coding standards
- [ ] Unit tests written and passing
- [ ] Integration tests passing
- [ ] Documentation updated
- [ ] Error handling implemented
- [ ] Logging added
- [ ] Security considerations addressed
- [ ] Performance validated
- [ ] Code reviewed
- [ ] Task marked as complete in instructions.md
- [ ] Infrastructure.md updated and other docs
- [ ] Changes committed to git; keep your messages in git short and clear
- [ ] Take the next task
---
## TODO List:

View File

@@ -1,4 +0,0 @@
API key : 299ae8f630a31bda814263c551361448
/mnt/server/serien/Serien/

53
features.md Normal file
View File

@@ -0,0 +1,53 @@
# Aniworld Web Application Features
## Authentication & Security
- **Master Password Login**: Secure access to the application with a master password system
- **JWT Token Sessions**: Stateless authentication with JSON Web Tokens
- **Rate Limiting**: Built-in protection against brute force attacks
## Configuration Management
- **Setup Page**: Initial configuration interface for server setup and basic settings
- **Config Page**: View and modify application configuration settings
- **Scheduler Configuration**: Configure automated rescan schedules
- **Backup Management**: Create, restore, and manage configuration backups
## User Interface
- **Dark Mode**: Toggle between light and dark themes for better user experience
- **Responsive Design**: Mobile-friendly interface with touch support
- **Real-time Updates**: WebSocket-based live notifications and progress tracking
## Anime Management
- **Anime Library Page**: Display list of anime series with missing episodes
- **Series Selection**: Select individual anime series and add episodes to download queue
- **Anime Search**: Search for anime series using integrated providers
- **Library Scanning**: Automated scanning for missing episodes
## Download Management
- **Download Queue Page**: View and manage the current download queue with organized sections
- **Queue Organization**: Displays downloads organized by status (pending, active, completed, failed)
- **Manual Start/Stop Control**: User manually starts downloads one at a time with Start/Stop buttons
- **FIFO Queue Processing**: First-in, first-out queue order (no priority or reordering)
- **Single Download Mode**: Only one download active at a time, new downloads must be manually started
- **Download Status Display**: Real-time status updates and progress of current download
- **Queue Operations**: Add and remove items from the pending queue
- **Completed Downloads List**: Separate section for completed downloads with clear button
- **Failed Downloads List**: Separate section for failed downloads with retry and clear options
- **Retry Failed Downloads**: Automatically retry failed downloads with configurable limits
- **Clear Completed**: Remove completed downloads from the queue
- **Clear Failed**: Remove failed downloads from the queue
- **Queue Statistics**: Real-time counters for pending, active, completed, and failed items
## Real-time Communication
- **WebSocket Support**: Real-time notifications for download progress and queue updates
- **Progress Tracking**: Live progress updates for downloads and scans
- **System Notifications**: Real-time system messages and alerts
## Core Functionality Overview
The web application provides a complete interface for managing anime downloads with user-friendly pages for configuration, library management, search capabilities, and download monitoring. All operations are tracked in real-time with comprehensive progress reporting and error handling.

131
fix_test_broadcasts.py Normal file
View File

@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Script to fix test files that use old set_broadcast_callback pattern."""
import re
import sys
from pathlib import Path
def fix_file(filepath: Path) -> bool:
"""Fix a single test file.
Args:
filepath: Path to the test file
Returns:
True if file was modified, False otherwise
"""
content = filepath.read_text()
original = content
# Pattern 1: Replace set_broadcast_callback calls
# Old: service.set_broadcast_callback(mock_broadcast)
# New: progress_service.subscribe("progress_updated", mock_event_handler)
# Pattern 2: Fix download_service fixture to return tuple
if "async def download_service(" in content and "yield service" in content:
content = re.sub(
r'(async def download_service\([^)]+\):.*?)(yield service)',
r'\1yield service, progress_service',
content,
flags=re.DOTALL
)
#Pattern 3: Unpack download_service in tests
if "def test_" in content or "async def test_" in content:
# Find tests that use download_service but don't unpack it
content = re.sub(
r'(async def test_[^\(]+\([^)]*download_service[^)]*\):.*?""".*?""")\s*broadcasts',
r'\1\n download_svc, progress_svc = download_service\n broadcasts',
content,
flags=re.DOTALL,
count=1 # Only first occurrence in each test
)
# Pattern 4: Replace set_broadcast_callback with subscribe
content = re.sub(
r'(\w+)\.set_broadcast_callback\((\w+)\)',
r'progress_service.subscribe("progress_updated", \2)',
content
)
# Pattern 5: Fix event handler signatures
# Old: async def mock_broadcast(message_type: str, room: str, data: dict):
# New: async def mock_event_handler(event):
content = re.sub(
r'async def (mock_broadcast\w*)\([^)]+\):(\s+"""[^"]*""")?(\s+)broadcasts\.append',
r'async def mock_event_handler(event):\2\3broadcasts.append',
content
)
# Pattern 6: Fix broadcast append calls
# Old: broadcasts.append({"type": message_type, "data": data})
# New: broadcasts.append({"type": event.event_type, "data": event.progress.to_dict()})
content = re.sub(
r'broadcasts\.append\(\{[^}]*"type":\s*message_type[^}]*\}\)',
'broadcasts.append({"type": event.event_type, "data": event.progress.to_dict()})',
content
)
# Pattern 7: Update download_service usage in tests to use unpacked version
content = re.sub(
r'await download_service\.add_to_queue\(',
r'await download_svc.add_to_queue(',
content
)
content = re.sub(
r'await download_service\.start',
r'await download_svc.start',
content
)
content = re.sub(
r'await download_service\.stop',
r'await download_svc.stop',
content
)
content = re.sub(
r'await download_service\.get_queue_status\(',
r'await download_svc.get_queue_status(',
content
)
content = re.sub(
r'await download_service\.remove_from_queue\(',
r'await download_svc.remove_from_queue(',
content
)
content = re.sub(
r'await download_service\.clear_completed\(',
r'await download_svc.clear_completed(',
content
)
if content != original:
filepath.write_text(content)
print(f"✓ Fixed {filepath}")
return True
else:
print(f" Skipped {filepath} (no changes needed)")
return False
def main():
"""Main function to fix all test files."""
test_dir = Path(__file__).parent / "tests"
# Find all test files that might need fixing
test_files = list(test_dir.rglob("test_*.py"))
print(f"Found {len(test_files)} test files")
print("Fixing test files...")
fixed_count = 0
for test_file in test_files:
if fix_file(test_file):
fixed_count += 1
print(f"\nFixed {fixed_count}/{len(test_files)} files")
return 0 if fixed_count > 0 else 1
if __name__ == "__main__":
sys.exit(main())

104
fix_tests.py Normal file
View File

@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Script to batch fix common test issues after API changes."""
import re
import sys
from pathlib import Path
def fix_add_to_queue_calls(content: str) -> str:
"""Add serie_folder parameter to add_to_queue calls."""
# Pattern: add_to_queue(\n serie_id="...",
# Add: serie_folder="...",
pattern = r'(add_to_queue\(\s+serie_id="([^"]+)",)'
def replace_func(match):
serie_id = match.group(2)
# Extract just the series name without number if present
serie_folder = serie_id.split('-')[0] if '-' in serie_id else serie_id
return f'{match.group(1)}\n serie_folder="{serie_folder}",'
return re.sub(pattern, replace_func, content)
def fix_queue_status_response(content: str) -> str:
"""Fix queue status response structure - remove nested 'status' key."""
# Replace data["status"]["pending"] with data["pending_queue"]
content = re.sub(r'data\["status"\]\["pending"\]', 'data["pending_queue"]', content)
content = re.sub(r'data\["status"\]\["active"\]', 'data["active_downloads"]', content)
content = re.sub(r'data\["status"\]\["completed"\]', 'data["completed_downloads"]', content)
content = re.sub(r'data\["status"\]\["failed"\]', 'data["failed_downloads"]', content)
content = re.sub(r'data\["status"\]\["is_running"\]', 'data["is_running"]', content)
content = re.sub(r'data\["status"\]\["is_paused"\]', 'data["is_paused"]', content)
# Also fix response.json()["status"]["..."]
content = re.sub(r'response\.json\(\)\["status"\]\["pending"\]', 'response.json()["pending_queue"]', content)
content = re.sub(r'response\.json\(\)\["status"\]\["is_running"\]', 'response.json()["is_running"]', content)
content = re.sub(r'status\.json\(\)\["status"\]\["is_running"\]', 'status.json()["is_running"]', content)
content = re.sub(r'status\.json\(\)\["status"\]\["failed"\]', 'status.json()["failed_downloads"]', content)
content = re.sub(r'status\.json\(\)\["status"\]\["completed"\]', 'status.json()["completed_downloads"]', content)
# Fix assert "status" in data
content = re.sub(r'assert "status" in data', 'assert "is_running" in data', content)
return content
def fix_anime_service_init(content: str) -> str:
"""Fix AnimeService initialization in test fixtures."""
# This one is complex, so we'll just note files that need manual review
if 'AnimeService(' in content and 'directory=' in content:
print(" ⚠️ Contains AnimeService with directory= parameter - needs manual review")
return content
def main():
test_dir = Path(__file__).parent / "tests"
if not test_dir.exists():
print(f"Error: {test_dir} not found")
sys.exit(1)
files_to_fix = [
# Download service tests
"unit/test_download_service.py",
"unit/test_download_progress_websocket.py",
"integration/test_download_progress_integration.py",
"integration/test_websocket_integration.py",
# API tests with queue status
"api/test_queue_features.py",
"api/test_download_endpoints.py",
"frontend/test_existing_ui_integration.py",
]
for file_path in files_to_fix:
full_path = test_dir / file_path
if not full_path.exists():
print(f"Skipping {file_path} (not found)")
continue
print(f"Processing {file_path}...")
# Read content
content = full_path.read_text()
original_content = content
# Apply fixes
if 'add_to_queue(' in content:
content = fix_add_to_queue_calls(content)
if 'data["status"]' in content or 'response.json()["status"]' in content:
content = fix_queue_status_response(content)
content = fix_anime_service_init(content)
# Write back if changed
if content != original_content:
full_path.write_text(content)
print(f" ✓ Updated {file_path}")
else:
print(f" - No changes needed for {file_path}")
if __name__ == "__main__":
main()

148
instructions.md Normal file
View File

@@ -0,0 +1,148 @@
# Aniworld Web Application Development Instructions
This document provides detailed tasks for AI agents to implement a modern web application for the Aniworld anime download manager. All tasks should follow the coding guidelines specified in the project's copilot instructions.
## Project Overview
The goal is to create a FastAPI-based web application that provides a modern interface for the existing Aniworld anime download functionality. The core anime logic should remain in `SeriesApp.py` while the web layer provides REST API endpoints and a responsive UI.
## Architecture Principles
- **Single Responsibility**: Each file/class has one clear purpose
- **Dependency Injection**: Use FastAPI's dependency system
- **Clean Separation**: Web layer calls core logic, never the reverse
- **File Size Limit**: Maximum 500 lines per file
- **Type Hints**: Use comprehensive type annotations
- **Error Handling**: Proper exception handling and logging
## Additional Implementation Guidelines
### Code Style and Standards
- **Type Hints**: Use comprehensive type annotations throughout all modules
- **Docstrings**: Follow PEP 257 for function and class documentation
- **Error Handling**: Implement custom exception classes with meaningful messages
- **Logging**: Use structured logging with appropriate log levels
- **Security**: Validate all inputs and sanitize outputs
- **Performance**: Use async/await patterns for I/O operations
## 📞 Escalation
If you encounter:
- Architecture issues requiring design decisions
- Tests that conflict with documented requirements
- Breaking changes needed
- Unclear requirements or expectations
**Document the issue and escalate rather than guessing.**
---
## 📚 Helpful Commands
```bash
# Run all tests
conda run -n AniWorld python -m pytest tests/ -v --tb=short
# Run specific test file
conda run -n AniWorld python -m pytest tests/unit/test_websocket_service.py -v
# Run specific test class
conda run -n AniWorld python -m pytest tests/unit/test_websocket_service.py::TestWebSocketService -v
# Run specific test
conda run -n AniWorld python -m pytest tests/unit/test_websocket_service.py::TestWebSocketService::test_broadcast_download_progress -v
# Run with extra verbosity
conda run -n AniWorld python -m pytest tests/ -vv
# Run with full traceback
conda run -n AniWorld python -m pytest tests/ -v --tb=long
# Run and stop at first failure
conda run -n AniWorld python -m pytest tests/ -v -x
# Run tests matching pattern
conda run -n AniWorld python -m pytest tests/ -v -k "auth"
# Show all print statements
conda run -n AniWorld python -m pytest tests/ -v -s
#Run app
conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000 --reload
```
---
## Final Implementation Notes
1. **Incremental Development**: Implement features incrementally, testing each component thoroughly before moving to the next
2. **Code Review**: Review all generated code for adherence to project standards
3. **Documentation**: Document all public APIs and complex logic
4. **Testing**: Maintain test coverage above 80% for all new code
5. **Performance**: Profile and optimize critical paths, especially download and streaming operations
6. **Security**: Regular security audits and dependency updates
7. **Monitoring**: Implement comprehensive monitoring and alerting
8. **Maintenance**: Plan for regular maintenance and updates
## Task Completion Checklist
For each task completed:
- [ ] Implementation follows coding standards
- [ ] Unit tests written and passing
- [ ] Integration tests passing
- [ ] Documentation updated
- [ ] Error handling implemented
- [ ] Logging added
- [ ] Security considerations addressed
- [ ] Performance validated
- [ ] Code reviewed
- [ ] Task marked as complete in instructions.md
- [ ] Infrastructure.md updated
- [ ] Changes committed to git; keep your messages in git short and clear
- [ ] Take the next task
---
### Prerequisites
1. Server is running: `conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.0.1 --port 8000 --reload`
2. Password: `Hallo123!`
3. Login via browser at `http://127.0.0.1:8000/login`
### Notes
- This is a simplification that removes complexity while maintaining core functionality
- Improves user experience with explicit manual control
- Easier to understand, test, and maintain
- Good foundation for future enhancements if needed
---
## ✅ COMPLETED: Migration Task - File-Based Storage to Database Storage
**Status**: All tasks completed on December 1, 2025
**Summary**:
- Created `DataMigrationService` for migrating legacy `data` files to SQLite
- Automatic migration on application startup
- Updated API endpoints to use database with fallback
- Added sync database methods to `AnimeSeriesService`
- Updated `SerieScanner` to save to database
- 28 unit tests + 14 integration tests (all passing)
- Documentation updated in `docs/infrastructure.md` and database README
**Files Created/Modified**:
- `src/server/services/data_migration_service.py` (new)
- `src/server/database/service.py` (sync methods added)
- `src/server/fastapi_app.py` (lifespan migration)
- `src/server/api/anime.py` (delete endpoint, DB integration)
- `src/core/SerieScanner.py` (DB save support)
- `tests/unit/test_data_migration_service.py` (new)
- `tests/integration/test_data_migration.py` (new)
**All 1048 tests pass.**

0
logs/download_errors.log Normal file
View File

View File

@@ -1,27 +0,0 @@
{
"name": "aniworld-web",
"version": "1.0.0",
"description": "Aniworld Anime Download Manager - Web Frontend",
"type": "module",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:e2e:debug": "playwright test --debug",
"playwright:install": "playwright install --with-deps chromium"
},
"devDependencies": {
"@playwright/test": "^1.41.0",
"@vitest/coverage-v8": "^1.2.0",
"@vitest/ui": "^1.2.0",
"happy-dom": "^13.3.5",
"vitest": "^1.2.0"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@@ -14,14 +14,5 @@ pytest==7.4.3
pytest-asyncio==0.21.1
httpx==0.25.2
sqlalchemy>=2.0.35
alembic==1.13.0
aiosqlite>=0.19.0
aiohttp>=3.9.0
lxml>=5.0.0
pillow>=10.0.0
APScheduler>=3.10.4
Events>=0.5
requests>=2.31.0
beautifulsoup4>=4.12.0
fake-useragent>=1.4.0
yt-dlp>=2024.1.0
urllib3>=2.0.0

View File

@@ -2,8 +2,7 @@
"""
Startup script for the Aniworld FastAPI application.
This script starts the application with proper logging configuration
and graceful shutdown support via Ctrl+C (SIGINT) or SIGTERM.
This script starts the application with proper logging configuration.
"""
import uvicorn
@@ -16,11 +15,6 @@ if __name__ == "__main__":
# Run the application with logging.
# Only watch .py files in src/, explicitly exclude __pycache__.
# This prevents reload loops from .pyc compilation.
#
# Graceful shutdown:
# - Ctrl+C (SIGINT) or SIGTERM triggers graceful shutdown
# - timeout_graceful_shutdown ensures shutdown completes within 30s
# - The FastAPI lifespan handler orchestrates cleanup in proper order
uvicorn.run(
"src.server.fastapi_app:app",
host="127.0.0.1",
@@ -30,5 +24,4 @@ if __name__ == "__main__":
reload_includes=["*.py"],
reload_excludes=["*/__pycache__/*", "*.pyc"],
log_config=log_config,
timeout_graceful_shutdown=30, # Allow 30s for graceful shutdown
)

421
scripts/setup.py Normal file
View File

@@ -0,0 +1,421 @@
"""
Aniworld Application Setup Script
This script handles initial setup, dependency installation, database
initialization, and configuration for the Aniworld application.
Usage:
python setup.py [--environment {development|production}] [--no-deps]
python setup.py --help
"""
import argparse
import asyncio
import os
import subprocess
import sys
from pathlib import Path
class SetupManager:
"""Manages application setup and initialization."""
def __init__(
self,
environment: str = "development",
skip_deps: bool = False
):
"""
Initialize setup manager.
Args:
environment: Environment mode (development or production)
skip_deps: Skip dependency installation
"""
self.environment = environment
self.skip_deps = skip_deps
self.project_root = Path(__file__).parent.parent
self.conda_env = "AniWorld"
# ============================================================================
# Logging
# ============================================================================
@staticmethod
def log_info(message: str) -> None:
"""Log info message."""
print(f"\033[34m[INFO]\033[0m {message}")
@staticmethod
def log_success(message: str) -> None:
"""Log success message."""
print(f"\033[32m[SUCCESS]\033[0m {message}")
@staticmethod
def log_warning(message: str) -> None:
"""Log warning message."""
print(f"\033[33m[WARNING]\033[0m {message}")
@staticmethod
def log_error(message: str) -> None:
"""Log error message."""
print(f"\033[31m[ERROR]\033[0m {message}")
# ============================================================================
# Validation
# ============================================================================
def validate_environment(self) -> bool:
"""
Validate environment parameter.
Returns:
True if valid, False otherwise
"""
valid_envs = {"development", "production", "testing"}
if self.environment not in valid_envs:
self.log_error(
f"Invalid environment: {self.environment}. "
f"Must be one of: {valid_envs}"
)
return False
self.log_success(f"Environment: {self.environment}")
return True
def check_conda_env(self) -> bool:
"""
Check if conda environment exists.
Returns:
True if exists, False otherwise
"""
result = subprocess.run(
["conda", "env", "list"],
capture_output=True,
text=True
)
if self.conda_env in result.stdout:
self.log_success(f"Conda environment '{self.conda_env}' found")
return True
self.log_error(
f"Conda environment '{self.conda_env}' not found. "
f"Create with: conda create -n {self.conda_env} python=3.11"
)
return False
def check_python_version(self) -> bool:
"""
Check Python version.
Returns:
True if version >= 3.9, False otherwise
"""
if sys.version_info < (3, 9):
self.log_error(
f"Python 3.9+ required. Current: {sys.version_info.major}."
f"{sys.version_info.minor}"
)
return False
self.log_success(
f"Python version: {sys.version_info.major}."
f"{sys.version_info.minor}"
)
return True
# ============================================================================
# Directory Setup
# ============================================================================
def create_directories(self) -> bool:
"""
Create necessary directories.
Returns:
True if successful, False otherwise
"""
try:
directories = [
"logs",
"data",
"data/config_backups",
"Temp",
"tests",
"scripts",
]
self.log_info("Creating directories...")
for directory in directories:
dir_path = self.project_root / directory
dir_path.mkdir(parents=True, exist_ok=True)
self.log_success("Directories created")
return True
except Exception as e:
self.log_error(f"Failed to create directories: {e}")
return False
# ============================================================================
# Dependency Installation
# ============================================================================
def install_dependencies(self) -> bool:
"""
Install Python dependencies.
Returns:
True if successful, False otherwise
"""
if self.skip_deps:
self.log_warning("Skipping dependency installation")
return True
try:
requirements_file = self.project_root / "requirements.txt"
if not requirements_file.exists():
self.log_error(
f"requirements.txt not found at {requirements_file}"
)
return False
self.log_info("Installing dependencies...")
subprocess.run(
["conda", "run", "-n", self.conda_env,
"pip", "install", "-q", "-r", str(requirements_file)],
check=True
)
self.log_success("Dependencies installed")
return True
except subprocess.CalledProcessError as e:
self.log_error(f"Failed to install dependencies: {e}")
return False
# ============================================================================
# Environment Configuration
# ============================================================================
def create_env_files(self) -> bool:
"""
Create environment configuration files.
Returns:
True if successful, False otherwise
"""
try:
self.log_info("Creating environment configuration files...")
env_file = self.project_root / f".env.{self.environment}"
if env_file.exists():
self.log_warning(f"{env_file.name} already exists")
return True
# Create environment file with defaults
env_content = self._get_env_template()
env_file.write_text(env_content)
self.log_success(f"Created {env_file.name}")
return True
except Exception as e:
self.log_error(f"Failed to create env files: {e}")
return False
def _get_env_template(self) -> str:
"""
Get environment file template.
Returns:
Environment file content
"""
if self.environment == "production":
return """# Aniworld Production Configuration
# IMPORTANT: Set these values before running in production
# Security (REQUIRED - generate new values)
JWT_SECRET_KEY=change-this-to-a-secure-random-key
PASSWORD_SALT=change-this-to-a-secure-random-salt
MASTER_PASSWORD_HASH=change-this-to-hashed-password
# Database (REQUIRED - use PostgreSQL or MySQL in production)
DATABASE_URL=postgresql://user:password@localhost/aniworld
DATABASE_POOL_SIZE=20
DATABASE_MAX_OVERFLOW=10
# Application
ENVIRONMENT=production
ANIME_DIRECTORY=/var/lib/aniworld
TEMP_DIRECTORY=/tmp/aniworld
# Server
HOST=0.0.0.0
PORT=8000
WORKERS=4
# Security
CORS_ORIGINS=https://yourdomain.com
ALLOWED_HOSTS=yourdomain.com
# Logging
LOG_LEVEL=WARNING
LOG_FILE=logs/production.log
LOG_ROTATION_SIZE=10485760
LOG_RETENTION_DAYS=30
# Performance
API_RATE_LIMIT=60
SESSION_TIMEOUT_HOURS=24
MAX_CONCURRENT_DOWNLOADS=3
"""
else: # development
return """# Aniworld Development Configuration
# Security (Development defaults - NOT for production)
JWT_SECRET_KEY=dev-secret-key-change-in-production
PASSWORD_SALT=dev-salt-change-in-production
MASTER_PASSWORD_HASH=$2b$12$wP0KBVbJKVAb8CdSSXw0NeGTKCkbw4fSAFXIqR2/wDqPSEBn9w7lS
MASTER_PASSWORD=password
# Database
DATABASE_URL=sqlite:///./data/aniworld_dev.db
# Application
ENVIRONMENT=development
ANIME_DIRECTORY=/tmp/aniworld_dev
TEMP_DIRECTORY=/tmp/aniworld_dev/temp
# Server
HOST=127.0.0.1
PORT=8000
WORKERS=1
# Security
CORS_ORIGINS=*
# Logging
LOG_LEVEL=DEBUG
LOG_FILE=logs/development.log
# Performance
API_RATE_LIMIT=1000
SESSION_TIMEOUT_HOURS=168
MAX_CONCURRENT_DOWNLOADS=1
"""
# ============================================================================
# Database Initialization
# ============================================================================
async def init_database(self) -> bool:
"""
Initialize database.
Returns:
True if successful, False otherwise
"""
try:
self.log_info("Initializing database...")
# Import and run database initialization
os.chdir(self.project_root)
from src.server.database import init_db
await init_db()
self.log_success("Database initialized")
return True
except Exception as e:
self.log_error(f"Failed to initialize database: {e}")
return False
# ============================================================================
# Summary
# ============================================================================
def print_summary(self) -> None:
"""Print setup summary."""
self.log_info("=" * 50)
self.log_info("Setup Summary")
self.log_info("=" * 50)
self.log_info(f"Environment: {self.environment}")
self.log_info(f"Conda Environment: {self.conda_env}")
self.log_info(f"Project Root: {self.project_root}")
self.log_info("")
self.log_success("Setup complete!")
self.log_info("")
self.log_info("Next steps:")
self.log_info("1. Configure .env files with your settings")
if self.environment == "production":
self.log_info("2. Set up database (PostgreSQL/MySQL)")
self.log_info("3. Configure security settings")
self.log_info("4. Run: ./scripts/start.sh production")
else:
self.log_info("2. Run: ./scripts/start.sh development")
self.log_info("")
# ============================================================================
# Main Setup
# ============================================================================
async def run(self) -> int:
"""
Run setup process.
Returns:
0 if successful, 1 otherwise
"""
print("\033[34m" + "=" * 50 + "\033[0m")
print("\033[34mAniworld Application Setup\033[0m")
print("\033[34m" + "=" * 50 + "\033[0m")
print()
# Validation
if not self.validate_environment():
return 1
if not self.check_python_version():
return 1
if not self.check_conda_env():
return 1
# Setup
if not self.create_directories():
return 1
if not self.create_env_files():
return 1
if not self.install_dependencies():
return 1
# Initialize database
if not await self.init_database():
return 1
# Summary
self.print_summary()
return 0
async def main() -> int:
"""
Main entry point.
Returns:
Exit code
"""
parser = argparse.ArgumentParser(
description="Aniworld Application Setup"
)
parser.add_argument(
"--environment",
choices=["development", "production", "testing"],
default="development",
help="Environment to set up (default: development)"
)
parser.add_argument(
"--no-deps",
action="store_true",
help="Skip dependency installation"
)
args = parser.parse_args()
setup = SetupManager(
environment=args.environment,
skip_deps=args.no_deps
)
return await setup.run()
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)

245
scripts/start.sh Normal file
View File

@@ -0,0 +1,245 @@
#!/bin/bash
################################################################################
# Aniworld Application Startup Script
#
# This script initializes the development or production environment,
# installs dependencies, sets up the database, and starts the application.
#
# Usage:
# ./start.sh [development|production] [--no-install] [--no-migrate]
#
# Environment Variables:
# ENVIRONMENT: 'development' or 'production' (default: development)
# CONDA_ENV: Conda environment name (default: AniWorld)
# PORT: Server port (default: 8000)
# HOST: Server host (default: 127.0.0.1)
#
################################################################################
set -euo pipefail
# ============================================================================
# Configuration
# ============================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
CONDA_ENV="${CONDA_ENV:-AniWorld}"
ENVIRONMENT="${1:-development}"
INSTALL_DEPS="${INSTALL_DEPS:-true}"
RUN_MIGRATIONS="${RUN_MIGRATIONS:-true}"
PORT="${PORT:-8000}"
HOST="${HOST:-127.0.0.1}"
# ============================================================================
# Color Output
# ============================================================================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# ============================================================================
# Functions
# ============================================================================
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Check if conda environment exists
check_conda_env() {
if ! conda env list | grep -q "^$CONDA_ENV "; then
log_error "Conda environment '$CONDA_ENV' not found."
log_info "Create it with: conda create -n $CONDA_ENV python=3.11"
exit 1
fi
log_success "Conda environment '$CONDA_ENV' found."
}
# Validate environment parameter
validate_environment() {
if [[ ! "$ENVIRONMENT" =~ ^(development|production|testing)$ ]]; then
log_error "Invalid environment: $ENVIRONMENT"
log_info "Valid options: development, production, testing"
exit 1
fi
log_success "Environment set to: $ENVIRONMENT"
}
# Create necessary directories
create_directories() {
log_info "Creating necessary directories..."
mkdir -p "$PROJECT_ROOT/logs"
mkdir -p "$PROJECT_ROOT/data"
mkdir -p "$PROJECT_ROOT/data/config_backups"
mkdir -p "$PROJECT_ROOT/Temp"
log_success "Directories created."
}
# Install dependencies
install_dependencies() {
if [[ "$INSTALL_DEPS" != "true" ]]; then
log_warning "Skipping dependency installation."
return
fi
log_info "Installing dependencies..."
conda run -n "$CONDA_ENV" pip install -q -r "$PROJECT_ROOT/requirements.txt"
log_success "Dependencies installed."
}
# Run database migrations
run_migrations() {
if [[ "$RUN_MIGRATIONS" != "true" ]]; then
log_warning "Skipping database migrations."
return
fi
log_info "Running database migrations..."
cd "$PROJECT_ROOT"
conda run -n "$CONDA_ENV" \
python -m alembic upgrade head 2>/dev/null || log_warning "No migrations to run."
log_success "Database migrations completed."
}
# Initialize database
init_database() {
log_info "Initializing database..."
cd "$PROJECT_ROOT"
conda run -n "$CONDA_ENV" \
python -c "from src.server.database import init_db; import asyncio; asyncio.run(init_db())"
log_success "Database initialized."
}
# Create environment file if it doesn't exist
create_env_file() {
ENV_FILE="$PROJECT_ROOT/.env.$ENVIRONMENT"
if [[ ! -f "$ENV_FILE" ]]; then
log_warning "Creating $ENV_FILE with defaults..."
cat > "$ENV_FILE" << EOF
# Aniworld Configuration for $ENVIRONMENT
# Security Settings
JWT_SECRET_KEY=your-secret-key-here
PASSWORD_SALT=your-salt-here
MASTER_PASSWORD_HASH=\$2b\$12\$wP0KBVbJKVAb8CdSSXw0NeGTKCkbw4fSAFXIqR2/wDqPSEBn9w7lS
# Database
DATABASE_URL=sqlite:///./data/aniworld_${ENVIRONMENT}.db
# Application
ENVIRONMENT=${ENVIRONMENT}
ANIME_DIRECTORY=/path/to/anime
# Server
PORT=${PORT}
HOST=${HOST}
# Logging
LOG_LEVEL=$([ "$ENVIRONMENT" = "production" ] && echo "WARNING" || echo "DEBUG")
# Features (development only)
$([ "$ENVIRONMENT" = "development" ] && echo "DEBUG=true" || echo "DEBUG=false")
EOF
log_success "Created $ENV_FILE - please configure with your settings"
fi
}
# Start the application
start_application() {
log_info "Starting Aniworld application..."
log_info "Environment: $ENVIRONMENT"
log_info "Conda Environment: $CONDA_ENV"
log_info "Server: http://$HOST:$PORT"
cd "$PROJECT_ROOT"
case "$ENVIRONMENT" in
development)
log_info "Starting in development mode with auto-reload..."
conda run -n "$CONDA_ENV" \
python -m uvicorn \
src.server.fastapi_app:app \
--host "$HOST" \
--port "$PORT" \
--reload
;;
production)
WORKERS="${WORKERS:-4}"
log_info "Starting in production mode with $WORKERS workers..."
conda run -n "$CONDA_ENV" \
python -m uvicorn \
src.server.fastapi_app:app \
--host "$HOST" \
--port "$PORT" \
--workers "$WORKERS" \
--worker-class "uvicorn.workers.UvicornWorker"
;;
testing)
log_warning "Starting in testing mode..."
# Testing mode typically runs tests instead of starting server
conda run -n "$CONDA_ENV" \
python -m pytest tests/ -v --tb=short
;;
*)
log_error "Unknown environment: $ENVIRONMENT"
exit 1
;;
esac
}
# ============================================================================
# Main Script
# ============================================================================
main() {
log_info "=========================================="
log_info "Aniworld Application Startup"
log_info "=========================================="
# Parse command-line options
while [[ $# -gt 0 ]]; do
case "$1" in
--no-install)
INSTALL_DEPS="false"
shift
;;
--no-migrate)
RUN_MIGRATIONS="false"
shift
;;
*)
ENVIRONMENT="$1"
shift
;;
esac
done
validate_environment
check_conda_env
create_directories
create_env_file
install_dependencies
init_database
run_migrations
start_application
}
# Run main function
main "$@"

Binary file not shown.

Binary file not shown.

Binary file not shown.

316
src/cli/Main.py Normal file
View File

@@ -0,0 +1,316 @@
"""Command-line interface for the Aniworld anime download manager."""
import logging
import os
from typing import Optional, Sequence
from rich.progress import Progress
from src.core.entities.series import Serie
from src.core.SeriesApp import SeriesApp as CoreSeriesApp
LOG_FORMAT = "%(asctime)s - %(levelname)s - %(name)s - %(message)s"
logger = logging.getLogger(__name__)
class SeriesCLI:
"""Thin wrapper around :class:`SeriesApp` providing an interactive CLI."""
def __init__(self, directory_to_search: str) -> None:
print("Please wait while initializing...")
self.directory_to_search = directory_to_search
self.series_app = CoreSeriesApp(directory_to_search)
self._progress: Optional[Progress] = None
self._overall_task_id: Optional[int] = None
self._series_task_id: Optional[int] = None
self._episode_task_id: Optional[int] = None
self._scan_task_id: Optional[int] = None
# ------------------------------------------------------------------
# Utility helpers
# ------------------------------------------------------------------
def _get_series_list(self) -> Sequence[Serie]:
"""Return the currently cached series with missing episodes."""
return self.series_app.get_series_list()
# ------------------------------------------------------------------
# Display & selection
# ------------------------------------------------------------------
def display_series(self) -> None:
"""Print all series with assigned numbers."""
series = self._get_series_list()
if not series:
print("\nNo series with missing episodes were found.")
return
print("\nCurrent result:")
for index, serie in enumerate(series, start=1):
name = (serie.name or "").strip()
label = name if name else serie.folder
print(f"{index}. {label}")
def get_user_selection(self) -> Optional[Sequence[Serie]]:
"""Prompt the user to select one or more series for download."""
series = list(self._get_series_list())
if not series:
print("No series available for download.")
return None
self.display_series()
prompt = (
"\nSelect series by number (e.g. '1', '1,2' or 'all') "
"or type 'exit' to return: "
)
selection = input(prompt).strip().lower()
if selection in {"exit", ""}:
return None
if selection == "all":
return series
try:
indexes = [
int(value.strip()) - 1
for value in selection.split(",")
]
except ValueError:
print("Invalid selection. Returning to main menu.")
return None
chosen = [
series[i]
for i in indexes
if 0 <= i < len(series)
]
if not chosen:
print("No valid series selected.")
return None
return chosen
# ------------------------------------------------------------------
# Download logic
# ------------------------------------------------------------------
def download_series(self, series: Sequence[Serie]) -> None:
"""Download all missing episodes for the provided series list."""
total_episodes = sum(
len(episodes)
for serie in series
for episodes in serie.episodeDict.values()
)
if total_episodes == 0:
print("Selected series do not contain missing episodes.")
return
self._progress = Progress()
with self._progress:
self._overall_task_id = self._progress.add_task(
"[red]Processing...", total=total_episodes
)
self._series_task_id = self._progress.add_task(
"[green]Current series", total=1
)
self._episode_task_id = self._progress.add_task(
"[gray]Download", total=100
)
for serie in series:
serie_total = sum(len(eps) for eps in serie.episodeDict.values())
self._progress.update(
self._series_task_id,
total=max(serie_total, 1),
completed=0,
description=f"[green]{serie.folder}",
)
for season, episodes in serie.episodeDict.items():
for episode in episodes:
if not self.series_app.loader.is_language(
season, episode, serie.key
):
logger.info(
"Skipping %s S%02dE%02d because the desired language is unavailable",
serie.folder,
season,
episode,
)
continue
result = self.series_app.download(
serieFolder=serie.folder,
season=season,
episode=episode,
key=serie.key,
callback=self._update_download_progress,
)
if not result.success:
logger.error("Download failed: %s", result.message)
self._progress.advance(self._overall_task_id)
self._progress.advance(self._series_task_id)
self._progress.update(
self._episode_task_id,
completed=0,
description="[gray]Waiting...",
)
self._progress = None
self.series_app.refresh_series_list()
def _update_download_progress(self, percent: float) -> None:
"""Update the episode progress bar based on download progress."""
if not self._progress or self._episode_task_id is None:
return
description = f"[gray]Download: {percent:.1f}%"
self._progress.update(
self._episode_task_id,
completed=percent,
description=description,
)
# ------------------------------------------------------------------
# Rescan logic
# ------------------------------------------------------------------
def rescan(self) -> None:
"""Trigger a rescan of the anime directory using the core app."""
total_to_scan = self.series_app.SerieScanner.get_total_to_scan()
total_to_scan = max(total_to_scan, 1)
self._progress = Progress()
with self._progress:
self._scan_task_id = self._progress.add_task(
"[red]Scanning folders...",
total=total_to_scan,
)
result = self.series_app.ReScan(
callback=self._wrap_scan_callback(total_to_scan)
)
self._progress = None
self._scan_task_id = None
if result.success:
print(result.message)
else:
print(f"Scan failed: {result.message}")
def _wrap_scan_callback(self, total: int):
"""Create a callback that updates the scan progress bar."""
def _callback(folder: str, current: int) -> None:
if not self._progress or self._scan_task_id is None:
return
self._progress.update(
self._scan_task_id,
completed=min(current, total),
description=f"[green]{folder}",
)
return _callback
# ------------------------------------------------------------------
# Search & add logic
# ------------------------------------------------------------------
def search_mode(self) -> None:
"""Search for a series and add it to the local list if chosen."""
query = input("Enter search string: ").strip()
if not query:
return
results = self.series_app.search(query)
if not results:
print("No results found. Returning to main menu.")
return
print("\nSearch results:")
for index, result in enumerate(results, start=1):
print(f"{index}. {result.get('name', 'Unknown')}")
selection = input(
"\nSelect an option by number or press <enter> to cancel: "
).strip()
if selection == "":
return
try:
chosen_index = int(selection) - 1
except ValueError:
print("Invalid input. Returning to main menu.")
return
if not (0 <= chosen_index < len(results)):
print("Invalid selection. Returning to main menu.")
return
chosen = results[chosen_index]
serie = Serie(
chosen.get("link", ""),
chosen.get("name", "Unknown"),
"aniworld.to",
chosen.get("link", ""),
{},
)
self.series_app.List.add(serie)
self.series_app.refresh_series_list()
print(f"Added '{serie.name}' to the local catalogue.")
# ------------------------------------------------------------------
# Main loop
# ------------------------------------------------------------------
def run(self) -> None:
"""Run the interactive CLI loop."""
while True:
action = input(
"\nChoose action ('s' for search, 'i' for rescan, 'd' for download, 'q' to quit): "
).strip().lower()
if action == "s":
self.search_mode()
elif action == "i":
print("\nRescanning series...\n")
self.rescan()
elif action == "d":
selected_series = self.get_user_selection()
if selected_series:
self.download_series(selected_series)
elif action in {"q", "quit", "exit"}:
print("Goodbye!")
break
else:
print("Unknown command. Please choose 's', 'i', 'd', or 'q'.")
def configure_logging() -> None:
"""Set up a basic logging configuration for the CLI."""
logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
logging.getLogger("urllib3.connectionpool").setLevel(logging.ERROR)
logging.getLogger("charset_normalizer").setLevel(logging.ERROR)
def main() -> None:
"""Entry point for the CLI application."""
configure_logging()
default_dir = os.getenv("ANIME_DIRECTORY")
if not default_dir:
print(
"Environment variable ANIME_DIRECTORY is not set. Please configure it to the base anime directory."
)
return
app = SeriesCLI(default_dir)
app.run()
if __name__ == "__main__":
main()

491
src/cli/logs/aniworld.log Normal file
View File

@@ -0,0 +1,491 @@
2025-09-29 12:38:25 - INFO - __main__ - <module> - Enhanced logging system initialized
2025-09-29 12:38:25 - INFO - __main__ - <module> - Starting Aniworld Flask server...
2025-09-29 12:38:25 - INFO - __main__ - <module> - Anime directory: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien
2025-09-29 12:38:25 - INFO - __main__ - <module> - Log level: INFO
2025-09-29 12:38:25 - INFO - __main__ - <module> - Scheduled operations disabled
2025-09-29 12:38:25 - INFO - __main__ - <module> - Server will be available at http://localhost:5000
2025-09-29 12:38:30 - INFO - __main__ - <module> - Enhanced logging system initialized
2025-09-29 12:38:30 - INFO - __main__ - <module> - Starting Aniworld Flask server...
2025-09-29 12:38:30 - INFO - __main__ - <module> - Anime directory: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien
2025-09-29 12:38:30 - INFO - __main__ - <module> - Log level: INFO
2025-09-29 12:38:30 - INFO - __main__ - <module> - Scheduled operations disabled
2025-09-29 12:38:30 - INFO - __main__ - <module> - Server will be available at http://localhost:5000
2025-09-29 12:38:30 - WARNING - werkzeug - _log - * Debugger is active!
2025-09-29 12:38:40 - INFO - root - __init__ - Initialized Loader with base path: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien
2025-09-29 12:38:40 - INFO - root - load_series - Scanning anime folders in: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien
2025-09-29 12:38:40 - WARNING - root - load_series - Skipping .deletedByTMM - No data folder found
2025-09-29 12:38:40 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\2.5 Dimensional Seduction (2024)\data
2025-09-29 12:38:40 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\2.5 Dimensional Seduction (2024)\data for 2.5 Dimensional Seduction (2024)
2025-09-29 12:38:40 - WARNING - root - load_series - Skipping 25-dimensional-seduction - No data folder found
2025-09-29 12:38:40 - WARNING - root - load_series - Skipping 25-sai no Joshikousei (2018) - No data folder found
2025-09-29 12:38:40 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\7th Time Loop The Villainess Enjoys a Carefree Life Married to Her Worst Enemy! (2024)\data
2025-09-29 12:38:40 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\7th Time Loop The Villainess Enjoys a Carefree Life Married to Her Worst Enemy! (2024)\data for 7th Time Loop The Villainess Enjoys a Carefree Life Married to Her Worst Enemy! (2024)
2025-09-29 12:38:40 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\9-nine-rulers-crown\data
2025-09-29 12:38:40 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\9-nine-rulers-crown\data for 9-nine-rulers-crown
2025-09-29 12:38:40 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\A Couple of Cuckoos (2022)\data
2025-09-29 12:38:40 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\A Couple of Cuckoos (2022)\data for A Couple of Cuckoos (2022)
2025-09-29 12:38:40 - WARNING - root - load_series - Skipping A Time Called You (2023) - No data folder found
2025-09-29 12:38:40 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\A.I.C.O. Incarnation (2018)\data
2025-09-29 12:38:40 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\A.I.C.O. Incarnation (2018)\data for A.I.C.O. Incarnation (2018)
2025-09-29 12:38:40 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Aesthetica of a Rogue Hero (2012)\data
2025-09-29 12:38:40 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Aesthetica of a Rogue Hero (2012)\data for Aesthetica of a Rogue Hero (2012)
2025-09-29 12:38:40 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Alya Sometimes Hides Her Feelings in Russian (2024)\data
2025-09-29 12:38:40 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Alya Sometimes Hides Her Feelings in Russian (2024)\data for Alya Sometimes Hides Her Feelings in Russian (2024)
2025-09-29 12:38:40 - WARNING - root - load_series - Skipping American Horror Story (2011) - No data folder found
2025-09-29 12:38:40 - WARNING - root - load_series - Skipping Andor (2022) - No data folder found
2025-09-29 12:38:40 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Angels of Death (2018)\data
2025-09-29 12:38:40 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Angels of Death (2018)\data for Angels of Death (2018)
2025-09-29 12:38:40 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Aokana Four Rhythm Across the Blue (2016)\data
2025-09-29 12:38:40 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Aokana Four Rhythm Across the Blue (2016)\data for Aokana Four Rhythm Across the Blue (2016)
2025-09-29 12:38:40 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Arifureta (2019)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Arifureta (2019)\data for Arifureta (2019)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\As a Reincarnated Aristocrat, I'll Use My Appraisal Skill to Rise in the World (2024)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\As a Reincarnated Aristocrat, I'll Use My Appraisal Skill to Rise in the World (2024)\data for As a Reincarnated Aristocrat, I'll Use My Appraisal Skill to Rise in the World (2024)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\BOFURI I Don't Want to Get Hurt, so I'll Max Out My Defense. (2020)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\BOFURI I Don't Want to Get Hurt, so I'll Max Out My Defense. (2020)\data for BOFURI I Don't Want to Get Hurt, so I'll Max Out My Defense. (2020)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Black Butler (2008)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Black Butler (2008)\data for Black Butler (2008)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Black Clover (2017)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Black Clover (2017)\data for Black Clover (2017)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Blast of Tempest (2012)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Blast of Tempest (2012)\data for Blast of Tempest (2012)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Blood Lad (2013)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Blood Lad (2013)\data for Blood Lad (2013)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Blue Box (2024)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Blue Box (2024)\data for Blue Box (2024)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Blue Exorcist (2011)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Blue Exorcist (2011)\data for Blue Exorcist (2011)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Bogus Skill Fruitmaster About That Time I Became Able to Eat Unlimited Numbers of Skill Fruits (That Kill You) (2025)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Bogus Skill Fruitmaster About That Time I Became Able to Eat Unlimited Numbers of Skill Fruits (That Kill You) (2025)\data for Bogus Skill Fruitmaster About That Time I Became Able to Eat Unlimited Numbers of Skill Fruits (That Kill You) (2025)
2025-09-29 12:38:41 - WARNING - root - load_series - Skipping Boys Over Flowers (2009) - No data folder found
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Burst Angel (2004)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Burst Angel (2004)\data for Burst Angel (2004)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\By the Grace of the Gods (2020)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\By the Grace of the Gods (2020)\data for By the Grace of the Gods (2020)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Call of the Night (2022)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Call of the Night (2022)\data for Call of the Night (2022)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Campfire Cooking in Another World with My Absurd Skill (2023)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Campfire Cooking in Another World with My Absurd Skill (2023)\data for Campfire Cooking in Another World with My Absurd Skill (2023)
2025-09-29 12:38:41 - WARNING - root - load_series - Skipping Celebrity (2023) - No data folder found
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Chainsaw Man (2022)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Chainsaw Man (2022)\data for Chainsaw Man (2022)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Charlotte (2015)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Charlotte (2015)\data for Charlotte (2015)
2025-09-29 12:38:41 - WARNING - root - load_series - Skipping Cherish the Day (2020) - No data folder found
2025-09-29 12:38:41 - WARNING - root - load_series - Skipping Chernobyl (2019) - No data folder found
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Chillin in Another World with Level 2 Super Cheat Powers (2024)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Chillin in Another World with Level 2 Super Cheat Powers (2024)\data for Chillin in Another World with Level 2 Super Cheat Powers (2024)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Clannad (2007)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Clannad (2007)\data for Clannad (2007)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Classroom of the Elite (2017)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Classroom of the Elite (2017)\data for Classroom of the Elite (2017)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Clevatess (2025)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Clevatess (2025)\data for Clevatess (2025)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\DAN DA DAN (2024)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\DAN DA DAN (2024)\data for DAN DA DAN (2024)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Danmachi Is It Wrong to Try to Pick Up Girls in a Dungeon (2015)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Danmachi Is It Wrong to Try to Pick Up Girls in a Dungeon (2015)\data for Danmachi Is It Wrong to Try to Pick Up Girls in a Dungeon (2015)
2025-09-29 12:38:41 - WARNING - root - load_series - Skipping Das Buch von Boba Fett (2021) - No data folder found
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Date a Live (2013)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Date a Live (2013)\data for Date a Live (2013)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Dead Mount Death Play (2023)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Dead Mount Death Play (2023)\data for Dead Mount Death Play (2023)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Deadman Wonderland (2011)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Deadman Wonderland (2011)\data for Deadman Wonderland (2011)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Dealing with Mikadono Sisters Is a Breeze (2025)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Dealing with Mikadono Sisters Is a Breeze (2025)\data for Dealing with Mikadono Sisters Is a Breeze (2025)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Delicious in Dungeon (2024)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Delicious in Dungeon (2024)\data for Delicious in Dungeon (2024)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Demon Lord, Retry! (2019)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Demon Lord, Retry! (2019)\data for Demon Lord, Retry! (2019)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Demon Slave - The Chained Soldier (2024)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Demon Slave - The Chained Soldier (2024)\data for Demon Slave - The Chained Soldier (2024)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Demon Slayer Kimetsu no Yaiba (2019)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Demon Slayer Kimetsu no Yaiba (2019)\data for Demon Slayer Kimetsu no Yaiba (2019)
2025-09-29 12:38:41 - WARNING - root - load_series - Skipping Der Herr der Ringe Die Ringe der Macht (2022) - No data folder found
2025-09-29 12:38:41 - WARNING - root - load_series - Skipping Devil in Ohio (2022) - No data folder found
2025-09-29 12:38:41 - WARNING - root - load_series - Skipping Die Bibel (2013) - No data folder found
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Die Tagebücher der Apothekerin (2023)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Die Tagebücher der Apothekerin (2023)\data for Die Tagebücher der Apothekerin (2023)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Domestic Girlfriend (2019)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Domestic Girlfriend (2019)\data for Domestic Girlfriend (2019)
2025-09-29 12:38:41 - WARNING - root - load_series - Skipping Doona! (2023) - No data folder found
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Dr. STONE (2019)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Dr. STONE (2019)\data for Dr. STONE (2019)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Dragonball Super (2015)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Dragonball Super (2015)\data for Dragonball Super (2015)
2025-09-29 12:38:41 - WARNING - root - load_series - Skipping Failure Frame I Became the Strongest and Annihilated Everything With Low-Level Spells (2024) - No data folder found
2025-09-29 12:38:41 - WARNING - root - load_series - Skipping Fallout (2024) - No data folder found
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Farming Life in Another World (2023)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Farming Life in Another World (2023)\data for Farming Life in Another World (2023)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Frieren - Nach dem Ende der Reise (2023)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Frieren - Nach dem Ende der Reise (2023)\data for Frieren - Nach dem Ende der Reise (2023)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Fruits Basket (2019)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Fruits Basket (2019)\data for Fruits Basket (2019)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Gachiakuta (2025)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Gachiakuta (2025)\data for Gachiakuta (2025)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Gate (2015)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Gate (2015)\data for Gate (2015)
2025-09-29 12:38:41 - WARNING - root - load_series - Skipping Generation der Verdammten (2014) - No data folder found
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Girls und Panzer (2012)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Girls und Panzer (2012)\data for Girls und Panzer (2012)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Gleipnir (2020)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Gleipnir (2020)\data for Gleipnir (2020)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Golden Time (2013)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Golden Time (2013)\data for Golden Time (2013)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Grimgar, Ashes and Illusions (2016)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Grimgar, Ashes and Illusions (2016)\data for Grimgar, Ashes and Illusions (2016)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Harem in the Labyrinth of Another World (2022)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Harem in the Labyrinth of Another World (2022)\data for Harem in the Labyrinth of Another World (2022)
2025-09-29 12:38:41 - WARNING - root - load_series - Skipping Highschool D×D (2012) - No data folder found
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Hinamatsuri (2018)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Hinamatsuri (2018)\data for Hinamatsuri (2018)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\I Got a Cheat Skill in Another World and Became Unrivaled in The Real World Too (2023)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\I Got a Cheat Skill in Another World and Became Unrivaled in The Real World Too (2023)\data for I Got a Cheat Skill in Another World and Became Unrivaled in The Real World Too (2023)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\I Parry Everything What Do You Mean Im the Strongest Im Not Even an Adventurer Yet! (2024)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\I Parry Everything What Do You Mean Im the Strongest Im Not Even an Adventurer Yet! (2024)\data for I Parry Everything What Do You Mean Im the Strongest Im Not Even an Adventurer Yet! (2024)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\I'm the Evil Lord of an Intergalactic Empire! (2025)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\I'm the Evil Lord of an Intergalactic Empire! (2025)\data for I'm the Evil Lord of an Intergalactic Empire! (2025)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\I've Been Killing Slimes for 300 Years and Maxed Out My Level (2021)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\I've Been Killing Slimes for 300 Years and Maxed Out My Level (2021)\data for I've Been Killing Slimes for 300 Years and Maxed Out My Level (2021)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\In the Land of Leadale (2022)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\In the Land of Leadale (2022)\data for In the Land of Leadale (2022)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Ishura (2024)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Ishura (2024)\data for Ishura (2024)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Ill Become a Villainess Who Goes Down in History (2024)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Ill Become a Villainess Who Goes Down in History (2024)\data for Ill Become a Villainess Who Goes Down in History (2024)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\JUJUTSU KAISEN (2020)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\JUJUTSU KAISEN (2020)\data for JUJUTSU KAISEN (2020)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Kaguya-sama Love is War (2019)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Kaguya-sama Love is War (2019)\data for Kaguya-sama Love is War (2019)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Kaiju No. 8 (20200)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Kaiju No. 8 (20200)\data for Kaiju No. 8 (20200)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\KamiKatsu Meine Arbeit als Missionar in einer gottlosen Welt (2023)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\KamiKatsu Meine Arbeit als Missionar in einer gottlosen Welt (2023)\data for KamiKatsu Meine Arbeit als Missionar in einer gottlosen Welt (2023)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Knight's & Magic (2017)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Knight's & Magic (2017)\data for Knight's & Magic (2017)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Kombattanten werden entsandt! (2021)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Kombattanten werden entsandt! (2021)\data for Kombattanten werden entsandt! (2021)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\KonoSuba An Explosion on This Wonderful World! (2023)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\KonoSuba An Explosion on This Wonderful World! (2023)\data for KonoSuba An Explosion on This Wonderful World! (2023)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Konosuba God's Blessing on This Wonderful World! (2016)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Konosuba God's Blessing on This Wonderful World! (2016)\data for Konosuba God's Blessing on This Wonderful World! (2016)
2025-09-29 12:38:41 - WARNING - root - load_series - Skipping Krieg der Welten (2019) - No data folder found
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Kuma Kuma Kuma Bear (2020)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Kuma Kuma Kuma Bear (2020)\data for Kuma Kuma Kuma Bear (2020)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Log Horizon (2013)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Log Horizon (2013)\data for Log Horizon (2013)
2025-09-29 12:38:41 - WARNING - root - load_series - Skipping Loki (2021) - No data folder found
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Loner Life in Another World (2024)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Loner Life in Another World (2024)\data for Loner Life in Another World (2024)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Lord of Mysteries (2025)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Lord of Mysteries (2025)\data for Lord of Mysteries (2025)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Lycoris Recoil (2022)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Lycoris Recoil (2022)\data for Lycoris Recoil (2022)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Magic Maker How to Make Magic in Another World (2025)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Magic Maker How to Make Magic in Another World (2025)\data for Magic Maker How to Make Magic in Another World (2025)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Magical Girl Site (2018)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Magical Girl Site (2018)\data for Magical Girl Site (2018)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Management of a Novice Alchemist (2022)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Management of a Novice Alchemist (2022)\data for Management of a Novice Alchemist (2022)
2025-09-29 12:38:41 - WARNING - root - load_series - Skipping Marianne (2019) - No data folder found
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Meine Wiedergeburt als Schleim in einer anderen Welt (2018)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Meine Wiedergeburt als Schleim in einer anderen Welt (2018)\data for Meine Wiedergeburt als Schleim in einer anderen Welt (2018)
2025-09-29 12:38:41 - WARNING - root - load_series - Skipping Midnight Mass (2021) - No data folder found
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Mirai Nikki (2011)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Mirai Nikki (2011)\data for Mirai Nikki (2011)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Miss Kobayashi's Dragon Maid (2017)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Miss Kobayashi's Dragon Maid (2017)\data for Miss Kobayashi's Dragon Maid (2017)
2025-09-29 12:38:41 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Mob Psycho 100 (2016)\data
2025-09-29 12:38:41 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Mob Psycho 100 (2016)\data for Mob Psycho 100 (2016)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\More than a Married Couple, but Not Lovers (2022)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\More than a Married Couple, but Not Lovers (2022)\data for More than a Married Couple, but Not Lovers (2022)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Mushoku Tensei Jobless Reincarnation (2021)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Mushoku Tensei Jobless Reincarnation (2021)\data for Mushoku Tensei Jobless Reincarnation (2021)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\My Hero Academia Vigilantes (2025)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\My Hero Academia Vigilantes (2025)\data for My Hero Academia Vigilantes (2025)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\My Instant Death Ability Is So Overpowered, No One in This Other World Stands a Chance Against Me! (2024)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\My Instant Death Ability Is So Overpowered, No One in This Other World Stands a Chance Against Me! (2024)\data for My Instant Death Ability Is So Overpowered, No One in This Other World Stands a Chance Against Me! (2024)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\My Isekai Life (2022)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\My Isekai Life (2022)\data for My Isekai Life (2022)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\My Life as Inukai-san's Dog (2023)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\My Life as Inukai-san's Dog (2023)\data for My Life as Inukai-san's Dog (2023)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\My Unique Skill Makes Me OP even at Level 1 (2023)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\My Unique Skill Makes Me OP even at Level 1 (2023)\data for My Unique Skill Makes Me OP even at Level 1 (2023)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\New Saga (2025)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\New Saga (2025)\data for New Saga (2025)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Nina the Starry Bride (2024)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Nina the Starry Bride (2024)\data for Nina the Starry Bride (2024)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Nisekoi Liebe, Lügen & Yakuza (2014)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Nisekoi Liebe, Lügen & Yakuza (2014)\data for Nisekoi Liebe, Lügen & Yakuza (2014)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\No Game No Life (2014)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\No Game No Life (2014)\data for No Game No Life (2014)
2025-09-29 12:38:42 - WARNING - root - load_series - Skipping Obi-Wan Kenobi (2022) - No data folder found
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Orange (2016)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Orange (2016)\data for Orange (2016)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Peach Boy Riverside (2021)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Peach Boy Riverside (2021)\data for Peach Boy Riverside (2021)
2025-09-29 12:38:42 - WARNING - root - load_series - Skipping Penny Dreadful (2014) - No data folder found
2025-09-29 12:38:42 - WARNING - root - load_series - Skipping Planet Erde II Eine Erde - viele Welten (2016) - No data folder found
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Plastic Memories (2015)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Plastic Memories (2015)\data for Plastic Memories (2015)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Ragna Crimson (2023)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Ragna Crimson (2023)\data for Ragna Crimson (2023)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Rascal Does Not Dream of Bunny Girl Senpai (2018)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Rascal Does Not Dream of Bunny Girl Senpai (2018)\data for Rascal Does Not Dream of Bunny Girl Senpai (2018)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\ReMonster (2024)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\ReMonster (2024)\data for ReMonster (2024)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\ReZERO - Starting Life in Another World (2016)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\ReZERO - Starting Life in Another World (2016)\data for ReZERO - Starting Life in Another World (2016)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Reborn as a Vending Machine, I Now Wander the Dungeon (2023)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Reborn as a Vending Machine, I Now Wander the Dungeon (2023)\data for Reborn as a Vending Machine, I Now Wander the Dungeon (2023)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Redo of Healer (2021)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Redo of Healer (2021)\data for Redo of Healer (2021)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Rick and Morty (2013)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Rick and Morty (2013)\data for Rick and Morty (2013)
2025-09-29 12:38:42 - WARNING - root - load_series - Skipping Rocket & Groot (2017) - No data folder found
2025-09-29 12:38:42 - WARNING - root - load_series - Skipping Romulus (2020) - No data folder found
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Saga of Tanya the Evil (2017)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Saga of Tanya the Evil (2017)\data for Saga of Tanya the Evil (2017)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Seirei Gensouki Spirit Chronicles (2021)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Seirei Gensouki Spirit Chronicles (2021)\data for Seirei Gensouki Spirit Chronicles (2021)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Shangri-La Frontier (2023)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Shangri-La Frontier (2023)\data for Shangri-La Frontier (2023)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\She Professed Herself Pupil of the Wise Man (2022)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\She Professed Herself Pupil of the Wise Man (2022)\data for She Professed Herself Pupil of the Wise Man (2022)
2025-09-29 12:38:42 - WARNING - root - load_series - Skipping She-Hulk Die Anwältin (2022) - No data folder found
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Solo Leveling (2024)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Solo Leveling (2024)\data for Solo Leveling (2024)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Spice and Wolf (2008)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Spice and Wolf (2008)\data for Spice and Wolf (2008)
2025-09-29 12:38:42 - WARNING - root - load_series - Skipping Star Trek Discovery (2017) - No data folder found
2025-09-29 12:38:42 - WARNING - root - load_series - Skipping Stargate (1997) - No data folder found
2025-09-29 12:38:42 - WARNING - root - load_series - Skipping Stargate Atlantis (2004) - No data folder found
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Steins;Gate (2011)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Steins;Gate (2011)\data for Steins;Gate (2011)
2025-09-29 12:38:42 - WARNING - root - load_series - Skipping Sweet Tooth (2021) - No data folder found
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Sword of the Demon Hunter Kijin Gen (2025)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Sword of the Demon Hunter Kijin Gen (2025)\data for Sword of the Demon Hunter Kijin Gen (2025)
2025-09-29 12:38:42 - WARNING - root - load_series - Skipping Tales from the Loop (2020) - No data folder found
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Tamako Market (2013)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Tamako Market (2013)\data for Tamako Market (2013)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Ancient Magus' Bride (2017)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Ancient Magus' Bride (2017)\data for The Ancient Magus' Bride (2017)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Demon Sword Master of Excalibur Academy (2023)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Demon Sword Master of Excalibur Academy (2023)\data for The Demon Sword Master of Excalibur Academy (2023)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Devil is a Part-Timer! (2013)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Devil is a Part-Timer! (2013)\data for The Devil is a Part-Timer! (2013)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Dreaming Boy is a Realist (2023)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Dreaming Boy is a Realist (2023)\data for The Dreaming Boy is a Realist (2023)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Dungeon of Black Company (2021)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Dungeon of Black Company (2021)\data for The Dungeon of Black Company (2021)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Eminence in Shadow (2022)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Eminence in Shadow (2022)\data for The Eminence in Shadow (2022)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Familiar of Zero (2006)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Familiar of Zero (2006)\data for The Familiar of Zero (2006)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Faraway Paladin (2021)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Faraway Paladin (2021)\data for The Faraway Paladin (2021)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Gorilla Gods Go-To Girl (2025)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Gorilla Gods Go-To Girl (2025)\data for The Gorilla Gods Go-To Girl (2025)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Hidden Dungeon Only I Can Enter (2021)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Hidden Dungeon Only I Can Enter (2021)\data for The Hidden Dungeon Only I Can Enter (2021)
2025-09-29 12:38:42 - WARNING - root - load_series - Skipping The Last of Us (2023) - No data folder found
2025-09-29 12:38:42 - WARNING - root - load_series - Skipping The Man in the High Castle (2015) - No data folder found
2025-09-29 12:38:42 - WARNING - root - load_series - Skipping The Mandalorian (2019) - No data folder found
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Quintessential Quintuplets (2019)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Quintessential Quintuplets (2019)\data for The Quintessential Quintuplets (2019)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Saints Magic Power is Omnipotent (2021)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Saints Magic Power is Omnipotent (2021)\data for The Saints Magic Power is Omnipotent (2021)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Too-Perfect Saint Tossed Aside by My Fiance and Sold to Another Kingdom (2025)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Too-Perfect Saint Tossed Aside by My Fiance and Sold to Another Kingdom (2025)\data for The Too-Perfect Saint Tossed Aside by My Fiance and Sold to Another Kingdom (2025)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Unaware Atelier Meister (2025)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Unaware Atelier Meister (2025)\data for The Unaware Atelier Meister (2025)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Weakest Tamer Began a Journey to Pick Up Trash (2024)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\The Weakest Tamer Began a Journey to Pick Up Trash (2024)\data for The Weakest Tamer Began a Journey to Pick Up Trash (2024)
2025-09-29 12:38:42 - WARNING - root - load_series - Skipping The Witcher (2019) - No data folder found
2025-09-29 12:38:42 - WARNING - root - load_series - Skipping The World's Finest Assassin Gets Reincarnated in Another World as an Aristocrat (2021) - No data folder found
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\To Your Eternity (2021)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\To Your Eternity (2021)\data for To Your Eternity (2021)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Tomo-chan Is a Girl! (2023)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Tomo-chan Is a Girl! (2023)\data for Tomo-chan Is a Girl! (2023)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Tonikawa Over the Moon for You (2020)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Tonikawa Over the Moon for You (2020)\data for Tonikawa Over the Moon for You (2020)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Tsukimichi Moonlit Fantasy (2021)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Tsukimichi Moonlit Fantasy (2021)\data for Tsukimichi Moonlit Fantasy (2021)
2025-09-29 12:38:42 - WARNING - root - load_series - Skipping Unidentified - Die wahren X-Akten (2019) - No data folder found
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Unnamed Memory (2024)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Unnamed Memory (2024)\data for Unnamed Memory (2024)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Vom Landei zum Schwertheiligen (2025)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Vom Landei zum Schwertheiligen (2025)\data for Vom Landei zum Schwertheiligen (2025)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\WIND BREAKER (2024)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\WIND BREAKER (2024)\data for WIND BREAKER (2024)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\WITCH WATCH (2025)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\WITCH WATCH (2025)\data for WITCH WATCH (2025)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Wolf Girl & Black Prince (2014)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Wolf Girl & Black Prince (2014)\data for Wolf Girl & Black Prince (2014)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Worlds End Harem (2022)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Worlds End Harem (2022)\data for Worlds End Harem (2022)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Zom 100 Bucket List of the Dead (2023)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Zom 100 Bucket List of the Dead (2023)\data for Zom 100 Bucket List of the Dead (2023)
2025-09-29 12:38:42 - WARNING - root - load_series - Skipping a-couple-of-cuckoos - No data folder found
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\a-ninja-and-an-assassin-under-one-roof\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\a-ninja-and-an-assassin-under-one-roof\data for a-ninja-and-an-assassin-under-one-roof
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\a-nobodys-way-up-to-an-exploration-hero\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\a-nobodys-way-up-to-an-exploration-hero\data for a-nobodys-way-up-to-an-exploration-hero
2025-09-29 12:38:42 - WARNING - root - load_series - Skipping a-silent-voice - No data folder found
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\am-i-actually-the-strongest\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\am-i-actually-the-strongest\data for am-i-actually-the-strongest
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\anne-shirley\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\anne-shirley\data for anne-shirley
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\apocalypse-bringer-mynoghra\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\apocalypse-bringer-mynoghra\data for apocalypse-bringer-mynoghra
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\banished-from-the-heros-party-i-decided-to-live-a-quiet-life-in-the-countryside\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\banished-from-the-heros-party-i-decided-to-live-a-quiet-life-in-the-countryside\data for banished-from-the-heros-party-i-decided-to-live-a-quiet-life-in-the-countryside
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\beheneko the elf girls cat is secretly an s ranked monster (2025) (2025)\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\beheneko the elf girls cat is secretly an s ranked monster (2025) (2025)\data for beheneko the elf girls cat is secretly an s ranked monster (2025) (2025)
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\berserk-of-gluttony\data
2025-09-29 12:38:42 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\berserk-of-gluttony\data for berserk-of-gluttony
2025-09-29 12:38:42 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\black-summoner\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\black-summoner\data for black-summoner
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\boarding-school-juliet\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\boarding-school-juliet\data for boarding-school-juliet
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\buddy-daddies\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\buddy-daddies\data for buddy-daddies
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\can-a-boy-girl-friendship-survive\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\can-a-boy-girl-friendship-survive\data for can-a-boy-girl-friendship-survive
2025-09-29 12:38:43 - WARNING - root - load_series - Skipping chillin-in-another-world-with-level-2-super-cheat-powers - No data folder found
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\chillin-in-my-30s-after-getting-fired-from-the-demon-kings-army\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\chillin-in-my-30s-after-getting-fired-from-the-demon-kings-army\data for chillin-in-my-30s-after-getting-fired-from-the-demon-kings-army
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\choujin koukousei tachi wa isekai de mo yoyuu de ikinuku you desu\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\choujin koukousei tachi wa isekai de mo yoyuu de ikinuku you desu\data for choujin koukousei tachi wa isekai de mo yoyuu de ikinuku you desu
2025-09-29 12:38:43 - WARNING - root - load_series - Skipping clevatess - No data folder found
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\compass-20-animation-project\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\compass-20-animation-project\data for compass-20-animation-project
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\dragon-raja-the-blazing-dawn\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\dragon-raja-the-blazing-dawn\data for dragon-raja-the-blazing-dawn
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\dragonar-academy\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\dragonar-academy\data for dragonar-academy
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\drugstore-in-another-world-the-slow-life-of-a-cheat-pharmacist\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\drugstore-in-another-world-the-slow-life-of-a-cheat-pharmacist\data for drugstore-in-another-world-the-slow-life-of-a-cheat-pharmacist
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\fluffy-paradise\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\fluffy-paradise\data for fluffy-paradise
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\food-for-the-soul\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\food-for-the-soul\data for food-for-the-soul
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\handyman-saitou-in-another-world\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\handyman-saitou-in-another-world\data for handyman-saitou-in-another-world
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\i-shall-survive-using-potions\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\i-shall-survive-using-potions\data for i-shall-survive-using-potions
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\im-giving-the-disgraced-noble-lady-i-rescued-a-crash-course-in-naughtiness\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\im-giving-the-disgraced-noble-lady-i-rescued-a-crash-course-in-naughtiness\data for im-giving-the-disgraced-noble-lady-i-rescued-a-crash-course-in-naughtiness
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\killing-bites\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\killing-bites\data for killing-bites
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\love-flops\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\love-flops\data for love-flops
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\magic-maker-how-to-make-magic-in-another-world\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\magic-maker-how-to-make-magic-in-another-world\data for magic-maker-how-to-make-magic-in-another-world
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\muhyo-rojis-bureau-of-supernatural-investigation\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\muhyo-rojis-bureau-of-supernatural-investigation\data for muhyo-rojis-bureau-of-supernatural-investigation
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\my-roommate-is-a-cat\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\my-roommate-is-a-cat\data for my-roommate-is-a-cat
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\nukitashi-the-animation\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\nukitashi-the-animation\data for nukitashi-the-animation
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\outbreak-company\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\outbreak-company\data for outbreak-company
2025-09-29 12:38:43 - WARNING - root - load_series - Skipping plastic-memories - No data folder found
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\pseudo-harem\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\pseudo-harem\data for pseudo-harem
2025-09-29 12:38:43 - WARNING - root - load_series - Skipping rent-a-girlfriend - No data folder found
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\sasaki-and-peeps\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\sasaki-and-peeps\data for sasaki-and-peeps
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\scooped-up-by-an-s-rank-adventurer\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\scooped-up-by-an-s-rank-adventurer\data for scooped-up-by-an-s-rank-adventurer
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\secrets-of-the-silent-witch\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\secrets-of-the-silent-witch\data for secrets-of-the-silent-witch
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\seton-academy-join-the-pack\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\seton-academy-join-the-pack\data for seton-academy-join-the-pack
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\shachibato-president-its-time-for-battle\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\shachibato-president-its-time-for-battle\data for shachibato-president-its-time-for-battle
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\skeleton-knight-in-another-world\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\skeleton-knight-in-another-world\data for skeleton-knight-in-another-world
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\sugar-apple-fairy-tale\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\sugar-apple-fairy-tale\data for sugar-apple-fairy-tale
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\summer-pockets\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\summer-pockets\data for summer-pockets
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\suppose-a-kid-from-the-last-dungeon-boonies-moved-to-a-starter-town\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\suppose-a-kid-from-the-last-dungeon-boonies-moved-to-a-starter-town\data for suppose-a-kid-from-the-last-dungeon-boonies-moved-to-a-starter-town
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-beginning-after-the-end\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-beginning-after-the-end\data for the-beginning-after-the-end
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-brilliant-healers-new-life-in-the-shadows\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-brilliant-healers-new-life-in-the-shadows\data for the-brilliant-healers-new-life-in-the-shadows
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-daily-life-of-a-middle-aged-online-shopper-in-another-world\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-daily-life-of-a-middle-aged-online-shopper-in-another-world\data for the-daily-life-of-a-middle-aged-online-shopper-in-another-world
2025-09-29 12:38:43 - WARNING - root - load_series - Skipping the-familiar-of-zero - No data folder found
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-fragrant-flower-blooms-with-dignity\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-fragrant-flower-blooms-with-dignity\data for the-fragrant-flower-blooms-with-dignity
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-great-cleric\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-great-cleric\data for the-great-cleric
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-new-chronicles-of-extraordinary-beings-preface\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-new-chronicles-of-extraordinary-beings-preface\data for the-new-chronicles-of-extraordinary-beings-preface
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-shiunji-family-children\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-shiunji-family-children\data for the-shiunji-family-children
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-shy-hero-and-the-assassin-princesses\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-shy-hero-and-the-assassin-princesses\data for the-shy-hero-and-the-assassin-princesses
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-testament-of-sister-new-devil\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-testament-of-sister-new-devil\data for the-testament-of-sister-new-devil
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-unwanted-undead-adventurer\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-unwanted-undead-adventurer\data for the-unwanted-undead-adventurer
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-water-magician\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-water-magician\data for the-water-magician
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-worlds-finest-assassin-gets-reincarnated-in-another-world-as-an-aristocrat\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-worlds-finest-assassin-gets-reincarnated-in-another-world-as-an-aristocrat\data for the-worlds-finest-assassin-gets-reincarnated-in-another-world-as-an-aristocrat
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-wrong-way-to-use-healing-magic\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\the-wrong-way-to-use-healing-magic\data for the-wrong-way-to-use-healing-magic
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\theres-no-freaking-way-ill-be-your-lover-unless\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\theres-no-freaking-way-ill-be-your-lover-unless\data for theres-no-freaking-way-ill-be-your-lover-unless
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\to-be-hero-x\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\to-be-hero-x\data for to-be-hero-x
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\tougen-anki\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\tougen-anki\data for tougen-anki
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\uglymug-epicfighter\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\uglymug-epicfighter\data for uglymug-epicfighter
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\valkyrie-drive-mermaid\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\valkyrie-drive-mermaid\data for valkyrie-drive-mermaid
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\wandering-witch-the-journey-of-elaina\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\wandering-witch-the-journey-of-elaina\data for wandering-witch-the-journey-of-elaina
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\war-god-system-im-counting-on-you\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\war-god-system-im-counting-on-you\data for war-god-system-im-counting-on-you
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\welcome-to-japan-ms-elf\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\welcome-to-japan-ms-elf\data for welcome-to-japan-ms-elf
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\welcome-to-the-outcasts-restaurant\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\welcome-to-the-outcasts-restaurant\data for welcome-to-the-outcasts-restaurant
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\yandere-dark-elf-she-chased-me-all-the-way-from-another-world\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\yandere-dark-elf-she-chased-me-all-the-way-from-another-world\data for yandere-dark-elf-she-chased-me-all-the-way-from-another-world
2025-09-29 12:38:43 - INFO - root - load_series - Found data folder: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Übel Blatt (2025)\data
2025-09-29 12:38:43 - INFO - root - load_data - Successfully loaded \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien\Übel Blatt (2025)\data for Übel Blatt (2025)
2025-09-29 20:23:13 - INFO - __main__ - <module> - Enhanced logging system initialized
2025-09-29 20:23:13 - INFO - __main__ - <module> - Starting Aniworld Flask server...
2025-09-29 20:23:13 - INFO - __main__ - <module> - Anime directory: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien
2025-09-29 20:23:13 - INFO - __main__ - <module> - Log level: INFO
2025-09-29 20:23:13 - INFO - __main__ - <module> - Scheduled operations disabled
2025-09-29 20:23:13 - INFO - __main__ - <module> - Server will be available at http://localhost:5000
2025-09-29 20:23:16 - INFO - __main__ - <module> - Enhanced logging system initialized
2025-09-29 20:23:16 - INFO - root - __init__ - Initialized Loader with base path: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien
2025-09-29 20:23:16 - INFO - root - load_series - Scanning anime folders in: \\sshfs.r\ubuntu@192.168.178.43\media\serien\Serien
2025-09-29 20:23:16 - ERROR - root - init_series_app - Error initializing SeriesApp:
Traceback (most recent call last):
File "D:\repo\Aniworld/src/server/app.py", line 145, in init_series_app
series_app = SeriesApp(directory_to_search)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "D:\repo\Aniworld\src\Main.py", line 54, in __init__
self.List = SerieList(self.directory_to_search)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "D:\repo\Aniworld\src\server\core\entities\SerieList.py", line 9, in __init__
self.load_series()
File "D:\repo\Aniworld\src\server\core\entities\SerieList.py", line 29, in load_series
for anime_folder in os.listdir(self.directory):
^^^^^^^^^^^^^^^^^^^^^^^^^^
FileNotFoundError: [WinError 53] Der Netzwerkpfad wurde nicht gefunden: '\\\\sshfs.r\\ubuntu@192.168.178.43\\media\\serien\\Serien'
2025-09-29 20:23:16 - WARNING - werkzeug - _log - * Debugger is active!
2025-09-29 20:33:06 - DEBUG - schedule - clear - Deleting *all* jobs
2025-09-29 20:33:06 - INFO - application.services.scheduler_service - stop_scheduler - Scheduled operations stopped
2025-09-29 20:33:06 - INFO - __main__ - <module> - Scheduler stopped

View File

View File

View File

@@ -1,281 +0,0 @@
"""CLI command for NFO management.
This script provides command-line interface for creating, updating,
and checking NFO metadata files.
"""
import asyncio
import sys
from pathlib import Path
# Add src to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.config.settings import settings
from src.core.services.series_manager_service import SeriesManagerService
async def scan_and_create_nfo():
"""Scan all series and create missing NFO files."""
print("=" * 70)
print("NFO Auto-Creation Tool")
print("=" * 70)
if not settings.tmdb_api_key:
print("\n❌ Error: TMDB_API_KEY not configured")
print(" Set TMDB_API_KEY in .env file or environment")
print(" Get API key from: https://www.themoviedb.org/settings/api")
return 1
if not settings.anime_directory:
print("\n❌ Error: ANIME_DIRECTORY not configured")
return 1
print(f"\nAnime Directory: {settings.anime_directory}")
print(f"Auto-create NFO: {settings.nfo_auto_create}")
print(f"Update on scan: {settings.nfo_update_on_scan}")
print(f"Download poster: {settings.nfo_download_poster}")
print(f"Download logo: {settings.nfo_download_logo}")
print(f"Download fanart: {settings.nfo_download_fanart}")
if not settings.nfo_auto_create:
print("\n⚠️ Warning: NFO_AUTO_CREATE is set to False")
print(" Enable it in .env to auto-create NFO files")
print("\n Continuing anyway to demonstrate functionality...")
# Override for demonstration
settings.nfo_auto_create = True
print("\nInitializing series manager...")
manager = SeriesManagerService.from_settings()
# Get series list first
serie_list = manager.get_serie_list()
all_series = serie_list.get_all()
print(f"Found {len(all_series)} series in directory")
if not all_series:
print("\n⚠️ No series found. Add some anime series first.")
return 0
# Show series without NFO
series_without_nfo = []
for serie in all_series:
if not serie.has_nfo():
series_without_nfo.append(serie)
if series_without_nfo:
print(f"\nSeries without NFO: {len(series_without_nfo)}")
for serie in series_without_nfo[:5]: # Show first 5
print(f" - {serie.name} ({serie.folder})")
if len(series_without_nfo) > 5:
print(f" ... and {len(series_without_nfo) - 5} more")
else:
print("\n✅ All series already have NFO files!")
if not settings.nfo_update_on_scan:
print("\nNothing to do. Enable NFO_UPDATE_ON_SCAN to update existing NFOs.")
return 0
print("\nProcessing NFO files...")
print("(This may take a while depending on the number of series)")
try:
await manager.scan_and_process_nfo()
print("\n✅ NFO processing complete!")
# Show updated stats
serie_list.load_series() # Reload to get updated stats
all_series = serie_list.get_all()
series_with_nfo = [s for s in all_series if s.has_nfo()]
series_with_poster = [s for s in all_series if s.has_poster()]
series_with_logo = [s for s in all_series if s.has_logo()]
series_with_fanart = [s for s in all_series if s.has_fanart()]
print("\nFinal Statistics:")
print(f" Series with NFO: {len(series_with_nfo)}/{len(all_series)}")
print(f" Series with poster: {len(series_with_poster)}/{len(all_series)}")
print(f" Series with logo: {len(series_with_logo)}/{len(all_series)}")
print(f" Series with fanart: {len(series_with_fanart)}/{len(all_series)}")
except Exception as e:
print(f"\n❌ Error: {e}")
import traceback
traceback.print_exc()
return 1
finally:
await manager.close()
return 0
async def check_nfo_status():
"""Check NFO status for all series."""
print("=" * 70)
print("NFO Status Check")
print("=" * 70)
if not settings.anime_directory:
print("\n❌ Error: ANIME_DIRECTORY not configured")
return 1
print(f"\nAnime Directory: {settings.anime_directory}")
# Create series list (no NFO service needed for status check)
from src.core.entities.SerieList import SerieList
serie_list = SerieList(settings.anime_directory)
all_series = serie_list.get_all()
if not all_series:
print("\n⚠️ No series found")
return 0
print(f"\nTotal series: {len(all_series)}")
# Categorize series
with_nfo = []
without_nfo = []
for serie in all_series:
if serie.has_nfo():
with_nfo.append(serie)
else:
without_nfo.append(serie)
print(f"\nWith NFO: {len(with_nfo)} ({len(with_nfo) * 100 // len(all_series)}%)")
print(f"Without NFO: {len(without_nfo)} ({len(without_nfo) * 100 // len(all_series)}%)")
if without_nfo:
print("\nSeries missing NFO:")
for serie in without_nfo[:10]:
print(f"{serie.name} ({serie.folder})")
if len(without_nfo) > 10:
print(f" ... and {len(without_nfo) - 10} more")
# Media file statistics
with_poster = sum(1 for s in all_series if s.has_poster())
with_logo = sum(1 for s in all_series if s.has_logo())
with_fanart = sum(1 for s in all_series if s.has_fanart())
print("\nMedia Files:")
print(f" Posters: {with_poster}/{len(all_series)} ({with_poster * 100 // len(all_series)}%)")
print(f" Logos: {with_logo}/{len(all_series)} ({with_logo * 100 // len(all_series)}%)")
print(f" Fanart: {with_fanart}/{len(all_series)} ({with_fanart * 100 // len(all_series)}%)")
return 0
async def update_nfo_files():
"""Update existing NFO files with fresh data from TMDB."""
print("=" * 70)
print("NFO Update Tool")
print("=" * 70)
if not settings.tmdb_api_key:
print("\n❌ Error: TMDB_API_KEY not configured")
print(" Set TMDB_API_KEY in .env file or environment")
print(" Get API key from: https://www.themoviedb.org/settings/api")
return 1
if not settings.anime_directory:
print("\n❌ Error: ANIME_DIRECTORY not configured")
return 1
print(f"\nAnime Directory: {settings.anime_directory}")
print(f"Download media: {settings.nfo_download_poster or settings.nfo_download_logo or settings.nfo_download_fanart}")
# Get series with NFO
from src.core.entities.SerieList import SerieList
serie_list = SerieList(settings.anime_directory)
all_series = serie_list.get_all()
series_with_nfo = [s for s in all_series if s.has_nfo()]
if not series_with_nfo:
print("\n⚠️ No series with NFO files found")
print(" Run 'scan' command first to create NFO files")
return 0
print(f"\nFound {len(series_with_nfo)} series with NFO files")
print("Updating NFO files with fresh data from TMDB...")
print("(This may take a while)")
# Initialize NFO service using factory
from src.core.services.nfo_factory import create_nfo_service
try:
nfo_service = create_nfo_service()
except ValueError as e:
print(f"\nError: {e}")
return 1
success_count = 0
error_count = 0
try:
for i, serie in enumerate(series_with_nfo, 1):
print(f"\n[{i}/{len(series_with_nfo)}] Updating: {serie.name}")
try:
await nfo_service.update_tvshow_nfo(
serie_folder=serie.folder,
download_media=(
settings.nfo_download_poster or
settings.nfo_download_logo or
settings.nfo_download_fanart
)
)
print(f" ✅ Updated successfully")
success_count += 1
# Small delay to respect API rate limits
await asyncio.sleep(0.5)
except Exception as e:
print(f" ❌ Error: {e}")
error_count += 1
print("\n" + "=" * 70)
print(f"✅ Update complete!")
print(f" Success: {success_count}")
print(f" Errors: {error_count}")
except Exception as e:
print(f"\n❌ Fatal error: {e}")
import traceback
traceback.print_exc()
return 1
finally:
await nfo_service.close()
return 0
def main():
"""Main CLI entry point."""
if len(sys.argv) < 2:
print("NFO Management Tool")
print("\nUsage:")
print(" python -m src.cli.nfo_cli scan # Scan and create missing NFO files")
print(" python -m src.cli.nfo_cli status # Check NFO status for all series")
print(" python -m src.cli.nfo_cli update # Update existing NFO files with fresh data")
print("\nConfiguration:")
print(" Set TMDB_API_KEY in .env file")
print(" Set NFO_AUTO_CREATE=true to enable auto-creation")
print(" Set NFO_UPDATE_ON_SCAN=true to update existing NFOs during scan")
return 1
command = sys.argv[1].lower()
if command == "scan":
return asyncio.run(scan_and_create_nfo())
elif command == "status":
return asyncio.run(check_nfo_status())
elif command == "update":
return asyncio.run(update_nfo_files())
else:
print(f"Unknown command: {command}")
print("Use 'scan', 'status', or 'update'")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -73,48 +73,6 @@ class Settings(BaseSettings):
validation_alias="RETRY_ATTEMPTS"
)
# NFO / TMDB Settings
tmdb_api_key: Optional[str] = Field(
default=None,
validation_alias="TMDB_API_KEY",
description="TMDB API key for scraping TV show metadata"
)
nfo_auto_create: bool = Field(
default=False,
validation_alias="NFO_AUTO_CREATE",
description="Automatically create NFO files when scanning series"
)
nfo_update_on_scan: bool = Field(
default=False,
validation_alias="NFO_UPDATE_ON_SCAN",
description="Update existing NFO files when scanning series"
)
nfo_download_poster: bool = Field(
default=True,
validation_alias="NFO_DOWNLOAD_POSTER",
description="Download poster.jpg when creating NFO"
)
nfo_download_logo: bool = Field(
default=True,
validation_alias="NFO_DOWNLOAD_LOGO",
description="Download logo.png when creating NFO"
)
nfo_download_fanart: bool = Field(
default=True,
validation_alias="NFO_DOWNLOAD_FANART",
description="Download fanart.jpg when creating NFO"
)
nfo_image_size: str = Field(
default="original",
validation_alias="NFO_IMAGE_SIZE",
description="Image size to download (original, w500, etc.)"
)
nfo_prefer_fsk_rating: bool = Field(
default=True,
validation_alias="NFO_PREFER_FSK_RATING",
description="Prefer German FSK rating over MPAA rating in NFO files"
)
@property
def allowed_origins(self) -> list[str]:
"""Return the list of allowed CORS origins.

View File

@@ -4,23 +4,26 @@ SerieScanner - Scans directories for anime series and missing episodes.
This module provides functionality to scan anime directories, identify
missing episodes, and report progress through callback interfaces.
Note:
This module is pure domain logic. Database operations are handled
by the service layer (AnimeService).
Supports both database storage (preferred) and file-based storage (fallback).
"""
from __future__ import annotations
import logging
import os
import re
import traceback
import uuid
from typing import Iterable, Iterator, Optional
from events import Events
from typing import Callable, Iterable, Iterator, Optional
from src.core.entities.series import Serie
from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException
from src.core.interfaces.callbacks import (
CallbackManager,
CompletionContext,
ErrorContext,
OperationType,
ProgressContext,
ProgressPhase,
)
from src.core.providers.base_provider import Loader
logger = logging.getLogger(__name__)
@@ -33,22 +36,15 @@ class SerieScanner:
Scans directories for anime series and identifies missing episodes.
Supports progress callbacks for real-time scanning updates.
Note:
This class is pure domain logic. Database operations are handled
by the service layer (AnimeService). Scan results are stored
in keyDict and can be retrieved after scanning.
Example:
scanner = SerieScanner("/path/to/anime", loader)
scanner.scan()
# Results are in scanner.keyDict
Prefers database storage when available, falls back to file storage.
"""
def __init__(
self,
basePath: str,
loader: Loader,
callback_manager: Optional[CallbackManager] = None,
use_database: bool = True,
) -> None:
"""
Initialize the SerieScanner.
@@ -57,6 +53,7 @@ class SerieScanner:
basePath: Base directory containing anime series
loader: Loader instance for fetching series information
callback_manager: Optional callback manager for progress updates
use_database: Whether to save to database (fallback to files)
Raises:
ValueError: If basePath is invalid or doesn't exist
@@ -75,121 +72,90 @@ class SerieScanner:
self.directory: str = abs_path
self.keyDict: dict[str, Serie] = {}
self.loader: Loader = loader
self._callback_manager: CallbackManager = (
callback_manager or CallbackManager()
)
self._current_operation_id: Optional[str] = None
self.events = Events()
self._use_database: bool = use_database
self._db_available: bool = False
self.events.on_progress = []
self.events.on_error = []
self.events.on_completion = []
# Check if database is available
if use_database:
try:
from src.server.database.connection import get_sync_session
session = get_sync_session()
session.close()
self._db_available = True
logger.info("Database available for SerieScanner")
except (ImportError, RuntimeError) as e:
logger.warning(
"Database not available, using file storage: %s", e
)
self._db_available = False
logger.info("Initialized SerieScanner with base path: %s", abs_path)
def _safe_call_event(self, event_handler, data: dict) -> None:
"""Safely call an event handler if it exists.
@property
def callback_manager(self) -> CallbackManager:
"""Get the callback manager instance."""
return self._callback_manager
def _save_serie(self, serie: Serie, data_path: str) -> None:
"""Save a serie to database (if available) and file.
This method handles saving serie data with database-first approach
and file fallback for backward compatibility.
Args:
event_handler: Event handler attribute (e.g., self.events.on_progress)
data: Data dictionary to pass to the event handler
serie: The Serie object to save.
data_path: The path to save the data file for fallback.
"""
if event_handler:
# Always save to file for backward compatibility
serie.save_to_file(data_path)
# Try to save to database if available
if self._use_database and self._db_available:
try:
# Event handlers are stored as lists, iterate over them
for handler in event_handler:
handler(data)
except Exception as e:
logger.error("Error calling event handler: %s", e, exc_info=True)
from src.server.database.connection import get_sync_session
from src.server.database.service import AnimeSeriesService
def subscribe_on_progress(self, handler):
"""
Subscribe a handler to an event.
Args:
handler: Callable to handle the event
"""
if handler not in self.events.on_progress:
self.events.on_progress.append(handler)
session = get_sync_session()
try:
# Convert episodeDict to JSON-serializable format
episode_dict_json = None
if serie.episodeDict:
episode_dict_json = {}
for season, episodes in serie.episodeDict.items():
season_key = str(season)
episode_dict_json[season_key] = [
ep.to_dict() if hasattr(ep, 'to_dict')
else str(ep)
for ep in episodes
]
def unsubscribe_on_progress(self, handler):
"""
Unsubscribe a handler from an event.
Args:
handler: Callable to remove
"""
if handler in self.events.on_progress:
self.events.on_progress.remove(handler)
# Get site from serie if available
site = getattr(serie, 'site', '') or ''
def _extract_year_from_folder_name(self, folder_name: str) -> int | None:
"""Extract year from folder name if present.
Looks for year in format "(YYYY)" at the end of folder name.
Args:
folder_name: The folder name to check
Returns:
int or None: Year if found, None otherwise
Example:
>>> _extract_year_from_folder_name("Dororo (2025)")
2025
>>> _extract_year_from_folder_name("Dororo")
None
"""
if not folder_name:
return None
# Look for year in format (YYYY) - typically at end of name
match = re.search(r'\((\d{4})\)', folder_name)
if match:
try:
year = int(match.group(1))
# Validate year is reasonable (between 1900 and 2100)
if 1900 <= year <= 2100:
logger.debug(
"Extracted year from folder name: %s -> %d",
folder_name,
year
# Upsert to database (static method call)
AnimeSeriesService.upsert_sync(
db=session,
key=serie.key,
name=serie.name,
site=site,
folder=serie.folder,
episode_dict=episode_dict_json
)
return year
except ValueError:
pass
return None
def subscribe_on_error(self, handler):
"""
Subscribe a handler to an event.
Args:
handler: Callable to handle the event
"""
if handler not in self.events.on_error:
self.events.on_error.append(handler)
def unsubscribe_on_error(self, handler):
"""
Unsubscribe a handler from an event.
Args:
handler: Callable to remove
"""
if handler in self.events.on_error:
self.events.on_error.remove(handler)
def subscribe_on_completion(self, handler):
"""
Subscribe a handler to an event.
Args:
handler: Callable to handle the event
"""
if handler not in self.events.on_completion:
self.events.on_completion.append(handler)
def unsubscribe_on_completion(self, handler):
"""
Unsubscribe a handler from an event.
Args:
handler: Callable to remove
"""
if handler in self.events.on_completion:
self.events.on_completion.remove(handler)
logger.debug(
"Saved serie to database: %s", serie.key
)
finally:
session.close()
except (ImportError, RuntimeError) as e:
logger.warning(
"Failed to save serie to database, "
"file backup exists: %s",
e
)
def reinit(self) -> None:
"""Reinitialize the series dictionary (keyed by serie.key)."""
@@ -204,12 +170,15 @@ class SerieScanner:
result = self.__find_mp4_files()
return sum(1 for _ in result)
def scan(self) -> None:
def scan(
self,
callback: Optional[Callable[[str, int], None]] = None
) -> None:
"""
Scan directories for anime series and missing episodes.
Results are stored in self.keyDict and can be retrieved after
scanning. Data files are also saved to disk for persistence.
Args:
callback: Optional legacy callback function (folder, count)
Raises:
Exception: If scan fails critically
@@ -220,16 +189,16 @@ class SerieScanner:
logger.info("Starting scan for missing episodes")
# Notify scan starting
self._safe_call_event(
self.events.on_progress,
{
"operation_id": self._current_operation_id,
"phase": "STARTING",
"current": 0,
"total": 0,
"percentage": 0.0,
"message": "Initializing scan"
}
self._callback_manager.notify_progress(
ProgressContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
phase=ProgressPhase.STARTING,
current=0,
total=0,
percentage=0.0,
message="Initializing scan"
)
)
try:
@@ -253,53 +222,33 @@ class SerieScanner:
else:
percentage = 0.0
# Progress is surfaced both through the callback manager
# (for the web/UI layer) and, for compatibility, through a
# legacy callback that updates CLI progress bars.
# Notify progress
self._safe_call_event(
self.events.on_progress,
{
"operation_id": self._current_operation_id,
"phase": "IN_PROGRESS",
"current": counter,
"total": total_to_scan,
"percentage": percentage,
"message": f"Scanning: {folder}",
"details": f"Found {len(mp4_files)} episodes"
}
self._callback_manager.notify_progress(
ProgressContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
phase=ProgressPhase.IN_PROGRESS,
current=counter,
total=total_to_scan,
percentage=percentage,
message=f"Scanning: {folder}",
details=f"Found {len(mp4_files)} episodes"
)
)
# Call legacy callback if provided
if callback:
callback(folder, counter)
serie = self.__read_data_from_file(folder)
if (
serie is not None
and serie.key
and serie.key.strip()
):
# Try to extract year from folder name first
if not hasattr(serie, 'year') or not serie.year:
year_from_folder = self._extract_year_from_folder_name(folder)
if year_from_folder:
serie.year = year_from_folder
logger.info(
"Using year from folder name: %s (year=%d)",
folder,
year_from_folder
)
else:
# If not in folder name, fetch from provider
try:
serie.year = self.loader.get_year(serie.key)
if serie.year:
logger.info(
"Fetched year from provider: %s (year=%d)",
serie.key,
serie.year
)
except Exception as e:
logger.warning(
"Could not fetch year for %s: %s",
serie.key,
str(e)
)
# Delegate the provider to compare local files with
# remote metadata, yielding missing episodes per
# season. Results are saved back to disk so that both
@@ -314,7 +263,8 @@ class SerieScanner:
data_path = os.path.join(
self.directory, folder, 'data'
)
serie.save_to_file(data_path)
# Save to database (if available) and file
self._save_serie(serie, data_path)
# Store by key (primary identifier), not folder
if serie.key in self.keyDict:
@@ -340,15 +290,15 @@ class SerieScanner:
error_msg = f"Error processing folder '{folder}': {nkfe}"
logger.error(error_msg)
self._safe_call_event(
self.events.on_error,
{
"operation_id": self._current_operation_id,
"error": nkfe,
"message": error_msg,
"recoverable": True,
"metadata": {"folder": folder, "key": None}
}
self._callback_manager.notify_error(
ErrorContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
error=nkfe,
message=error_msg,
recoverable=True,
metadata={"folder": folder, "key": None}
)
)
except Exception as e:
# Log error and notify via callback
@@ -362,30 +312,30 @@ class SerieScanner:
traceback.format_exc()
)
self._safe_call_event(
self.events.on_error,
{
"operation_id": self._current_operation_id,
"error": e,
"message": error_msg,
"recoverable": True,
"metadata": {"folder": folder, "key": None}
}
self._callback_manager.notify_error(
ErrorContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
error=e,
message=error_msg,
recoverable=True,
metadata={"folder": folder, "key": None}
)
)
continue
# Notify scan completion
self._safe_call_event(
self.events.on_completion,
{
"operation_id": self._current_operation_id,
"success": True,
"message": f"Scan completed. Processed {counter} folders.",
"statistics": {
self._callback_manager.notify_completion(
CompletionContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
success=True,
message=f"Scan completed. Processed {counter} folders.",
statistics={
"total_folders": counter,
"series_found": len(self.keyDict)
}
}
)
)
logger.info(
@@ -399,23 +349,23 @@ class SerieScanner:
error_msg = f"Critical scan error: {e}"
logger.error("%s\n%s", error_msg, traceback.format_exc())
self._safe_call_event(
self.events.on_error,
{
"operation_id": self._current_operation_id,
"error": e,
"message": error_msg,
"recoverable": False
}
self._callback_manager.notify_error(
ErrorContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
error=e,
message=error_msg,
recoverable=False
)
)
self._safe_call_event(
self.events.on_completion,
{
"operation_id": self._current_operation_id,
"success": False,
"message": error_msg
}
self._callback_manager.notify_completion(
CompletionContext(
operation_type=OperationType.SCAN,
operation_id=self._current_operation_id,
success=False,
message=error_msg
)
)
raise
@@ -435,6 +385,16 @@ class SerieScanner:
has_files = True
yield anime_name, mp4_files if has_files else []
def __remove_year(self, input_string: str) -> str:
"""Remove year information from input string."""
cleaned_string = re.sub(r'\(\d{4}\)', '', input_string).strip()
logger.debug(
"Removed year from '%s' -> '%s'",
input_string,
cleaned_string
)
return cleaned_string
def __read_data_from_file(self, folder_name: str) -> Optional[Serie]:
"""Read serie data from file or key file.
@@ -561,212 +521,3 @@ class SerieScanner:
episodes_dict[season] = missing_episodes
return episodes_dict, "aniworld.to"
def scan_single_series(
self,
key: str,
folder: str,
) -> dict[int, list[int]]:
"""
Scan a single series for missing episodes.
This method performs a targeted scan for only the specified series,
without triggering a full library rescan. It fetches available
episodes from the provider and compares with local files.
Args:
key: The unique provider key for the series
folder: The filesystem folder name where the series is stored
Returns:
dict[int, list[int]]: Dictionary mapping season numbers to lists
of missing episode numbers. Empty dict if no missing episodes.
Raises:
ValueError: If key or folder is empty
Example:
>>> scanner = SerieScanner("/path/to/anime", loader)
>>> missing = scanner.scan_single_series(
... "attack-on-titan",
... "Attack on Titan"
... )
>>> print(missing)
{1: [5, 6, 7], 2: [1, 2]}
"""
if not key or not key.strip():
raise ValueError("Series key cannot be empty")
if not folder or not folder.strip():
raise ValueError("Series folder cannot be empty")
logger.info(
"Starting targeted scan for series: %s (folder: %s)",
key,
folder
)
# Generate unique operation ID for this targeted scan
operation_id = str(uuid.uuid4())
# Notify scan starting
self._safe_call_event(
self.events.on_progress,
{
"operation_id": operation_id,
"phase": "STARTING",
"current": 0,
"total": 1,
"percentage": 0.0,
"message": f"Scanning series: {folder}",
"details": f"Key: {key}"
}
)
try:
# Get the folder path
folder_path = os.path.join(self.directory, folder)
# Check if folder exists
if not os.path.isdir(folder_path):
logger.info(
"Series folder does not exist yet: %s - "
"will scan for available episodes from provider",
folder_path
)
mp4_files: list[str] = []
else:
# Find existing MP4 files in the folder
mp4_files = []
for root, _, files in os.walk(folder_path):
for file in files:
if file.endswith(".mp4"):
mp4_files.append(os.path.join(root, file))
logger.debug(
"Found %d existing MP4 files in folder %s",
len(mp4_files),
folder
)
# Get missing episodes from provider
missing_episodes, site = self.__get_missing_episodes_and_season(
key, mp4_files
)
# Update progress
self._safe_call_event(
self.events.on_progress,
{
"operation_id": operation_id,
"phase": "IN_PROGRESS",
"current": 1,
"total": 1,
"percentage": 100.0,
"message": f"Scanned: {folder}",
"details": f"Found {sum(len(eps) for eps in missing_episodes.values())} missing episodes"
}
)
# Create or update Serie in keyDict
if key in self.keyDict:
# Update existing serie
self.keyDict[key].episodeDict = missing_episodes
logger.debug(
"Updated existing series %s with %d missing episodes",
key,
sum(len(eps) for eps in missing_episodes.values())
)
else:
# Try to extract year from folder name first
year = self._extract_year_from_folder_name(folder)
if year:
logger.info(
"Using year from folder name: %s (year=%d)",
folder,
year
)
else:
# If not in folder name, fetch from provider
try:
year = self.loader.get_year(key)
if year:
logger.info(
"Fetched year from provider: %s (year=%d)",
key,
year
)
except Exception as e:
logger.warning(
"Could not fetch year for %s: %s",
key,
str(e)
)
# Create new serie entry
serie = Serie(
key=key,
name="", # Will be populated by caller if needed
site=site,
folder=folder,
episodeDict=missing_episodes,
year=year
)
self.keyDict[key] = serie
logger.debug(
"Created new series entry for %s with %d missing episodes (year=%s)",
key,
sum(len(eps) for eps in missing_episodes.values()),
year
)
# Notify completion
self._safe_call_event(
self.events.on_completion,
{
"operation_id": operation_id,
"success": True,
"message": f"Scan completed for {folder}",
"statistics": {
"missing_episodes": sum(
len(eps) for eps in missing_episodes.values()
),
"seasons_with_missing": len(missing_episodes)
}
}
)
logger.info(
"Targeted scan completed for %s: %d missing episodes across %d seasons",
key,
sum(len(eps) for eps in missing_episodes.values()),
len(missing_episodes)
)
return missing_episodes
except Exception as e:
error_msg = f"Failed to scan series {key}: {e}"
logger.error(error_msg, exc_info=True)
# Notify error
self._safe_call_event(
self.events.on_error,
{
"operation_id": operation_id,
"error": e,
"message": error_msg,
"recoverable": True,
"metadata": {"key": key, "folder": folder}
}
)
# Notify completion with failure
self._safe_call_event(
self.events.on_completion,
{
"operation_id": operation_id,
"success": False,
"message": error_msg
}
)
# Return empty dict on error (scan failed but not critical)
return {}

View File

@@ -4,27 +4,18 @@ SeriesApp - Core application logic for anime series management.
This module provides the main application interface for searching,
downloading, and managing anime series with support for async callbacks,
progress reporting, and error handling.
Note:
This module is pure domain logic with no database dependencies.
Database operations are handled by the service layer (AnimeService).
"""
import asyncio
import logging
import os
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional
from events import Events
from src.config.settings import settings
from src.core.entities.SerieList import SerieList
from src.core.entities.series import Serie
from src.core.providers.provider_factory import Loaders
from src.core.SerieScanner import SerieScanner
from src.core.services.nfo_service import NFOService
from src.core.services.tmdb_client import TMDBAPIError
logger = logging.getLogger(__name__)
@@ -129,10 +120,6 @@ class SeriesApp:
Supports async callbacks for progress reporting.
Note:
This class is now pure domain logic with no database dependencies.
Database operations are handled by the service layer (AnimeService).
Events:
download_status: Raised when download status changes.
Handler signature: def handler(args: DownloadStatusEventArgs)
@@ -153,37 +140,18 @@ class SeriesApp:
self.directory_to_search = directory_to_search
# Initialize thread pool executor
self.executor = ThreadPoolExecutor(max_workers=3)
# Initialize events
self._events = Events()
self._events.download_status = None
self._events.scan_status = None
self.loaders = Loaders()
self.loader = self.loaders.GetLoader(key="aniworld.to")
self.serie_scanner = SerieScanner(
directory_to_search, self.loader
)
# Skip automatic loading from data files - series will be loaded
# from database by the service layer during application setup
self.list = SerieList(self.directory_to_search, skip_load=True)
self.series_list: List[Any] = []
# Initialize empty list - series loaded later via load_series_from_list()
# No need to call _init_list_sync() anymore
# Initialize NFO service if TMDB API key is configured
self.nfo_service: Optional[NFOService] = None
if settings.tmdb_api_key:
try:
from src.core.services.nfo_factory import get_nfo_factory
factory = get_nfo_factory()
self.nfo_service = factory.create()
logger.info("NFO service initialized successfully")
except (ValueError, Exception) as e: # pylint: disable=broad-except
logger.warning(
"Failed to initialize NFO service: %s", str(e)
)
self.nfo_service = None
self.serie_scanner = SerieScanner(directory_to_search, self.loader)
self.list = SerieList(self.directory_to_search)
# Synchronous init used during constructor to avoid awaiting
# in __init__
self._init_list_sync()
logger.info(
"SeriesApp initialized for directory: %s",
@@ -220,23 +188,21 @@ class SeriesApp:
"""Set scan_status event handler."""
self._events.scan_status = value
def load_series_from_list(self, series: list) -> None:
"""
Load series into the in-memory list.
This method is called by the service layer after loading
series from the database.
Args:
series: List of Serie objects to load
"""
self.list.keyDict.clear()
for serie in series:
self.list.keyDict[serie.key] = serie
def _init_list_sync(self) -> None:
"""Synchronous initialization helper for constructor."""
self.series_list = self.list.GetMissingEpisode()
logger.debug(
"Loaded %d series with %d having missing episodes",
len(series),
"Loaded %d series with missing episodes",
len(self.series_list)
)
async def _init_list(self) -> None:
"""Initialize the series list with missing episodes (async)."""
self.series_list = await asyncio.to_thread(
self.list.GetMissingEpisode
)
logger.debug(
"Loaded %d series with missing episodes",
len(self.series_list)
)
@@ -254,12 +220,7 @@ class SeriesApp:
RuntimeError: If search fails
"""
logger.info("Searching for: %s", words)
loop = asyncio.get_running_loop()
results = await loop.run_in_executor(
self.executor,
self.loader.search,
words
)
results = await asyncio.to_thread(self.loader.search, words)
logger.info("Found %d results", len(results))
return results
@@ -294,7 +255,6 @@ class SeriesApp:
lookups. The 'serie_folder' parameter is only used for
filesystem operations.
"""
logger.info(
"Starting download: %s (key: %s) S%02dE%02d",
serie_folder,
@@ -316,130 +276,10 @@ class SeriesApp:
)
)
# Create series folder if it doesn't exist
folder_path = os.path.join(self.directory_to_search, serie_folder)
if not os.path.exists(folder_path):
try:
os.makedirs(folder_path, exist_ok=True)
logger.info(
"Created series folder: %s (key: %s)",
folder_path,
key
)
except OSError as e:
logger.error(
"Failed to create series folder %s: %s",
folder_path,
str(e)
)
# Fire download failed event
self._events.download_status(
DownloadStatusEventArgs(
serie_folder=serie_folder,
key=key,
season=season,
episode=episode,
status="failed",
message=f"Failed to create folder: {str(e)}",
item_id=item_id,
)
)
return False
# Check and create NFO files if needed
if self.nfo_service and settings.nfo_auto_create:
try:
# Check if NFO exists
nfo_exists = await self.nfo_service.check_nfo_exists(
serie_folder
)
if not nfo_exists:
logger.info(
"NFO not found for %s, creating metadata...",
serie_folder
)
# Fire NFO creation started event
self._events.download_status(
DownloadStatusEventArgs(
serie_folder=serie_folder,
key=key,
season=season,
episode=episode,
status="nfo_creating",
message="Creating NFO metadata...",
item_id=item_id,
)
)
# Create NFO and download media files
try:
# Use folder name as series name
await self.nfo_service.create_tvshow_nfo(
serie_name=serie_folder,
serie_folder=serie_folder,
download_poster=settings.nfo_download_poster,
download_logo=settings.nfo_download_logo,
download_fanart=settings.nfo_download_fanart
)
logger.info(
"NFO and media files created for %s",
serie_folder
)
# Fire NFO creation completed event
self._events.download_status(
DownloadStatusEventArgs(
serie_folder=serie_folder,
key=key,
season=season,
episode=episode,
status="nfo_completed",
message="NFO metadata created",
item_id=item_id,
)
)
except TMDBAPIError as tmdb_error:
logger.warning(
"Failed to create NFO for %s: %s",
serie_folder,
str(tmdb_error)
)
# Fire failed event (but continue with download)
self._events.download_status(
DownloadStatusEventArgs(
serie_folder=serie_folder,
key=key,
season=season,
episode=episode,
status="nfo_failed",
message=(
f"NFO creation failed: "
f"{str(tmdb_error)}"
),
item_id=item_id,
)
)
else:
logger.debug("NFO already exists for %s", serie_folder)
except Exception as nfo_error: # pylint: disable=broad-except
logger.error(
"Error checking/creating NFO for %s: %s",
serie_folder,
str(nfo_error),
exc_info=True
)
# Don't fail the download if NFO creation fails
try:
def download_progress_handler(progress_info):
"""Handle download progress events from loader."""
def download_callback(progress_info):
logger.debug(
"download_progress_handler called with: %s", progress_info
"wrapped_callback called with: %s", progress_info
)
downloaded = progress_info.get('downloaded_bytes', 0)
@@ -469,28 +309,17 @@ class SeriesApp:
item_id=item_id,
)
)
# Subscribe to loader's download progress events
self.loader.subscribe_download_progress(download_progress_handler)
try:
# Perform download in thread to avoid blocking event loop
loop = asyncio.get_running_loop()
download_success = await loop.run_in_executor(
self.executor,
self.loader.download,
self.directory_to_search,
serie_folder,
season,
episode,
key,
language
)
finally:
# Always unsubscribe after download completes or fails
self.loader.unsubscribe_download_progress(
download_progress_handler
)
# Perform download in thread to avoid blocking event loop
download_success = await asyncio.to_thread(
self.loader.download,
self.directory_to_search,
serie_folder,
season,
episode,
key,
language,
download_callback
)
if download_success:
logger.info(
@@ -538,30 +367,7 @@ class SeriesApp:
return download_success
except InterruptedError:
# Download was cancelled - propagate the cancellation
logger.info(
"Download cancelled: %s (key: %s) S%02dE%02d",
serie_folder,
key,
season,
episode,
)
# Fire download cancelled event
self._events.download_status(
DownloadStatusEventArgs(
serie_folder=serie_folder,
key=key,
season=season,
episode=episode,
status="cancelled",
message="Download cancelled by user",
item_id=item_id,
)
)
raise # Re-raise to propagate cancellation
except Exception as e: # pylint: disable=broad-except
except Exception as e:
logger.error(
"Download error: %s (key: %s) S%02dE%02d - %s",
serie_folder,
@@ -588,40 +394,23 @@ class SeriesApp:
return False
async def rescan(self) -> list:
async def rescan(self) -> int:
"""
Rescan directory for missing episodes (async).
This method performs a file-based scan and returns the results.
Database persistence is handled by the service layer (AnimeService).
Returns:
List of Serie objects found during scan with their
missing episodes.
Note:
This method no longer saves to database directly. The returned
list should be persisted by the caller (AnimeService).
Number of series with missing episodes after rescan.
"""
logger.info("Starting directory rescan")
total_to_scan = 0
try:
# Get total items to scan
logger.info("Getting total items to scan...")
loop = asyncio.get_running_loop()
total_to_scan = await loop.run_in_executor(
self.executor,
total_to_scan = await asyncio.to_thread(
self.serie_scanner.get_total_to_scan
)
logger.info("Total folders to scan: %d", total_to_scan)
# Fire scan started event
logger.info(
"Firing scan_status 'started' event, handler=%s",
self._events.scan_status
)
self._events.scan_status(
ScanStatusEventArgs(
current=0,
@@ -634,60 +423,37 @@ class SeriesApp:
)
# Reinitialize scanner
await loop.run_in_executor(
self.executor,
self.serie_scanner.reinit
)
await asyncio.to_thread(self.serie_scanner.reinit)
def scan_callback(folder: str, current: int):
# Calculate progress
if total_to_scan > 0:
progress = current / total_to_scan
else:
progress = 0.0
def scan_progress_handler(progress_data):
"""Handle scan progress events from scanner."""
# Fire scan progress event
message = progress_data.get('message', '')
folder = message.replace('Scanning: ', '')
self._events.scan_status(
ScanStatusEventArgs(
current=progress_data.get('current', 0),
total=progress_data.get('total', total_to_scan),
current=current,
total=total_to_scan,
folder=folder,
status="progress",
progress=(
progress_data.get('percentage', 0.0) / 100.0
),
message=message,
progress=progress,
message=f"Scanning: {folder}",
)
)
# Subscribe to scanner's progress events
self.serie_scanner.subscribe_on_progress(scan_progress_handler)
# Perform scan
await asyncio.to_thread(self.serie_scanner.scan, scan_callback)
try:
# Perform scan (file-based, returns results in scanner.keyDict)
await loop.run_in_executor(
self.executor,
self.serie_scanner.scan
)
finally:
# Always unsubscribe after scan completes or fails
self.serie_scanner.unsubscribe_on_progress(
scan_progress_handler
)
# Get scanned series from scanner
scanned_series = list(self.serie_scanner.keyDict.values())
# Update in-memory list with scan results
self.list.keyDict.clear()
for serie in scanned_series:
self.list.keyDict[serie.key] = serie
self.series_list = self.list.GetMissingEpisode()
# Reinitialize list
self.list = SerieList(self.directory_to_search)
await self._init_list()
logger.info("Directory rescan completed successfully")
# Fire scan completed event
logger.info(
"Firing scan_status 'completed' event, handler=%s",
self._events.scan_status
)
self._events.scan_status(
ScanStatusEventArgs(
current=total_to_scan,
@@ -702,7 +468,7 @@ class SeriesApp:
)
)
return scanned_series
return len(self.series_list)
except InterruptedError:
logger.warning("Scan cancelled by user")
@@ -711,7 +477,7 @@ class SeriesApp:
self._events.scan_status(
ScanStatusEventArgs(
current=0,
total=total_to_scan,
total=total_to_scan if 'total_to_scan' in locals() else 0,
folder="",
status="cancelled",
message="Scan cancelled by user",
@@ -726,7 +492,7 @@ class SeriesApp:
self._events.scan_status(
ScanStatusEventArgs(
current=0,
total=total_to_scan,
total=total_to_scan if 'total_to_scan' in locals() else 0,
folder="",
status="failed",
error=e,
@@ -770,66 +536,3 @@ class SeriesApp:
looks up series by their unique key, not by folder name.
"""
return self.list.get_by_key(key)
def get_all_series_from_data_files(self) -> List[Serie]:
"""
Get all series from data files in the anime directory.
Scans the directory_to_search for all 'data' files and loads
the Serie metadata from each file. This method is synchronous
and can be wrapped with asyncio.to_thread if needed for async
contexts.
Returns:
List of Serie objects found in data files. Returns an empty
list if no data files are found or if the directory doesn't
exist.
Example:
series_app = SeriesApp("/path/to/anime")
all_series = series_app.get_all_series_from_data_files()
for serie in all_series:
print(f"Found: {serie.name} (key={serie.key})")
"""
logger.info(
"Scanning for data files in directory: %s",
self.directory_to_search
)
# Create a fresh SerieList instance for file-based loading
# This ensures we get all series from data files without
# interfering with the main instance's state
try:
temp_list = SerieList(
self.directory_to_search,
skip_load=False # Allow automatic loading
)
except (OSError, ValueError) as e:
logger.error(
"Failed to scan directory for data files: %s",
str(e),
exc_info=True
)
return []
# Get all series from the temporary list
all_series = temp_list.get_all()
logger.info(
"Found %d series from data files in %s",
len(all_series),
self.directory_to_search
)
return all_series
def shutdown(self) -> None:
"""
Shutdown the thread pool executor.
Should be called when the SeriesApp instance is no longer needed
to properly clean up resources.
"""
if hasattr(self, 'executor'):
self.executor.shutdown(wait=True)
logger.info("ThreadPoolExecutor shut down successfully")

View File

@@ -1,14 +1,4 @@
"""Utilities for loading and managing stored anime series metadata.
This module provides the SerieList class for managing collections of anime
series metadata. It uses file-based storage only.
Note:
This module is part of the core domain layer and has no database
dependencies. All database operations are handled by the service layer.
"""
from __future__ import annotations
"""Utilities for loading and managing stored anime series metadata."""
import logging
import os
@@ -18,8 +8,6 @@ from typing import Dict, Iterable, List, Optional
from src.core.entities.series import Serie
logger = logging.getLogger(__name__)
class SerieList:
"""
@@ -27,85 +15,35 @@ class SerieList:
Series are identified by their unique 'key' (provider identifier).
The 'folder' is metadata only and not used for lookups.
This class manages in-memory series data loaded from filesystem.
It has no database dependencies - all persistence is handled by
the service layer.
Example:
# File-based mode
serie_list = SerieList("/path/to/anime")
series = serie_list.get_all()
Attributes:
directory: Path to the anime directory
keyDict: Internal dictionary mapping serie.key to Serie objects
"""
def __init__(
self,
base_path: str,
skip_load: bool = False
) -> None:
"""Initialize the SerieList.
Args:
base_path: Path to the anime directory
skip_load: If True, skip automatic loading of series from files.
Useful when planning to load from database instead.
"""
def __init__(self, base_path: str) -> None:
self.directory: str = base_path
# Internal storage using serie.key as the dictionary key
self.keyDict: Dict[str, Serie] = {}
self.load_series()
# Only auto-load from files if not skipping
if not skip_load:
self.load_series()
def add(self, serie: Serie, use_sanitized_folder: bool = True) -> str:
def add(self, serie: Serie) -> None:
"""
Persist a new series if it is not already present (file-based mode).
Persist a new series if it is not already present.
Uses serie.key for identification. Creates the filesystem folder
using either the sanitized display name (default) or the existing
folder property.
Uses serie.key for identification. The serie.folder is used for
filesystem operations only.
Args:
serie: The Serie instance to add
use_sanitized_folder: If True (default), use serie.sanitized_folder
for the filesystem folder name based on display name.
If False, use serie.folder as-is for backward compatibility.
Returns:
str: The folder path that was created/used
Note:
This method creates data files on disk. For database storage,
use add_to_db() instead.
"""
if self.contains(serie.key):
# Return existing folder path
existing = self.keyDict[serie.key]
return os.path.join(self.directory, existing.folder)
return
# Determine folder name to use
if use_sanitized_folder:
folder_name = serie.sanitized_folder
# Update the serie's folder property to match what we create
serie.folder = folder_name
else:
folder_name = serie.folder
data_path = os.path.join(self.directory, folder_name, "data")
anime_path = os.path.join(self.directory, folder_name)
data_path = os.path.join(self.directory, serie.folder, "data")
anime_path = os.path.join(self.directory, serie.folder)
os.makedirs(anime_path, exist_ok=True)
if not os.path.isfile(data_path):
serie.save_to_file(data_path)
# Store by key, not folder
self.keyDict[serie.key] = serie
return anime_path
def contains(self, key: str) -> bool:
"""
Return True when a series identified by ``key`` already exists.
@@ -132,75 +70,11 @@ class SerieList:
)
return
nfo_stats = {"total": 0, "with_nfo": 0, "without_nfo": 0}
media_stats = {
"with_poster": 0,
"without_poster": 0,
"with_logo": 0,
"without_logo": 0,
"with_fanart": 0,
"without_fanart": 0
}
for anime_folder in entries:
anime_path = os.path.join(self.directory, anime_folder, "data")
if os.path.isfile(anime_path):
logging.debug("Found data file for folder %s", anime_folder)
serie = self._load_data(anime_folder, anime_path)
if serie:
nfo_stats["total"] += 1
# Check for NFO file
nfo_file_path = os.path.join(
self.directory, anime_folder, "tvshow.nfo"
)
if os.path.isfile(nfo_file_path):
serie.nfo_path = nfo_file_path
nfo_stats["with_nfo"] += 1
else:
nfo_stats["without_nfo"] += 1
logging.debug(
"Series '%s' (key: %s) is missing tvshow.nfo",
serie.name,
serie.key
)
# Check for media files
folder_path = os.path.join(self.directory, anime_folder)
poster_path = os.path.join(folder_path, "poster.jpg")
if os.path.isfile(poster_path):
media_stats["with_poster"] += 1
else:
media_stats["without_poster"] += 1
logging.debug(
"Series '%s' (key: %s) is missing poster.jpg",
serie.name,
serie.key
)
logo_path = os.path.join(folder_path, "logo.png")
if os.path.isfile(logo_path):
media_stats["with_logo"] += 1
else:
media_stats["without_logo"] += 1
logging.debug(
"Series '%s' (key: %s) is missing logo.png",
serie.name,
serie.key
)
fanart_path = os.path.join(folder_path, "fanart.jpg")
if os.path.isfile(fanart_path):
media_stats["with_fanart"] += 1
else:
media_stats["without_fanart"] += 1
logging.debug(
"Series '%s' (key: %s) is missing fanart.jpg",
serie.name,
serie.key
)
self._load_data(anime_folder, anime_path)
continue
logging.warning(
@@ -208,34 +82,13 @@ class SerieList:
anime_folder,
)
# Log summary statistics
if nfo_stats["total"] > 0:
logging.info(
"NFO scan complete: %d series total, %d with NFO, %d without NFO",
nfo_stats["total"],
nfo_stats["with_nfo"],
nfo_stats["without_nfo"]
)
logging.info(
"Media scan complete: Poster (%d/%d), Logo (%d/%d), Fanart (%d/%d)",
media_stats["with_poster"],
nfo_stats["total"],
media_stats["with_logo"],
nfo_stats["total"],
media_stats["with_fanart"],
nfo_stats["total"]
)
def _load_data(self, anime_folder: str, data_path: str) -> Optional[Serie]:
def _load_data(self, anime_folder: str, data_path: str) -> None:
"""
Load a single series metadata file into the in-memory collection.
Args:
anime_folder: The folder name (for logging only)
data_path: Path to the metadata file
Returns:
Serie: The loaded Serie object, or None if loading failed
"""
try:
serie = Serie.load_from_file(data_path)
@@ -246,7 +99,6 @@ class SerieList:
anime_folder,
serie.key
)
return serie
except (OSError, JSONDecodeError, KeyError, ValueError) as error:
logging.error(
"Failed to load metadata for folder %s from %s: %s",
@@ -254,7 +106,6 @@ class SerieList:
data_path,
error,
)
return None
def GetMissingEpisode(self) -> List[Serie]:
"""Return all series that still contain missing episodes."""

View File

@@ -1,335 +0,0 @@
"""Pydantic models for NFO metadata based on Kodi/XBMC standard.
This module provides data models for tvshow.nfo files that are compatible
with media center applications like Kodi, Plex, and Jellyfin.
Example:
>>> nfo = TVShowNFO(
... title="Attack on Titan",
... year=2013,
... tmdbid=1429
... )
>>> nfo.premiered = "2013-04-07"
"""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field, HttpUrl, field_validator
class RatingInfo(BaseModel):
"""Rating information from various sources.
Attributes:
name: Source of the rating (e.g., 'themoviedb', 'imdb')
value: Rating value (typically 0-10)
votes: Number of votes
max_rating: Maximum possible rating (default: 10)
default: Whether this is the default rating to display
"""
name: str = Field(..., description="Rating source name")
value: float = Field(..., ge=0, description="Rating value")
votes: Optional[int] = Field(None, ge=0, description="Number of votes")
max_rating: int = Field(10, ge=1, description="Maximum rating value")
default: bool = Field(False, description="Is this the default rating")
@field_validator('value')
@classmethod
def validate_value(cls, v: float, info) -> float:
"""Ensure rating value doesn't exceed max_rating."""
# Note: max_rating is not available yet during validation,
# so we use a reasonable default check
if v > 10:
raise ValueError("Rating value cannot exceed 10")
return v
class ActorInfo(BaseModel):
"""Actor/cast member information.
Attributes:
name: Actor's name
role: Character name/role
thumb: URL to actor's photo
profile: URL to actor's profile page
tmdbid: TMDB ID for the actor
"""
name: str = Field(..., description="Actor's name")
role: Optional[str] = Field(None, description="Character role")
thumb: Optional[HttpUrl] = Field(None, description="Actor photo URL")
profile: Optional[HttpUrl] = Field(None, description="Actor profile URL")
tmdbid: Optional[int] = Field(None, description="TMDB actor ID")
class ImageInfo(BaseModel):
"""Image information for posters, fanart, and logos.
Attributes:
url: URL to the image
aspect: Image aspect/type (e.g., 'poster', 'clearlogo', 'logo')
season: Season number for season-specific images
type: Image type (e.g., 'season')
"""
url: HttpUrl = Field(..., description="Image URL")
aspect: Optional[str] = Field(
None,
description="Image aspect (poster, clearlogo, logo)"
)
season: Optional[int] = Field(None, ge=-1, description="Season number")
type: Optional[str] = Field(None, description="Image type")
class NamedSeason(BaseModel):
"""Named season information.
Attributes:
number: Season number
name: Season name/title
"""
number: int = Field(..., ge=0, description="Season number")
name: str = Field(..., description="Season name")
class UniqueID(BaseModel):
"""Unique identifier from various sources.
Attributes:
type: ID source type (tmdb, imdb, tvdb)
value: The ID value
default: Whether this is the default ID
"""
type: str = Field(..., description="ID type (tmdb, imdb, tvdb)")
value: str = Field(..., description="ID value")
default: bool = Field(False, description="Is default ID")
class TVShowNFO(BaseModel):
"""Main tvshow.nfo structure following Kodi/XBMC standard.
This model represents the complete metadata for a TV show that can be
serialized to XML for use with media center applications.
Attributes:
title: Main title of the show
originaltitle: Original title (e.g., in original language)
showtitle: Show title (often same as title)
sorttitle: Title used for sorting
year: Release year
plot: Full plot description
outline: Short plot summary
tagline: Show tagline/slogan
runtime: Episode runtime in minutes
mpaa: Content rating (e.g., TV-14, TV-MA)
certification: Additional certification info
premiered: Premiere date (YYYY-MM-DD format)
status: Show status (e.g., 'Continuing', 'Ended')
studio: List of production studios
genre: List of genres
country: List of countries
tag: List of tags/keywords
ratings: List of ratings from various sources
userrating: User's personal rating
watched: Whether the show has been watched
playcount: Number of times watched
tmdbid: TMDB ID
imdbid: IMDB ID
tvdbid: TVDB ID
uniqueid: List of unique IDs
thumb: List of thumbnail/poster images
fanart: List of fanart/backdrop images
actors: List of cast members
namedseason: List of named seasons
trailer: Trailer URL
dateadded: Date when added to library
"""
# Required fields
title: str = Field(..., description="Show title", min_length=1)
# Basic information (optional)
originaltitle: Optional[str] = Field(None, description="Original title")
showtitle: Optional[str] = Field(None, description="Show title")
sorttitle: Optional[str] = Field(None, description="Sort title")
year: Optional[int] = Field(
None,
ge=1900,
le=2100,
description="Release year"
)
# Plot and description
plot: Optional[str] = Field(None, description="Full plot description")
outline: Optional[str] = Field(None, description="Short plot summary")
tagline: Optional[str] = Field(None, description="Show tagline")
# Technical details
runtime: Optional[int] = Field(
None,
ge=0,
description="Episode runtime in minutes"
)
mpaa: Optional[str] = Field(None, description="Content rating")
fsk: Optional[str] = Field(
None,
description="German FSK rating (e.g., 'FSK 12', 'FSK 16')"
)
certification: Optional[str] = Field(
None,
description="Certification info"
)
# Status and dates
premiered: Optional[str] = Field(
None,
description="Premiere date (YYYY-MM-DD)"
)
status: Optional[str] = Field(None, description="Show status")
dateadded: Optional[str] = Field(
None,
description="Date added to library"
)
# Multi-value fields
studio: List[str] = Field(
default_factory=list,
description="Production studios"
)
genre: List[str] = Field(
default_factory=list,
description="Genres"
)
country: List[str] = Field(
default_factory=list,
description="Countries"
)
tag: List[str] = Field(
default_factory=list,
description="Tags/keywords"
)
# IDs
tmdbid: Optional[int] = Field(None, description="TMDB ID")
imdbid: Optional[str] = Field(None, description="IMDB ID")
tvdbid: Optional[int] = Field(None, description="TVDB ID")
uniqueid: List[UniqueID] = Field(
default_factory=list,
description="Unique IDs"
)
# Ratings and viewing info
ratings: List[RatingInfo] = Field(
default_factory=list,
description="Ratings"
)
userrating: Optional[float] = Field(
None,
ge=0,
le=10,
description="User rating"
)
watched: bool = Field(False, description="Watched status")
playcount: Optional[int] = Field(
None,
ge=0,
description="Play count"
)
# Media
thumb: List[ImageInfo] = Field(
default_factory=list,
description="Thumbnail images"
)
fanart: List[ImageInfo] = Field(
default_factory=list,
description="Fanart images"
)
# Cast and crew
actors: List[ActorInfo] = Field(
default_factory=list,
description="Cast members"
)
# Seasons
namedseason: List[NamedSeason] = Field(
default_factory=list,
description="Named seasons"
)
# Additional
trailer: Optional[HttpUrl] = Field(None, description="Trailer URL")
@field_validator('premiered')
@classmethod
def validate_premiered_date(cls, v: Optional[str]) -> Optional[str]:
"""Validate premiered date format (YYYY-MM-DD)."""
if v is None:
return v
# Check format strictly: YYYY-MM-DD
if len(v) != 10 or v[4] != '-' or v[7] != '-':
raise ValueError(
"Premiered date must be in YYYY-MM-DD format"
)
try:
datetime.strptime(v, '%Y-%m-%d')
except ValueError as exc:
raise ValueError(
"Premiered date must be in YYYY-MM-DD format"
) from exc
return v
@field_validator('dateadded')
@classmethod
def validate_dateadded(cls, v: Optional[str]) -> Optional[str]:
"""Validate dateadded format (YYYY-MM-DD HH:MM:SS)."""
if v is None:
return v
# Check format strictly: YYYY-MM-DD HH:MM:SS
if len(v) != 19 or v[4] != '-' or v[7] != '-' or v[10] != ' ' or v[13] != ':' or v[16] != ':':
raise ValueError(
"Dateadded must be in YYYY-MM-DD HH:MM:SS format"
)
try:
datetime.strptime(v, '%Y-%m-%d %H:%M:%S')
except ValueError as exc:
raise ValueError(
"Dateadded must be in YYYY-MM-DD HH:MM:SS format"
) from exc
return v
@field_validator('imdbid')
@classmethod
def validate_imdbid(cls, v: Optional[str]) -> Optional[str]:
"""Validate IMDB ID format (should start with 'tt')."""
if v is None:
return v
if not v.startswith('tt'):
raise ValueError("IMDB ID must start with 'tt'")
if not v[2:].isdigit():
raise ValueError("IMDB ID must be 'tt' followed by digits")
return v
def model_post_init(self, __context) -> None:
"""Set default values after initialization."""
# Set showtitle to title if not provided
if self.showtitle is None:
self.showtitle = self.title
# Set originaltitle to title if not provided
if self.originaltitle is None:
self.originaltitle = self.title

View File

@@ -1,13 +1,4 @@
import json
import logging
import os
import warnings
from pathlib import Path
from typing import Optional
from src.server.utils.filesystem import sanitize_folder_name
logger = logging.getLogger(__name__)
class Serie:
@@ -28,7 +19,6 @@ class Serie:
e.g., "Attack on Titan (2013)")
episodeDict: Dictionary mapping season numbers to
lists of episode numbers
year: Release year of the series (optional)
Raises:
ValueError: If key is None or empty string
@@ -40,9 +30,7 @@ class Serie:
name: str,
site: str,
folder: str,
episodeDict: dict[int, list[int]],
year: int | None = None,
nfo_path: Optional[str] = None
episodeDict: dict[int, list[int]]
):
if not key or not key.strip():
raise ValueError("Serie key cannot be None or empty")
@@ -52,16 +40,13 @@ class Serie:
self._site = site
self._folder = folder
self._episodeDict = episodeDict
self._year = year
self._nfo_path = nfo_path
def __str__(self):
"""String representation of Serie object"""
year_str = f", year={self.year}" if self.year else ""
return (
f"Serie(key='{self.key}', name='{self.name}', "
f"site='{self.site}', folder='{self.folder}', "
f"episodeDict={self.episodeDict}{year_str})"
f"episodeDict={self.episodeDict})"
)
@property
@@ -141,188 +126,6 @@ class Serie:
def episodeDict(self, value: dict[int, list[int]]):
self._episodeDict = value
@property
def year(self) -> int | None:
"""
Release year of the series.
Returns:
int or None: The year the series was released, or None if unknown
"""
return self._year
@year.setter
def year(self, value: int | None):
"""Set the release year of the series."""
self._year = value
@property
def nfo_path(self) -> Optional[str]:
"""
Path to the tvshow.nfo metadata file.
Returns:
str or None: Path to the NFO file, or None if not set
"""
return self._nfo_path
@nfo_path.setter
def nfo_path(self, value: Optional[str]):
"""Set the path to the NFO file."""
self._nfo_path = value
def has_nfo(self, base_directory: Optional[str] = None) -> bool:
"""
Check if tvshow.nfo file exists for this series.
Args:
base_directory: Base anime directory path. If provided, checks
relative to base_directory/folder/tvshow.nfo. If not provided,
uses nfo_path directly.
Returns:
bool: True if tvshow.nfo exists, False otherwise
"""
if base_directory:
nfo_file = Path(base_directory) / self.folder / "tvshow.nfo"
elif self._nfo_path:
nfo_file = Path(self._nfo_path)
else:
return False
return nfo_file.exists() and nfo_file.is_file()
def has_poster(self, base_directory: Optional[str] = None) -> bool:
"""
Check if poster.jpg file exists for this series.
Args:
base_directory: Base anime directory path. If provided, checks
relative to base_directory/folder/poster.jpg.
Returns:
bool: True if poster.jpg exists, False otherwise
"""
if not base_directory:
return False
poster_file = Path(base_directory) / self.folder / "poster.jpg"
return poster_file.exists() and poster_file.is_file()
def has_logo(self, base_directory: Optional[str] = None) -> bool:
"""
Check if logo.png file exists for this series.
Args:
base_directory: Base anime directory path. If provided, checks
relative to base_directory/folder/logo.png.
Returns:
bool: True if logo.png exists, False otherwise
"""
if not base_directory:
return False
logo_file = Path(base_directory) / self.folder / "logo.png"
return logo_file.exists() and logo_file.is_file()
def has_fanart(self, base_directory: Optional[str] = None) -> bool:
"""
Check if fanart.jpg file exists for this series.
Args:
base_directory: Base anime directory path. If provided, checks
relative to base_directory/folder/fanart.jpg.
Returns:
bool: True if fanart.jpg exists, False otherwise
"""
if not base_directory:
return False
fanart_file = Path(base_directory) / self.folder / "fanart.jpg"
return fanart_file.exists() and fanart_file.is_file()
@property
def name_with_year(self) -> str:
"""
Get the series name with year appended if available.
Returns a name in the format "Name (Year)" if year is available,
otherwise returns just the name. This should be used for creating
filesystem folders to distinguish series with the same name.
Returns:
str: Name with year in format "Name (Year)", or just name if no year
Example:
>>> serie = Serie("dororo", "Dororo", ..., year=2025)
>>> serie.name_with_year
'Dororo (2025)'
"""
if self._year:
return f"{self._name} ({self._year})"
return self._name
@property
def sanitized_folder(self) -> str:
"""
Get a filesystem-safe folder name derived from the display name with year.
This property returns a sanitized version of the series name with year
(if available) suitable for use as a filesystem folder name. It removes/
replaces characters that are invalid for filesystems while preserving
Unicode characters.
Use this property when creating folders for the series on disk.
The `folder` property stores the actual folder name used.
Returns:
str: Filesystem-safe folder name based on display name with year
Example:
>>> serie = Serie("attack-on-titan", "Attack on Titan: Final", ..., year=2025)
>>> serie.sanitized_folder
'Attack on Titan Final (2025)'
"""
# Use name_with_year if available, fall back to folder, then key
name_to_sanitize = self.name_with_year or self._folder or self._key
try:
return sanitize_folder_name(name_to_sanitize)
except ValueError:
# Fallback to key if name cannot be sanitized
return sanitize_folder_name(self._key)
def ensure_folder_with_year(self) -> str:
"""Ensure folder name includes year if available.
If the serie has a year and the current folder name doesn't include it,
updates the folder name to include the year in format "Name (Year)".
This method should be called before creating folders or NFO files to
ensure consistent naming across the application.
Returns:
str: The folder name (updated if needed)
Example:
>>> serie = Serie("perfect-blue", "Perfect Blue", ..., folder="Perfect Blue", year=1997)
>>> serie.ensure_folder_with_year()
'Perfect Blue (1997)'
>>> serie.folder # folder property is updated
'Perfect Blue (1997)'
"""
if self._year:
# Check if folder already has year format
year_pattern = f"({self._year})"
if year_pattern not in self._folder:
# Update folder to include year
self._folder = self.sanitized_folder
logger.info(
f"Updated folder name for '{self._key}' to include year: {self._folder}"
)
return self._folder
def to_dict(self):
"""Convert Serie object to dictionary for JSON serialization."""
return {
@@ -332,9 +135,7 @@ class Serie:
"folder": self.folder,
"episodeDict": {
str(k): list(v) for k, v in self.episodeDict.items()
},
"year": self.year,
"nfo_path": self.nfo_path
}
}
@staticmethod
@@ -349,52 +150,17 @@ class Serie:
data["name"],
data["site"],
data["folder"],
episode_dict,
data.get("year"), # Optional year field for backward compatibility
data.get("nfo_path") # Optional nfo_path field
episode_dict
)
def save_to_file(self, filename: str):
"""Save Serie object to JSON file.
.. deprecated::
File-based storage is deprecated. Use database storage via
`AnimeSeriesService.create()` instead. This method will be
removed in v3.0.0.
Args:
filename: Path to save the JSON file
"""
warnings.warn(
"save_to_file() is deprecated and will be removed in v3.0.0. "
"Use database storage via AnimeSeriesService.create() instead.",
DeprecationWarning,
stacklevel=2
)
"""Save Serie object to JSON file."""
with open(filename, "w", encoding="utf-8") as file:
json.dump(self.to_dict(), file, indent=4)
@classmethod
def load_from_file(cls, filename: str) -> "Serie":
"""Load Serie object from JSON file.
.. deprecated::
File-based storage is deprecated. Use database storage via
`AnimeSeriesService.get_by_key()` instead. This method will be
removed in v3.0.0.
Args:
filename: Path to load the JSON file from
Returns:
Serie: The loaded Serie object
"""
warnings.warn(
"load_from_file() is deprecated and will be removed in v3.0.0. "
"Use database storage via AnimeSeriesService instead.",
DeprecationWarning,
stacklevel=2
)
"""Load Serie object from JSON file."""
with open(filename, "r", encoding="utf-8") as file:
data = json.load(file)
return cls.from_dict(data)

View File

@@ -1,51 +1,22 @@
import html
import json
import logging
import os
import re
import shutil
import threading
from pathlib import Path
from urllib.parse import quote
import requests
from bs4 import BeautifulSoup
from events import Events
from fake_useragent import UserAgent
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from yt_dlp import YoutubeDL
from yt_dlp.utils import DownloadCancelled
from ..interfaces.providers import Providers
from .base_provider import Loader
def _cleanup_temp_file(temp_path: str) -> None:
"""Clean up a temp file and any associated partial download files.
Removes the temp file itself and any yt-dlp partial files
(e.g. ``<name>.part``) that may have been left behind.
Args:
temp_path: Absolute or relative path to the temp file.
"""
paths_to_remove = [temp_path]
# yt-dlp writes partial fragments to <file>.part
paths_to_remove.extend(
str(p) for p in Path(temp_path).parent.glob(
Path(temp_path).name + ".*"
)
)
for path in paths_to_remove:
if os.path.exists(path):
try:
os.remove(path)
logging.debug(f"Removed temp file: {path}")
except OSError as exc:
logging.warning(f"Failed to remove temp file {path}: {exc}")
# Imported shared provider configuration
from .provider_config import (
ANIWORLD_HEADERS,
@@ -100,9 +71,6 @@ class AniworldLoader(Loader):
self.ANIWORLD_TO = "https://aniworld.to"
self.session = requests.Session()
# Cancellation flag for graceful shutdown
self._cancel_flag = threading.Event()
# Configure retries with backoff
retries = Retry(
total=5, # Number of retries
@@ -123,23 +91,6 @@ class AniworldLoader(Loader):
self._EpisodeHTMLDict = {}
self.Providers = Providers()
# Events: download_progress is triggered with progress dict
self.events = Events()
def subscribe_download_progress(self, handler):
"""Subscribe a handler to the download_progress event.
Args:
handler: Callable to be called with progress dict.
"""
self.events.download_progress += handler
def unsubscribe_download_progress(self, handler):
"""Unsubscribe a handler from the download_progress event.
Args:
handler: Callable previously subscribed.
"""
self.events.download_progress -= handler
def clear_cache(self):
"""Clear the cached HTML data."""
logging.debug("Clearing HTML cache")
@@ -254,7 +205,8 @@ class AniworldLoader(Loader):
season: int,
episode: int,
key: str,
language: str = "German Dub"
language: str = "German Dub",
progress_callback=None
) -> bool:
"""Download episode to specified directory.
@@ -267,6 +219,8 @@ class AniworldLoader(Loader):
key: Series unique identifier from provider (used for
identification and API calls)
language: Audio language preference (default: German Dub)
progress_callback: Optional callback for download progress
Returns:
bool: True if download succeeded, False otherwise
"""
@@ -312,16 +266,6 @@ class AniworldLoader(Loader):
season, episode, key, language
)
logging.debug("Direct link obtained from provider")
cancel_flag = self._cancel_flag
def events_progress_hook(d):
if cancel_flag.is_set():
logging.info("Cancellation detected in progress hook")
raise DownloadCancelled("Download cancelled by user")
# Fire the event for progress
self.events.download_progress(d)
ydl_opts = {
'fragment_retries': float('inf'),
'outtmpl': temp_path,
@@ -329,12 +273,24 @@ class AniworldLoader(Loader):
'no_warnings': True,
'progress_with_newline': False,
'nocheckcertificate': True,
'progress_hooks': [events_progress_hook],
}
if header:
ydl_opts['http_headers'] = header
logging.debug("Using custom headers for download")
if progress_callback:
# Wrap the callback to add logging
def logged_progress_callback(d):
logging.debug(
f"YT-DLP progress: status={d.get('status')}, "
f"downloaded={d.get('downloaded_bytes')}, "
f"total={d.get('total_bytes')}, "
f"speed={d.get('speed')}"
)
progress_callback(d)
ydl_opts['progress_hooks'] = [logged_progress_callback]
logging.debug("Progress callback registered with YT-DLP")
try:
logging.debug("Starting YoutubeDL download")
@@ -351,8 +307,7 @@ class AniworldLoader(Loader):
if os.path.exists(temp_path):
logging.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.copy(temp_path, output_path)
os.remove(temp_path)
logging.info(
f"Download completed successfully: {output_file}"
@@ -370,20 +325,19 @@ class AniworldLoader(Loader):
f"Broken pipe error with provider {provider}: {e}. "
f"This usually means the stream connection was closed."
)
_cleanup_temp_file(temp_path)
# Try next provider if available
continue
except Exception as e:
logging.error(
f"YoutubeDL download failed with provider {provider}: "
f"{type(e).__name__}: {e}"
)
_cleanup_temp_file(temp_path)
# Try next provider if available
continue
break
# If we get here, all providers failed
logging.error("All download providers failed")
_cleanup_temp_file(temp_path)
self.clear_cache()
return False
@@ -401,64 +355,13 @@ class AniworldLoader(Loader):
title_div = soup.find('div', class_='series-title')
if title_div:
h1_tag = title_div.find('h1')
span_tag = h1_tag.find('span') if h1_tag else None
if span_tag:
title = span_tag.text
logging.debug(f"Found title: {title}")
return title
title = title_div.find('h1').find('span').text
logging.debug(f"Found title: {title}")
return title
logging.warning(f"No title found for key: {key}")
return ""
def get_year(self, key: str) -> int | None:
"""Get anime release year from series key.
Attempts to extract the year from the series page metadata.
Returns None if year cannot be determined.
Args:
key: Series identifier
Returns:
int or None: Release year if found, None otherwise
"""
logging.debug(f"Getting year for key: {key}")
try:
soup = BeautifulSoup(
self._get_key_html(key).content,
'html.parser'
)
# Try to find year in metadata
# Check for "Jahr:" or similar metadata fields
for p_tag in soup.find_all('p'):
text = p_tag.get_text()
if 'Jahr:' in text or 'Year:' in text:
# Extract year from text like "Jahr: 2025"
match = re.search(r'(\d{4})', text)
if match:
year = int(match.group(1))
logging.debug(f"Found year in metadata: {year}")
return year
# Try alternative: look for year in genre/info section
info_div = soup.find('div', class_='series-info')
if info_div:
text = info_div.get_text()
match = re.search(r'\b(19\d{2}|20\d{2})\b', text)
if match:
year = int(match.group(1))
logging.debug(f"Found year in info section: {year}")
return year
logging.debug(f"No year found for key: {key}")
return None
except Exception as e:
logging.warning(f"Error extracting year for key {key}: {e}")
return None
def _get_key_html(self, key: str):
"""Get cached HTML for series key.
@@ -562,7 +465,7 @@ class AniworldLoader(Loader):
redirect_link_tag = link.find('a', class_='watchEpisode')
redirect_link = (
redirect_link_tag.get('href')
redirect_link_tag['href']
if redirect_link_tag else None
)

View File

@@ -1,21 +1,9 @@
from abc import ABC, abstractmethod
from typing import Any, Dict, List
from typing import Any, Callable, Dict, List, Optional
class Loader(ABC):
"""Abstract base class for anime data loaders/providers."""
@abstractmethod
def subscribe_download_progress(self, handler):
"""Subscribe a handler to the download_progress event.
Args:
handler: Callable to be called with progress dict.
"""
@abstractmethod
def unsubscribe_download_progress(self, handler):
"""Unsubscribe a handler from the download_progress event.
Args:
handler: Callable previously subscribed.
"""
@abstractmethod
def search(self, word: str) -> List[Dict[str, Any]]:
@@ -56,7 +44,8 @@ class Loader(ABC):
season: int,
episode: int,
key: str,
language: str = "German Dub"
language: str = "German Dub",
progress_callback: Optional[Callable[[str, Dict], None]] = None,
) -> bool:
"""Download episode to specified directory.
@@ -67,6 +56,8 @@ class Loader(ABC):
episode: Episode number within season
key: Unique series identifier/key
language: Language version to download (default: German Dub)
progress_callback: Optional callback for progress updates
called with (event_type: str, data: Dict)
Returns:
True if download successful, False otherwise

View File

@@ -43,33 +43,6 @@ from .provider_config import (
)
def _cleanup_temp_file(
temp_path: str,
logger: Optional[logging.Logger] = None,
) -> None:
"""Remove a temp file and any associated yt-dlp partial files.
Args:
temp_path: Path to the primary temp file.
logger: Optional logger for diagnostic messages.
"""
_log = logger or logging.getLogger(__name__)
candidates = [temp_path]
# yt-dlp creates fragment files like <file>.part
candidates.extend(
str(p) for p in Path(temp_path).parent.glob(
Path(temp_path).name + ".*"
)
)
for path in candidates:
if os.path.exists(path):
try:
os.remove(path)
_log.debug(f"Removed temp file: {path}")
except OSError as exc:
_log.warning(f"Failed to remove temp file {path}: {exc}")
class EnhancedAniWorldLoader(Loader):
"""Aniworld provider with retry and recovery strategies.
@@ -582,8 +555,7 @@ class EnhancedAniWorldLoader(Loader):
# Verify downloaded file
if file_corruption_detector.is_valid_video_file(temp_path):
# Move to final location
# Use copyfile instead of copy2 to avoid metadata permission issues
shutil.copyfile(temp_path, output_path)
shutil.copy2(temp_path, output_path)
# Calculate and store checksum for integrity
integrity_mgr = get_integrity_manager()
@@ -623,13 +595,9 @@ class EnhancedAniWorldLoader(Loader):
except Exception as e:
self.logger.warning(f"Provider {provider_name} failed: {e}")
# Clean up any partial temp files left by this failed attempt
_cleanup_temp_file(temp_path, self.logger)
self.download_stats['retried_downloads'] += 1
continue
# All providers failed make sure no temp remnants are left behind
_cleanup_temp_file(temp_path, self.logger)
return False
def _perform_ytdl_download(

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,88 @@
"""Resolve Doodstream embed players into direct download URLs."""
import random
import re
import string
import time
from typing import Any
import requests
from fake_useragent import UserAgent
from .Provider import Provider
# Precompiled regex patterns to extract the ``pass_md5`` endpoint and the
# session token embedded in the obfuscated player script. Compiling once keeps
# repeated invocations fast and documents the parsing intent.
PASS_MD5_PATTERN = re.compile(r"\$\.get\('([^']*/pass_md5/[^']*)'")
TOKEN_PATTERN = re.compile(r"token=([a-zA-Z0-9]+)")
class Doodstream(Provider):
"""Doodstream video provider implementation."""
def __init__(self):
self.RANDOM_USER_AGENT = UserAgent().random
def get_link(
self, embedded_link: str, timeout: int
) -> tuple[str, dict[str, Any]]:
"""
Extract direct download link from Doodstream embedded player.
Args:
embedded_link: URL of the embedded Doodstream player
timeout: Request timeout in seconds
Returns:
Tuple of (direct_link, headers)
"""
headers = {
"User-Agent": self.RANDOM_USER_AGENT,
"Referer": "https://dood.li/",
}
def extract_data(pattern: re.Pattern[str], content: str) -> str | None:
"""Extract data using a compiled regex pattern."""
match = pattern.search(content)
return match.group(1) if match else None
def generate_random_string(length: int = 10) -> str:
"""Generate random alphanumeric string."""
charset = string.ascii_letters + string.digits
return "".join(random.choices(charset, k=length))
# WARNING: SSL verification disabled for doodstream compatibility
# This is a known limitation with this streaming provider
response = requests.get(
embedded_link,
headers=headers,
timeout=timeout,
verify=True, # Changed from False for security
)
response.raise_for_status()
pass_md5_url = extract_data(PASS_MD5_PATTERN, response.text)
if not pass_md5_url:
raise ValueError(f"pass_md5 URL not found using {embedded_link}.")
full_md5_url = f"https://dood.li{pass_md5_url}"
token = extract_data(TOKEN_PATTERN, response.text)
if not token:
raise ValueError(f"Token not found using {embedded_link}.")
md5_response = requests.get(
full_md5_url, headers=headers, timeout=timeout, verify=True
)
md5_response.raise_for_status()
video_base_url = md5_response.text.strip()
random_string = generate_random_string(10)
expiry = int(time.time())
direct_link = (
f"{video_base_url}{random_string}?token={token}&expiry={expiry}"
)
return direct_link, headers

View File

@@ -0,0 +1,59 @@
"""Resolve Filemoon embed pages into direct streaming asset URLs."""
import re
import requests
from aniworld import config
# import jsbeautifier.unpackers.packer as packer
# Match the embedded ``iframe`` pointing to the actual Filemoon player.
REDIRECT_REGEX = re.compile(
r'<iframe *(?:[^>]+ )?src=(?:\'([^\']+)\'|"([^"]+)")[^>]*>')
# The player HTML hides an ``eval`` wrapped script with ``data-cfasync``
# disabled; capture the entire script body for unpacking.
SCRIPT_REGEX = re.compile(
r'(?s)<script\s+[^>]*?data-cfasync=["\']?false["\']?[^>]*>(.+?)</script>')
# Extract the direct ``file:"<m3u8>"`` URL once the script is unpacked.
VIDEO_URL_REGEX = re.compile(r'file:\s*"([^"]+\.m3u8[^"]*)"')
# TODO Implement this script fully
def get_direct_link_from_filemoon(embeded_filemoon_link: str):
session = requests.Session()
session.verify = False
headers = {
"User-Agent": config.RANDOM_USER_AGENT,
"Referer": embeded_filemoon_link,
}
response = session.get(embeded_filemoon_link, headers=headers)
source = response.text
match = REDIRECT_REGEX.search(source)
if match:
redirect_url = match.group(1) or match.group(2)
response = session.get(redirect_url, headers=headers)
source = response.text
for script_match in SCRIPT_REGEX.finditer(source):
script_content = script_match.group(1).strip()
if not script_content.startswith("eval("):
continue
if packer.detect(script_content):
unpacked = packer.unpack(script_content)
video_match = VIDEO_URL_REGEX.search(unpacked)
if video_match:
return video_match.group(1)
raise Exception("No Video link found!")
if __name__ == '__main__':
url = input("Enter Filemoon Link: ")
print(get_direct_link_from_filemoon(url))

View File

@@ -0,0 +1,95 @@
"""Helpers for extracting direct stream URLs from hanime.tv pages."""
import json
import re
import sys
import requests
from aniworld.config import DEFAULT_REQUEST_TIMEOUT
def fetch_page_content(url):
try:
response = requests.get(url, timeout=DEFAULT_REQUEST_TIMEOUT)
response.raise_for_status()
return response.text
except requests.exceptions.RequestException as e:
print(f"Failed to fetch the page content: {e}")
return None
def extract_video_data(page_content):
# ``videos_manifest`` lines embed a JSON blob with the stream metadata
# inside a larger script tag; grab that entire line for further parsing.
match = re.search(r'^.*videos_manifest.*$', page_content, re.MULTILINE)
if not match:
raise ValueError("Failed to extract video manifest from the response.")
json_str = match.group(0)[match.group(0).find(
'{'):match.group(0).rfind('}') + 1]
return json.loads(json_str)
def get_streams(url):
page_content = fetch_page_content(url)
data = extract_video_data(page_content)
video_info = data['state']['data']['video']
name = video_info['hentai_video']['name']
streams = video_info['videos_manifest']['servers'][0]['streams']
return {"name": name, "streams": streams}
def display_streams(streams):
if not streams:
print("No streams available.")
return
print("Available qualities:")
for i, stream in enumerate(streams, 1):
premium_tag = "(Premium)" if not stream['is_guest_allowed'] else ""
print(
f"{i}. {stream['width']}x{stream['height']}\t"
f"({stream['filesize_mbs']}MB) {premium_tag}")
def get_user_selection(streams):
try:
selected_index = int(input("Select a stream: ").strip()) - 1
if 0 <= selected_index < len(streams):
return selected_index
print("Invalid selection.")
return None
except ValueError:
print("Invalid input.")
return None
def get_direct_link_from_hanime(url=None):
try:
if url is None:
if len(sys.argv) > 1:
url = sys.argv[1]
else:
url = input("Please enter the hanime.tv video URL: ").strip()
try:
video_data = get_streams(url)
print(f"Video: {video_data['name']}")
print('*' * 40)
display_streams(video_data['streams'])
selected_index = None
while selected_index is None:
selected_index = get_user_selection(video_data['streams'])
print(f"M3U8 URL: {video_data['streams'][selected_index]['url']}")
except ValueError as e:
print(f"Error: {e}")
except KeyboardInterrupt:
print("\nOperation cancelled by user.")
if __name__ == "__main__":
get_direct_link_from_hanime()

View File

@@ -0,0 +1,59 @@
import json
from urllib.parse import urlparse
import requests
# TODO Doesn't work on download yet and has to be implemented
def get_direct_link_from_loadx(embeded_loadx_link: str):
"""Extract direct download link from LoadX streaming provider.
Args:
embeded_loadx_link: Embedded LoadX link
Returns:
str: Direct video URL
Raises:
ValueError: If link extraction fails
"""
# Default timeout for network requests
timeout = 30
response = requests.head(
embeded_loadx_link,
allow_redirects=True,
verify=True,
timeout=timeout
)
parsed_url = urlparse(response.url)
path_parts = parsed_url.path.split("/")
if len(path_parts) < 3:
raise ValueError("Invalid path!")
id_hash = path_parts[2]
host = parsed_url.netloc
post_url = f"https://{host}/player/index.php?data={id_hash}&do=getVideo"
headers = {"X-Requested-With": "XMLHttpRequest"}
response = requests.post(
post_url,
headers=headers,
verify=True,
timeout=timeout
)
data = json.loads(response.text)
print(data)
video_url = data.get("videoSource")
if not video_url:
raise ValueError("No Video link found!")
return video_url
if __name__ == '__main__':
url = input("Enter Loadx Link: ")
print(get_direct_link_from_loadx(url))

View File

@@ -0,0 +1,40 @@
import re
import requests
from aniworld import config
def get_direct_link_from_luluvdo(embeded_luluvdo_link, arguments=None):
luluvdo_id = embeded_luluvdo_link.split('/')[-1]
filelink = (
f"https://luluvdo.com/dl?op=embed&file_code={luluvdo_id}&embed=1&referer=luluvdo.com&adb=0"
)
# The User-Agent needs to be the same as the direct-link ones to work
headers = {
"Origin": "https://luluvdo.com",
"Referer": "https://luluvdo.com/",
"User-Agent": config.LULUVDO_USER_AGENT
}
if arguments.action == "Download":
headers["Accept-Language"] = "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7"
response = requests.get(filelink, headers=headers,
timeout=config.DEFAULT_REQUEST_TIMEOUT)
if response.status_code == 200:
# Capture the ``file:"<url>"`` assignment embedded in the player
# configuration so we can return the stream URL.
pattern = r'file:\s*"([^"]+)"'
matches = re.findall(pattern, str(response.text))
if matches:
return matches[0]
raise ValueError("No match found")
if __name__ == '__main__':
url = input("Enter Luluvdo Link: ")
print(get_direct_link_from_luluvdo(url))

View File

@@ -0,0 +1,45 @@
import base64
import re
import requests
from aniworld.config import DEFAULT_REQUEST_TIMEOUT, RANDOM_USER_AGENT
# Capture the base64 payload hidden inside the obfuscated ``_0x5opu234``
# assignment. The named group lets us pull out the encoded blob directly.
SPEEDFILES_PATTERN = re.compile(r'var _0x5opu234 = "(?P<encoded_data>.*?)";')
def get_direct_link_from_speedfiles(embeded_speedfiles_link):
response = requests.get(
embeded_speedfiles_link,
timeout=DEFAULT_REQUEST_TIMEOUT,
headers={'User-Agent': RANDOM_USER_AGENT}
)
if "<span class=\"inline-block\">Web server is down</span>" in response.text:
raise ValueError(
"The SpeedFiles server is currently down.\n"
"Please try again later or choose a different hoster."
)
match = SPEEDFILES_PATTERN.search(response.text)
if not match:
raise ValueError("Pattern not found in the response.")
encoded_data = match.group("encoded_data")
decoded = base64.b64decode(encoded_data).decode()
decoded = decoded.swapcase()[::-1]
decoded = base64.b64decode(decoded).decode()[::-1]
decoded_hex = ''.join(chr(int(decoded[i:i + 2], 16))
for i in range(0, len(decoded), 2))
shifted = ''.join(chr(ord(char) - 3) for char in decoded_hex)
result = base64.b64decode(shifted.swapcase()[::-1]).decode()
return result
if __name__ == '__main__':
speedfiles_link = input("Enter Speedfiles Link: ")
print(get_direct_link_from_speedfiles(
embeded_speedfiles_link=speedfiles_link))

View File

@@ -0,0 +1,2 @@
def get_direct_link_from_streamtape(embeded_streamtape_link: str) -> str:
pass

View File

@@ -0,0 +1,35 @@
import re
import requests
from aniworld.config import DEFAULT_REQUEST_TIMEOUT, RANDOM_USER_AGENT
from bs4 import BeautifulSoup
def get_direct_link_from_vidmoly(embeded_vidmoly_link: str):
response = requests.get(
embeded_vidmoly_link,
headers={'User-Agent': RANDOM_USER_AGENT},
timeout=DEFAULT_REQUEST_TIMEOUT
)
html_content = response.text
soup = BeautifulSoup(html_content, 'html.parser')
scripts = soup.find_all('script')
# Match the ``file:"<url>"`` assignment inside the obfuscated player
# script so we can recover the direct MP4 source URL.
file_link_pattern = r'file:\s*"(https?://.*?)"'
for script in scripts:
if script.string:
match = re.search(file_link_pattern, script.string)
if match:
file_link = match.group(1)
return file_link
raise ValueError("No direct link found.")
if __name__ == '__main__':
link = input("Enter Vidmoly Link: ")
print('Note: --referer "https://vidmoly.to"')
print(get_direct_link_from_vidmoly(embeded_vidmoly_link=link))

View File

@@ -0,0 +1,30 @@
import re
import requests
from aniworld.config import DEFAULT_REQUEST_TIMEOUT, RANDOM_USER_AGENT
from bs4 import BeautifulSoup
def get_direct_link_from_vidoza(embeded_vidoza_link: str) -> str:
response = requests.get(
embeded_vidoza_link,
headers={'User-Agent': RANDOM_USER_AGENT},
timeout=DEFAULT_REQUEST_TIMEOUT
)
soup = BeautifulSoup(response.content, "html.parser")
for tag in soup.find_all('script'):
if 'sourcesCode:' in tag.text:
# Script blocks contain a ``sourcesCode`` object with ``src``
# assignments; extract the first URL between the quotes.
match = re.search(r'src: "(.*?)"', tag.text)
if match:
return match.group(1)
raise ValueError("No direct link found.")
if __name__ == '__main__':
link = input("Enter Vidoza Link: ")
print(get_direct_link_from_vidoza(embeded_vidoza_link=link))

Binary file not shown.

Binary file not shown.

View File

@@ -1,237 +0,0 @@
"""NFO Service Factory Module.
This module provides a centralized factory for creating NFOService instances
with consistent configuration and initialization logic.
The factory supports both direct instantiation and FastAPI dependency injection,
while remaining testable through optional dependency overrides.
"""
import logging
from typing import Optional
from src.config.settings import settings
from src.core.services.nfo_service import NFOService
logger = logging.getLogger(__name__)
class NFOServiceFactory:
"""Factory for creating NFOService instances with consistent configuration.
This factory centralizes NFO service initialization logic that was previously
duplicated across multiple modules (SeriesApp, SeriesManagerService, API endpoints).
The factory follows these precedence rules for configuration:
1. Explicit parameters (highest priority)
2. Environment variables via settings
3. config.json via ConfigService (fallback)
4. Raise error if TMDB API key unavailable
Example:
>>> factory = NFOServiceFactory()
>>> nfo_service = factory.create()
>>> # Or with custom settings:
>>> nfo_service = factory.create(tmdb_api_key="custom_key")
"""
def __init__(self):
"""Initialize the NFO service factory."""
self._config_service = None
def create(
self,
tmdb_api_key: Optional[str] = None,
anime_directory: Optional[str] = None,
image_size: Optional[str] = None,
auto_create: Optional[bool] = None
) -> NFOService:
"""Create an NFOService instance with proper configuration.
This method implements the configuration precedence:
1. Use explicit parameters if provided
2. Fall back to settings (from ENV vars)
3. Fall back to config.json (only if ENV not set)
4. Raise ValueError if TMDB API key still unavailable
Args:
tmdb_api_key: TMDB API key (optional, falls back to settings/config)
anime_directory: Anime directory path (optional, defaults to settings)
image_size: Image size for downloads (optional, defaults to settings)
auto_create: Whether to auto-create NFO files (optional, defaults to settings)
Returns:
NFOService: Configured NFO service instance
Raises:
ValueError: If TMDB API key cannot be determined from any source
Example:
>>> factory = NFOServiceFactory()
>>> # Use all defaults from settings
>>> service = factory.create()
>>> # Override specific settings
>>> service = factory.create(auto_create=False)
"""
# Step 1: Determine TMDB API key with fallback logic
api_key = tmdb_api_key or settings.tmdb_api_key
# Step 2: If no API key in settings, try config.json as fallback
if not api_key:
api_key = self._get_api_key_from_config()
# Step 3: Validate API key is available
if not api_key:
raise ValueError(
"TMDB API key not configured. Set TMDB_API_KEY environment "
"variable or configure in config.json (nfo.tmdb_api_key)."
)
# Step 4: Use provided values or fall back to settings
directory = anime_directory or settings.anime_directory
size = image_size or settings.nfo_image_size
auto = auto_create if auto_create is not None else settings.nfo_auto_create
# Step 5: Create and return the service
logger.debug(
"Creating NFOService: directory=%s, size=%s, auto_create=%s",
directory, size, auto
)
return NFOService(
tmdb_api_key=api_key,
anime_directory=directory,
image_size=size,
auto_create=auto
)
def create_optional(
self,
tmdb_api_key: Optional[str] = None,
anime_directory: Optional[str] = None,
image_size: Optional[str] = None,
auto_create: Optional[bool] = None
) -> Optional[NFOService]:
"""Create an NFOService instance, returning None if configuration unavailable.
This is a convenience method for cases where NFO service is optional.
Unlike create(), this returns None instead of raising ValueError when
the TMDB API key is not configured.
Args:
tmdb_api_key: TMDB API key (optional)
anime_directory: Anime directory path (optional)
image_size: Image size for downloads (optional)
auto_create: Whether to auto-create NFO files (optional)
Returns:
Optional[NFOService]: Configured service or None if key unavailable
Example:
>>> factory = NFOServiceFactory()
>>> service = factory.create_optional()
>>> if service:
... service.create_tvshow_nfo(...)
"""
try:
return self.create(
tmdb_api_key=tmdb_api_key,
anime_directory=anime_directory,
image_size=image_size,
auto_create=auto_create
)
except ValueError as e:
logger.debug("NFO service not available: %s", e)
return None
def _get_api_key_from_config(self) -> Optional[str]:
"""Get TMDB API key from config.json as fallback.
This method is only called when the API key is not in settings
(i.e., not set via environment variable). It provides backward
compatibility with config.json configuration.
Returns:
Optional[str]: API key from config.json, or None if unavailable
"""
try:
# Lazy import to avoid circular dependencies
from src.server.services.config_service import get_config_service
if self._config_service is None:
self._config_service = get_config_service()
config = self._config_service.load_config()
if config.nfo and config.nfo.tmdb_api_key:
logger.debug("Using TMDB API key from config.json")
return config.nfo.tmdb_api_key
except Exception as e: # pylint: disable=broad-except
logger.debug("Could not load API key from config.json: %s", e)
return None
# Global factory instance for convenience
_factory_instance: Optional[NFOServiceFactory] = None
def get_nfo_factory() -> NFOServiceFactory:
"""Get the global NFO service factory instance.
This function provides a singleton factory instance for the application.
The singleton pattern here is for the factory itself (which is stateless),
not for the NFO service instances it creates.
Returns:
NFOServiceFactory: The global factory instance
Example:
>>> factory = get_nfo_factory()
>>> service = factory.create()
"""
global _factory_instance
if _factory_instance is None:
_factory_instance = NFOServiceFactory()
return _factory_instance
def create_nfo_service(
tmdb_api_key: Optional[str] = None,
anime_directory: Optional[str] = None,
image_size: Optional[str] = None,
auto_create: Optional[bool] = None
) -> NFOService:
"""Convenience function to create an NFOService instance.
This is a shorthand for get_nfo_factory().create() that can be used
when you need a quick NFO service instance without interacting with
the factory directly.
Args:
tmdb_api_key: TMDB API key (optional)
anime_directory: Anime directory path (optional)
image_size: Image size for downloads (optional)
auto_create: Whether to auto-create NFO files (optional)
Returns:
NFOService: Configured NFO service instance
Raises:
ValueError: If TMDB API key cannot be determined
Example:
>>> service = create_nfo_service()
>>> # Or with custom settings:
>>> service = create_nfo_service(auto_create=False)
"""
factory = get_nfo_factory()
return factory.create(
tmdb_api_key=tmdb_api_key,
anime_directory=anime_directory,
image_size=image_size,
auto_create=auto_create
)

View File

@@ -1,180 +0,0 @@
"""NFO repair service for detecting and fixing incomplete tvshow.nfo files.
This module provides utilities to check whether an existing ``tvshow.nfo``
contains all required tags and to trigger a repair (re-fetch from TMDB) when
needed.
Example:
>>> service = NfoRepairService(nfo_service)
>>> repaired = await service.repair_series(Path("/anime/Attack on Titan"), "Attack on Titan")
"""
import logging
from pathlib import Path
from typing import Dict, List
from lxml import etree
from src.core.services.nfo_service import NFOService
logger = logging.getLogger(__name__)
# XPath relative to <tvshow> root → human-readable label
REQUIRED_TAGS: Dict[str, str] = {
"./title": "title",
"./originaltitle": "originaltitle",
"./year": "year",
"./plot": "plot",
"./runtime": "runtime",
"./premiered": "premiered",
"./status": "status",
"./imdbid": "imdbid",
"./genre": "genre",
"./studio": "studio",
"./country": "country",
"./actor/name": "actor/name",
"./watched": "watched",
}
def parse_nfo_tags(nfo_path: Path) -> Dict[str, List[str]]:
"""Parse an existing tvshow.nfo and return present tag values.
Evaluates every XPath in :data:`REQUIRED_TAGS` against the document root
and collects all non-empty text values.
Args:
nfo_path: Absolute path to the ``tvshow.nfo`` file.
Returns:
Mapping of XPath expression → list of non-empty text strings found in
the document. Returns an empty dict on any error (missing file,
invalid XML, permission error).
Example:
>>> tags = parse_nfo_tags(Path("/anime/Attack on Titan/tvshow.nfo"))
>>> tags.get("./title")
['Attack on Titan']
"""
if not nfo_path.exists():
logger.debug("NFO file not found: %s", nfo_path)
return {}
try:
tree = etree.parse(str(nfo_path))
root = tree.getroot()
result: Dict[str, List[str]] = {}
for xpath in REQUIRED_TAGS:
elements = root.findall(xpath)
result[xpath] = [e.text for e in elements if e.text]
return result
except etree.XMLSyntaxError as exc:
logger.warning("Malformed XML in %s: %s", nfo_path, exc)
return {}
except Exception as exc: # pylint: disable=broad-except
logger.warning("Unexpected error parsing %s: %s", nfo_path, exc)
return {}
def find_missing_tags(nfo_path: Path) -> List[str]:
"""Return tags that are absent or empty in the NFO.
Args:
nfo_path: Absolute path to the ``tvshow.nfo`` file.
Returns:
List of human-readable tag labels (values from :data:`REQUIRED_TAGS`)
whose XPath matched no elements or only elements with empty text.
An empty list means the NFO is complete.
Example:
>>> missing = find_missing_tags(Path("/anime/series/tvshow.nfo"))
>>> if missing:
... print("Missing:", missing)
"""
parsed = parse_nfo_tags(nfo_path)
missing: List[str] = []
for xpath, label in REQUIRED_TAGS.items():
if not parsed.get(xpath):
missing.append(label)
return missing
def nfo_needs_repair(nfo_path: Path) -> bool:
"""Return ``True`` if the NFO is missing any required tag.
Args:
nfo_path: Absolute path to the ``tvshow.nfo`` file.
Returns:
True if :func:`find_missing_tags` returns a non-empty list.
Example:
>>> if nfo_needs_repair(Path("/anime/series/tvshow.nfo")):
... await service.repair_series(series_path, series_name)
"""
return bool(find_missing_tags(nfo_path))
class NfoRepairService:
"""Service that detects and repairs incomplete tvshow.nfo files.
Wraps the module-level helpers with structured logging and delegates
the actual TMDB re-fetch to an injected :class:`NFOService` instance.
Attributes:
_nfo_service: The underlying NFOService used to update NFOs.
"""
def __init__(self, nfo_service: NFOService) -> None:
"""Initialise the repair service.
Args:
nfo_service: Configured :class:`NFOService` instance.
"""
self._nfo_service = nfo_service
async def repair_series(self, series_path: Path, series_name: str) -> bool:
"""Repair an NFO file if required tags are missing.
Checks ``{series_path}/tvshow.nfo`` for completeness. If tags are
missing, logs them and calls
``NFOService.update_tvshow_nfo(series_name)`` to re-fetch metadata
from TMDB.
Args:
series_path: Absolute path to the series folder.
series_name: Series folder name used as the identifier for
:meth:`NFOService.update_tvshow_nfo`.
Returns:
``True`` if a repair was triggered, ``False`` if the NFO was
already complete (or did not exist).
"""
nfo_path = series_path / "tvshow.nfo"
missing = find_missing_tags(nfo_path)
if not missing:
logger.info(
"NFO repair skipped — complete: %s",
series_name,
)
return False
logger.info(
"NFO repair triggered for %s — missing tags: %s",
series_name,
", ".join(missing),
)
await self._nfo_service.update_tvshow_nfo(
series_name,
download_media=False,
)
logger.info("NFO repair completed: %s", series_name)
return True

View File

@@ -1,555 +0,0 @@
"""NFO service for creating and managing tvshow.nfo files.
This service orchestrates TMDB API calls, XML generation, and media downloads
to create complete NFO metadata for TV series.
Example:
>>> nfo_service = NFOService(tmdb_api_key="key", anime_directory="/anime")
>>> await nfo_service.create_tvshow_nfo("Attack on Titan", "/anime/aot", 2013)
"""
import logging
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from lxml import etree
from src.core.services.tmdb_client import TMDBAPIError, TMDBClient
from src.core.utils.image_downloader import ImageDownloader
from src.core.utils.nfo_generator import generate_tvshow_nfo
from src.core.utils.nfo_mapper import tmdb_to_nfo_model
logger = logging.getLogger(__name__)
class NFOService:
"""Service for creating and managing tvshow.nfo files.
Attributes:
tmdb_client: TMDB API client
image_downloader: Image downloader utility
anime_directory: Base directory for anime series
"""
def __init__(
self,
tmdb_api_key: str,
anime_directory: str,
image_size: str = "original",
auto_create: bool = True
):
"""Initialize NFO service.
Args:
tmdb_api_key: TMDB API key
anime_directory: Base anime directory path
image_size: Image size to download (original, w500, etc.)
auto_create: Whether to auto-create NFOs
"""
self.tmdb_client = TMDBClient(api_key=tmdb_api_key)
self.image_downloader = ImageDownloader()
self.anime_directory = Path(anime_directory)
self.image_size = image_size
self.auto_create = auto_create
def has_nfo(self, serie_folder: str) -> bool:
"""Check if tvshow.nfo exists for a series.
Args:
serie_folder: Series folder name
Returns:
True if NFO file exists
"""
nfo_path = self.anime_directory / serie_folder / "tvshow.nfo"
return nfo_path.exists()
@staticmethod
def _extract_year_from_name(serie_name: str) -> Tuple[str, Optional[int]]:
"""Extract year from series name if present in format 'Name (YYYY)'.
Args:
serie_name: Series name, possibly with year in parentheses
Returns:
Tuple of (clean_name, year)
- clean_name: Series name without year
- year: Extracted year or None
Examples:
>>> _extract_year_from_name("Attack on Titan (2013)")
("Attack on Titan", 2013)
>>> _extract_year_from_name("Attack on Titan")
("Attack on Titan", None)
"""
# Match year in parentheses at the end: (YYYY)
match = re.search(r'\((\d{4})\)\s*$', serie_name)
if match:
year = int(match.group(1))
clean_name = serie_name[:match.start()].strip()
return clean_name, year
return serie_name, None
async def check_nfo_exists(self, serie_folder: str) -> bool:
"""Check if tvshow.nfo exists for a series.
Args:
serie_folder: Series folder name
Returns:
True if tvshow.nfo exists
"""
nfo_path = self.anime_directory / serie_folder / "tvshow.nfo"
return nfo_path.exists()
async def create_tvshow_nfo(
self,
serie_name: str,
serie_folder: str,
year: Optional[int] = None,
download_poster: bool = True,
download_logo: bool = True,
download_fanart: bool = True
) -> Path:
"""Create tvshow.nfo by scraping TMDB.
Args:
serie_name: Name of the series to search (may include year in parentheses)
serie_folder: Series folder name
year: Release year (helps narrow search). If None and name contains year,
year will be auto-extracted
download_poster: Whether to download poster.jpg
download_logo: Whether to download logo.png
download_fanart: Whether to download fanart.jpg
Returns:
Path to created NFO file
Raises:
TMDBAPIError: If TMDB API fails
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
logger.info(f"Extracted year {year} from series name")
# Use clean name for search
search_name = clean_name
logger.info(f"Creating NFO for {search_name} (year: {year})")
folder_path = self.anime_directory / serie_folder
if not folder_path.exists():
logger.info(f"Creating series folder: {folder_path}")
folder_path.mkdir(parents=True, exist_ok=True)
async with self.tmdb_client:
# Search for TV show with clean name (without year)
logger.debug(f"Searching TMDB for: {search_name}")
search_results = await self.tmdb_client.search_tv_show(search_name)
if not search_results.get("results"):
raise TMDBAPIError(f"No results found for: {search_name}")
# Find best match (consider year if provided)
tv_show = self._find_best_match(search_results["results"], search_name, year)
tv_id = tv_show["id"]
logger.info(f"Found match: {tv_show['name']} (ID: {tv_id})")
# Get detailed information with multi-language image support
details = await self.tmdb_client.get_tv_show_details(
tv_id,
append_to_response="credits,external_ids,images"
)
# Get content ratings for FSK
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tv_id)
# Enrich with fallback languages for empty overview/tagline
# Pass search result overview as last resort fallback
search_overview = tv_show.get("overview") or None
details = await self._enrich_details_with_fallback(
details, search_overview=search_overview
)
# Convert TMDB data to TVShowNFO model
nfo_model = tmdb_to_nfo_model(
details,
content_ratings,
self.tmdb_client.get_image_url,
self.image_size,
)
# 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(f"Created NFO: {nfo_path}")
# Download media files
await self._download_media_files(
details,
folder_path,
download_poster=download_poster,
download_logo=download_logo,
download_fanart=download_fanart
)
return nfo_path
async def update_tvshow_nfo(
self,
serie_folder: str,
download_media: bool = True
) -> Path:
"""Update existing tvshow.nfo with fresh data from TMDB.
Args:
serie_folder: Series folder name
download_media: Whether to re-download media files
Returns:
Path to updated NFO file
Raises:
FileNotFoundError: If NFO file doesn't exist
TMDBAPIError: If TMDB API fails or no TMDB ID found in NFO
"""
folder_path = self.anime_directory / serie_folder
nfo_path = folder_path / "tvshow.nfo"
if not nfo_path.exists():
raise FileNotFoundError(f"NFO file not found: {nfo_path}")
logger.info(f"Updating NFO for {serie_folder}")
# Parse existing NFO to extract TMDB ID
try:
tree = etree.parse(str(nfo_path))
root = tree.getroot()
# Try to find TMDB ID from uniqueid elements
tmdb_id = None
for uniqueid in root.findall(".//uniqueid"):
if uniqueid.get("type") == "tmdb":
tmdb_id = int(uniqueid.text)
break
# Fallback: check for tmdbid element
if tmdb_id is None:
tmdbid_elem = root.find(".//tmdbid")
if tmdbid_elem is not None and tmdbid_elem.text:
tmdb_id = int(tmdbid_elem.text)
if tmdb_id is None:
raise TMDBAPIError(
f"No TMDB ID found in existing NFO. "
f"Delete the NFO and create a new one instead."
)
logger.debug(f"Found TMDB ID: {tmdb_id}")
except etree.XMLSyntaxError as e:
raise TMDBAPIError(f"Invalid XML in NFO file: {e}")
except ValueError as e:
raise TMDBAPIError(f"Invalid TMDB ID format in NFO: {e}")
# Fetch fresh data from TMDB
async with self.tmdb_client:
logger.debug(f"Fetching fresh data for TMDB ID: {tmdb_id}")
details = await self.tmdb_client.get_tv_show_details(
tmdb_id,
append_to_response="credits,external_ids,images"
)
# Get content ratings for FSK
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tmdb_id)
# Enrich with fallback languages for empty overview/tagline
details = await self._enrich_details_with_fallback(details)
# Convert TMDB data to TVShowNFO model
nfo_model = tmdb_to_nfo_model(
details,
content_ratings,
self.tmdb_client.get_image_url,
self.image_size,
)
# Generate XML
nfo_xml = generate_tvshow_nfo(nfo_model)
# Save updated NFO file
nfo_path.write_text(nfo_xml, encoding="utf-8")
logger.info(f"Updated NFO: {nfo_path}")
# Re-download media files if requested
if download_media:
await self._download_media_files(
details,
folder_path,
download_poster=True,
download_logo=True,
download_fanart=True
)
return nfo_path
def parse_nfo_ids(self, nfo_path: Path) -> Dict[str, Optional[int]]:
"""Parse TMDB ID and TVDB ID from an existing NFO file.
Args:
nfo_path: Path to tvshow.nfo file
Returns:
Dictionary with 'tmdb_id' and 'tvdb_id' keys.
Values are integers if found, None otherwise.
Example:
>>> ids = nfo_service.parse_nfo_ids(Path("/anime/series/tvshow.nfo"))
>>> print(ids)
{'tmdb_id': 1429, 'tvdb_id': 79168}
"""
result = {"tmdb_id": None, "tvdb_id": None}
if not nfo_path.exists():
logger.debug(f"NFO file not found: {nfo_path}")
return result
try:
tree = etree.parse(str(nfo_path))
root = tree.getroot()
# Try to find TMDB ID from uniqueid elements first
for uniqueid in root.findall(".//uniqueid"):
uid_type = uniqueid.get("type")
uid_text = uniqueid.text
if uid_type == "tmdb" and uid_text:
try:
result["tmdb_id"] = int(uid_text)
except ValueError:
logger.warning(
f"Invalid TMDB ID format in NFO: {uid_text}"
)
elif uid_type == "tvdb" and uid_text:
try:
result["tvdb_id"] = int(uid_text)
except ValueError:
logger.warning(
f"Invalid TVDB ID format in NFO: {uid_text}"
)
# Fallback: check for dedicated tmdbid/tvdbid elements
if result["tmdb_id"] is None:
tmdbid_elem = root.find(".//tmdbid")
if tmdbid_elem is not None and tmdbid_elem.text:
try:
result["tmdb_id"] = int(tmdbid_elem.text)
except ValueError:
logger.warning(
f"Invalid TMDB ID format in tmdbid element: "
f"{tmdbid_elem.text}"
)
if result["tvdb_id"] is None:
tvdbid_elem = root.find(".//tvdbid")
if tvdbid_elem is not None and tvdbid_elem.text:
try:
result["tvdb_id"] = int(tvdbid_elem.text)
except ValueError:
logger.warning(
f"Invalid TVDB ID format in tvdbid element: "
f"{tvdbid_elem.text}"
)
logger.debug(
f"Parsed IDs from NFO: {nfo_path.name} - "
f"TMDB: {result['tmdb_id']}, TVDB: {result['tvdb_id']}"
)
except etree.XMLSyntaxError as e:
logger.error(f"Invalid XML in NFO file {nfo_path}: {e}")
except Exception as e: # pylint: disable=broad-except
logger.error(f"Error parsing NFO file {nfo_path}: {e}")
return result
async def _enrich_details_with_fallback(
self,
details: Dict[str, Any],
search_overview: Optional[str] = None,
) -> Dict[str, Any]:
"""Enrich TMDB details with fallback languages for empty fields.
When requesting details in ``de-DE``, some anime have an empty
``overview`` (and potentially other translatable fields). This
method detects empty values and fills them from alternative
languages (``en-US``, then ``ja-JP``) so that NFO files always
contain a ``plot`` regardless of whether the German translation
exists. As a last resort, the overview from the search result
is used.
Args:
details: TMDB TV show details (language ``de-DE``).
search_overview: Overview text from the TMDB search result,
used as a final fallback if all language-specific
requests fail or return empty overviews.
Returns:
The *same* dict, mutated in-place with fallback values
where needed.
"""
overview = details.get("overview") or ""
if overview:
# Overview already populated nothing to do.
return details
tmdb_id = details.get("id")
fallback_languages = ["en-US", "ja-JP"]
for lang in fallback_languages:
if details.get("overview"):
break
logger.debug(
"Trying %s fallback for TMDB ID %s",
lang, tmdb_id,
)
try:
lang_details = await self.tmdb_client.get_tv_show_details(
tmdb_id,
language=lang,
)
if not details.get("overview") and lang_details.get("overview"):
details["overview"] = lang_details["overview"]
logger.info(
"Used %s overview fallback for TMDB ID %s",
lang, tmdb_id,
)
# Also fill tagline if missing
if not details.get("tagline") and lang_details.get("tagline"):
details["tagline"] = lang_details["tagline"]
except Exception as exc: # pylint: disable=broad-except
logger.warning(
"Failed to fetch %s fallback for TMDB ID %s: %s",
lang, tmdb_id, exc,
)
# Last resort: use search result overview
if not details.get("overview") and search_overview:
details["overview"] = search_overview
logger.info(
"Used search result overview fallback for TMDB ID %s",
tmdb_id,
)
return details
def _find_best_match(
self,
results: List[Dict[str, Any]],
query: str,
year: Optional[int] = None
) -> Dict[str, Any]:
"""Find best matching TV show from search results.
Args:
results: TMDB search results
query: Original search query
year: Expected release year
Returns:
Best matching TV show data
"""
if not results:
raise TMDBAPIError("No search results to match")
# If year is provided, try to find exact match
if year:
for result in results:
first_air_date = result.get("first_air_date", "")
if first_air_date.startswith(str(year)):
logger.debug(f"Found year match: {result['name']} ({first_air_date})")
return result
# Return first result (usually best match)
return results[0]
async def _download_media_files(
self,
tmdb_data: Dict[str, Any],
folder_path: Path,
download_poster: bool = True,
download_logo: bool = True,
download_fanart: bool = True
) -> Dict[str, bool]:
"""Download media files (poster, logo, fanart).
Args:
tmdb_data: TMDB TV show details
folder_path: Series folder path
download_poster: Download poster.jpg
download_logo: Download logo.png
download_fanart: Download fanart.jpg
Returns:
Dictionary with download status for each file
"""
poster_url = None
logo_url = None
fanart_url = None
# Get poster URL
if download_poster and tmdb_data.get("poster_path"):
poster_url = self.tmdb_client.get_image_url(
tmdb_data["poster_path"],
self.image_size
)
# Get fanart URL
if download_fanart and tmdb_data.get("backdrop_path"):
fanart_url = self.tmdb_client.get_image_url(
tmdb_data["backdrop_path"],
"original" # Always use original for fanart
)
# Get logo URL
if download_logo:
images_data = tmdb_data.get("images", {})
logos = images_data.get("logos", [])
if logos:
logo_url = self.tmdb_client.get_image_url(
logos[0]["file_path"],
"original" # Logos should be original size
)
# Download all media concurrently
results = await self.image_downloader.download_all_media(
folder_path,
poster_url=poster_url,
logo_url=logo_url,
fanart_url=fanart_url,
skip_existing=True
)
logger.info(f"Media download results: {results}")
return results
async def close(self):
"""Clean up resources."""
await self.tmdb_client.close()

View File

@@ -1,279 +0,0 @@
"""Service for managing series with NFO metadata support.
This service layer component orchestrates SerieList (core entity) with
NFOService to provide automatic NFO creation and updates during series scans.
This follows clean architecture principles by keeping the core entities
independent of external services like TMDB API.
"""
import asyncio
import logging
from pathlib import Path
from typing import Optional
from src.config.settings import settings
from src.core.entities.SerieList import SerieList
from src.core.services.nfo_service import NFOService
from src.core.services.tmdb_client import TMDBAPIError
logger = logging.getLogger(__name__)
class SeriesManagerService:
"""Service for managing series with optional NFO metadata support.
This service wraps SerieList and adds NFO creation/update capabilities
based on configuration settings. It maintains clean separation between
core entities and external services.
Attributes:
serie_list: SerieList instance for series management
nfo_service: Optional NFOService for metadata management
auto_create_nfo: Whether to auto-create NFO files
update_on_scan: Whether to update existing NFO files
"""
def __init__(
self,
anime_directory: str,
tmdb_api_key: Optional[str] = None,
auto_create_nfo: bool = False,
update_on_scan: bool = False,
download_poster: bool = True,
download_logo: bool = True,
download_fanart: bool = True,
image_size: str = "original"
):
"""Initialize series manager service.
Args:
anime_directory: Base directory for anime series
tmdb_api_key: TMDB API key (optional, required for NFO features)
auto_create_nfo: Automatically create NFO files when scanning
update_on_scan: Update existing NFO files when scanning
download_poster: Download poster.jpg
download_logo: Download logo.png
download_fanart: Download fanart.jpg
image_size: Image size to download
"""
self.anime_directory = anime_directory
# Skip automatic folder scanning - we load from database instead
self.serie_list = SerieList(anime_directory, skip_load=True)
# NFO configuration
self.auto_create_nfo = auto_create_nfo
self.update_on_scan = update_on_scan
self.download_poster = download_poster
self.download_logo = download_logo
self.download_fanart = download_fanart
# Initialize NFO service if API key provided and NFO features enabled
self.nfo_service: Optional[NFOService] = None
if tmdb_api_key and (auto_create_nfo or update_on_scan):
try:
from src.core.services.nfo_factory import get_nfo_factory
factory = get_nfo_factory()
self.nfo_service = factory.create(
tmdb_api_key=tmdb_api_key,
anime_directory=anime_directory,
image_size=image_size,
auto_create=auto_create_nfo
)
logger.info("NFO service initialized (auto_create=%s, update=%s)",
auto_create_nfo, update_on_scan)
except (ValueError, Exception) as e: # pylint: disable=broad-except
logger.warning(
"Failed to initialize NFO service: %s", str(e)
)
self.nfo_service = None
elif auto_create_nfo or update_on_scan:
logger.warning(
"NFO features requested but TMDB_API_KEY not provided. "
"NFO creation/updates will be skipped."
)
@classmethod
def from_settings(cls) -> "SeriesManagerService":
"""Create SeriesManagerService from application settings.
Returns:
Configured SeriesManagerService instance
"""
return cls(
anime_directory=settings.anime_directory,
tmdb_api_key=settings.tmdb_api_key,
auto_create_nfo=settings.nfo_auto_create,
update_on_scan=settings.nfo_update_on_scan,
download_poster=settings.nfo_download_poster,
download_logo=settings.nfo_download_logo,
download_fanart=settings.nfo_download_fanart,
image_size=settings.nfo_image_size
)
async def process_nfo_for_series(
self,
serie_folder: str,
serie_name: str,
serie_key: str,
year: Optional[int] = None
):
"""Process NFO file for a series (create or update).
Args:
serie_folder: Series folder name
serie_name: Series display name
serie_key: Series unique identifier for database updates
year: Release year (helps with TMDB matching)
"""
if not self.nfo_service:
return
try:
folder_path = Path(self.anime_directory) / serie_folder
nfo_path = folder_path / "tvshow.nfo"
nfo_exists = await self.nfo_service.check_nfo_exists(serie_folder)
# If NFO exists, parse IDs and update database
if nfo_exists:
logger.debug(f"Parsing IDs from existing NFO for '{serie_name}'")
ids = self.nfo_service.parse_nfo_ids(nfo_path)
if ids["tmdb_id"] or ids["tvdb_id"]:
# Update database using service layer
from datetime import datetime, timezone
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
async with get_db_session() as db:
series = await AnimeSeriesService.get_by_key(db, serie_key)
if series:
now = datetime.now(timezone.utc)
# Prepare update fields
update_fields = {
"has_nfo": True,
"nfo_updated_at": now,
}
if series.nfo_created_at is None:
update_fields["nfo_created_at"] = now
if ids["tmdb_id"] is not None:
update_fields["tmdb_id"] = ids["tmdb_id"]
logger.debug(
f"Updated TMDB ID for '{serie_name}': "
f"{ids['tmdb_id']}"
)
if ids["tvdb_id"] is not None:
update_fields["tvdb_id"] = ids["tvdb_id"]
logger.debug(
f"Updated TVDB ID for '{serie_name}': "
f"{ids['tvdb_id']}"
)
# Use service layer for update
await AnimeSeriesService.update(db, series.id, **update_fields)
await db.commit()
logger.info(
f"Updated database with IDs from NFO for "
f"'{serie_name}' - TMDB: {ids['tmdb_id']}, "
f"TVDB: {ids['tvdb_id']}"
)
else:
logger.warning(
f"Series not found in database for NFO ID "
f"update: {serie_key}"
)
# Create NFO file only if it doesn't exist and auto_create enabled
if not nfo_exists and self.auto_create_nfo:
logger.info(
f"Creating NFO for '{serie_name}' ({serie_folder})"
)
await self.nfo_service.create_tvshow_nfo(
serie_name=serie_name,
serie_folder=serie_folder,
year=year,
download_poster=self.download_poster,
download_logo=self.download_logo,
download_fanart=self.download_fanart
)
logger.info(f"Successfully created NFO for '{serie_name}'")
elif nfo_exists:
logger.debug(
f"NFO exists for '{serie_name}', skipping download"
)
except TMDBAPIError as e:
logger.error(f"TMDB API error processing '{serie_name}': {e}")
except Exception as e:
logger.error(
f"Unexpected error processing NFO for '{serie_name}': {e}",
exc_info=True
)
async def scan_and_process_nfo(self):
"""Scan all series and process NFO files based on configuration.
This method:
1. Loads series from database (avoiding filesystem scan)
2. For each series with existing NFO, reads TMDB/TVDB IDs
and updates database
3. For each series without NFO (if auto_create=True), creates one
4. For each series with NFO (if update_on_scan=True), updates it
5. Runs operations concurrently for better performance
"""
if not self.nfo_service:
logger.info("NFO service not enabled, skipping NFO processing")
return
# Import database dependencies
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
# Load series from database (not from filesystem)
async with get_db_session() as db:
anime_series_list = await AnimeSeriesService.get_all(
db, with_episodes=False
)
if not anime_series_list:
logger.info("No series found in database to process")
return
logger.info(f"Processing NFO for {len(anime_series_list)} series...")
# Create tasks for concurrent processing
# Each task creates its own database session
tasks = []
for anime_series in anime_series_list:
# Extract year if available
year = getattr(anime_series, 'year', None)
task = self.process_nfo_for_series(
serie_folder=anime_series.folder,
serie_name=anime_series.name,
serie_key=anime_series.key,
year=year
)
tasks.append(task)
# Process in batches to avoid overwhelming TMDB API
batch_size = 5
for i in range(0, len(tasks), batch_size):
batch = tasks[i:i + batch_size]
await asyncio.gather(*batch, return_exceptions=True)
# Small delay between batches to respect rate limits
if i + batch_size < len(tasks):
await asyncio.sleep(2)
async def close(self):
"""Clean up resources."""
if self.nfo_service:
await self.nfo_service.close()

View File

@@ -1,316 +0,0 @@
"""TMDB API client for fetching TV show metadata.
This module provides an async client for The Movie Database (TMDB) API,
adapted from the scraper project to fit the AniworldMain architecture.
Example:
>>> async with TMDBClient(api_key="your_key") as client:
... results = await client.search_tv_show("Attack on Titan")
... show_id = results["results"][0]["id"]
... details = await client.get_tv_show_details(show_id)
"""
import asyncio
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
import aiohttp
logger = logging.getLogger(__name__)
class TMDBAPIError(Exception):
"""Exception raised for TMDB API errors."""
pass
class TMDBClient:
"""Async TMDB API client for TV show metadata.
Attributes:
api_key: TMDB API key for authentication
base_url: Base URL for TMDB API
image_base_url: Base URL for TMDB images
max_connections: Maximum concurrent connections
session: aiohttp ClientSession for requests
"""
DEFAULT_BASE_URL = "https://api.themoviedb.org/3"
DEFAULT_IMAGE_BASE_URL = "https://image.tmdb.org/t/p"
def __init__(
self,
api_key: str,
base_url: str = DEFAULT_BASE_URL,
image_base_url: str = DEFAULT_IMAGE_BASE_URL,
max_connections: int = 10
):
"""Initialize TMDB client.
Args:
api_key: TMDB API key
base_url: TMDB API base URL
image_base_url: TMDB image base URL
max_connections: Maximum concurrent connections
"""
if not api_key:
raise ValueError("TMDB API key is required")
self.api_key = api_key
self.base_url = base_url.rstrip('/')
self.image_base_url = image_base_url.rstrip('/')
self.max_connections = max_connections
self.session: Optional[aiohttp.ClientSession] = None
self._cache: Dict[str, Any] = {}
async def __aenter__(self):
"""Async context manager entry."""
await self._ensure_session()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit."""
await self.close()
async def _ensure_session(self):
"""Ensure aiohttp session is created."""
if self.session is None or self.session.closed:
connector = aiohttp.TCPConnector(limit=self.max_connections)
self.session = aiohttp.ClientSession(connector=connector)
async def _request(
self,
endpoint: str,
params: Optional[Dict[str, Any]] = None,
max_retries: int = 3
) -> Dict[str, Any]:
"""Make an async request to TMDB API with retries.
Args:
endpoint: API endpoint (e.g., 'search/tv')
params: Query parameters
max_retries: Maximum retry attempts
Returns:
API response as dictionary
Raises:
TMDBAPIError: If request fails after retries
"""
await self._ensure_session()
url = f"{self.base_url}/{endpoint}"
params = params or {}
params["api_key"] = self.api_key
# Cache key for deduplication
cache_key = f"{endpoint}:{str(sorted(params.items()))}"
if cache_key in self._cache:
logger.debug(f"Cache hit for {endpoint}")
return self._cache[cache_key]
delay = 1
last_error = None
for attempt in range(max_retries):
try:
# Re-ensure session before each attempt in case it was closed
await self._ensure_session()
if self.session is None:
raise TMDBAPIError("Session is not available")
logger.debug(f"TMDB API request: {endpoint} (attempt {attempt + 1})")
async with self.session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
if resp.status == 401:
raise TMDBAPIError("Invalid TMDB API key")
elif resp.status == 404:
raise TMDBAPIError(f"Resource not found: {endpoint}")
elif resp.status == 429:
# Rate limit - wait longer
retry_after = int(resp.headers.get('Retry-After', delay * 2))
logger.warning(f"Rate limited, waiting {retry_after}s")
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(f"Request timeout (attempt {attempt + 1}), retrying in {delay}s")
await asyncio.sleep(delay)
delay *= 2
else:
logger.error(f"Request timed out after {max_retries} attempts")
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(f"Session issue detected, recreating session: {e}")
self.session = None
await self._ensure_session()
if attempt < max_retries - 1:
logger.warning(f"Request failed (attempt {attempt + 1}): {e}, retrying in {delay}s")
await asyncio.sleep(delay)
delay *= 2
else:
logger.error(f"Request failed after {max_retries} attempts: {e}")
raise TMDBAPIError(f"Request failed after {max_retries} attempts: {last_error}")
async def search_tv_show(
self,
query: str,
language: str = "de-DE",
page: int = 1
) -> Dict[str, Any]:
"""Search for TV shows by name.
Args:
query: Search query (show name)
language: Language for results (default: German)
page: Page number for pagination
Returns:
Search results with list of shows
Example:
>>> results = await client.search_tv_show("Attack on Titan")
>>> shows = results["results"]
"""
return await self._request(
"search/tv",
{"query": query, "language": language, "page": page}
)
async def get_tv_show_details(
self,
tv_id: int,
language: str = "de-DE",
append_to_response: Optional[str] = None
) -> Dict[str, Any]:
"""Get detailed information about a TV show.
Args:
tv_id: TMDB TV show ID
language: Language for metadata
append_to_response: Additional data to include (e.g., "credits,images")
Returns:
TV show details including metadata, cast, etc.
"""
params = {"language": language}
if append_to_response:
params["append_to_response"] = append_to_response
return await self._request(f"tv/{tv_id}", params)
async def get_tv_show_content_ratings(self, tv_id: int) -> Dict[str, Any]:
"""Get content ratings for a TV show.
Args:
tv_id: TMDB TV show ID
Returns:
Content ratings by country
"""
return await self._request(f"tv/{tv_id}/content_ratings")
async def get_tv_show_external_ids(self, tv_id: int) -> Dict[str, Any]:
"""Get external IDs (IMDB, TVDB) for a TV show.
Args:
tv_id: TMDB TV show ID
Returns:
Dictionary with external IDs (imdb_id, tvdb_id, etc.)
"""
return await self._request(f"tv/{tv_id}/external_ids")
async def get_tv_show_images(
self,
tv_id: int,
language: Optional[str] = None
) -> Dict[str, Any]:
"""Get images (posters, backdrops, logos) for a TV show.
Args:
tv_id: TMDB TV show ID
language: Language filter for images (None = all languages)
Returns:
Dictionary with poster, backdrop, and logo lists
"""
params = {}
if language:
params["language"] = language
return await self._request(f"tv/{tv_id}/images", params)
async def download_image(
self,
image_path: str,
local_path: Path,
size: str = "original"
) -> None:
"""Download an image from TMDB.
Args:
image_path: Image path from TMDB API (e.g., "/abc123.jpg")
local_path: Local file path to save image
size: Image size (w500, original, etc.)
Raises:
TMDBAPIError: If download fails
"""
await self._ensure_session()
url = f"{self.image_base_url}/{size}{image_path}"
try:
logger.debug(f"Downloading image from {url}")
async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=60)) as resp:
resp.raise_for_status()
# Ensure parent directory exists
local_path.parent.mkdir(parents=True, exist_ok=True)
# Write image data
with open(local_path, "wb") as f:
f.write(await resp.read())
logger.info(f"Downloaded image to {local_path}")
except aiohttp.ClientError as e:
raise TMDBAPIError(f"Failed to download image: {e}")
def get_image_url(self, image_path: str, size: str = "original") -> str:
"""Get full URL for an image.
Args:
image_path: Image path from TMDB API
size: Image size (w500, original, etc.)
Returns:
Full image URL
"""
return f"{self.image_base_url}/{size}{image_path}"
async def close(self):
"""Close the aiohttp session and clean up resources."""
if self.session and not self.session.closed:
await self.session.close()
self.session = None
logger.debug("TMDB client session closed")
def clear_cache(self):
"""Clear the request cache."""
self._cache.clear()
logger.debug("TMDB client cache cleared")

View File

@@ -1,349 +0,0 @@
"""Image downloader utility for NFO media files.
This module provides functions to download poster, logo, and fanart images
from TMDB and validate them.
Example:
>>> downloader = ImageDownloader()
>>> await downloader.download_poster(poster_url, "/path/to/poster.jpg")
"""
import asyncio
import logging
from pathlib import Path
from typing import Optional
import aiohttp
from PIL import Image
logger = logging.getLogger(__name__)
class ImageDownloadError(Exception):
"""Exception raised for image download failures."""
pass
class ImageDownloader:
"""Utility for downloading and validating images.
Supports async context manager protocol for proper resource cleanup.
Attributes:
max_retries: Maximum retry attempts for downloads
timeout: Request timeout in seconds
min_file_size: Minimum valid file size in bytes
session: Optional aiohttp session (managed internally)
Example:
>>> async with ImageDownloader() as downloader:
... await downloader.download_poster(url, path)
"""
def __init__(
self,
max_retries: int = 3,
timeout: int = 30,
min_file_size: int = 1024, # 1 KB
retry_delay: float = 1.0
):
"""Initialize image downloader.
Args:
max_retries: Maximum retry attempts
timeout: Request timeout in seconds
min_file_size: Minimum valid file size in bytes
retry_delay: Delay between retries in seconds
"""
self.max_retries = max_retries
self.timeout = timeout
self.min_file_size = min_file_size
self.retry_delay = retry_delay
self.session: Optional[aiohttp.ClientSession] = None
async def __aenter__(self):
"""Enter async context manager and create session."""
self._get_session() # Ensure session is created
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Exit async context manager and cleanup resources."""
await self.close()
return False
async def close(self):
"""Close aiohttp session if open."""
if self.session and not self.session.closed:
await self.session.close()
self.session = None
def _get_session(self) -> aiohttp.ClientSession:
"""Get or create aiohttp session.
Returns:
Active aiohttp session
"""
# If no session, create one
if self.session is None:
timeout = aiohttp.ClientTimeout(total=self.timeout)
self.session = aiohttp.ClientSession(timeout=timeout)
return self.session
# If session exists, check if it's closed (handle real sessions only)
# Mock sessions from tests won't have a boolean closed attribute
try:
if hasattr(self.session, 'closed') and self.session.closed is True:
timeout = aiohttp.ClientTimeout(total=self.timeout)
self.session = aiohttp.ClientSession(timeout=timeout)
except (AttributeError, TypeError):
# Mock session or unusual object, just use it as-is
pass
return self.session
async def download_image(
self,
url: str,
local_path: Path,
skip_existing: bool = True,
validate: bool = True
) -> bool:
"""Download an image from URL to local path.
Args:
url: Image URL
local_path: Local file path to save image
skip_existing: Skip download if file already exists
validate: Validate image after download
Returns:
True if download successful, False otherwise
Raises:
ImageDownloadError: If download fails after retries
"""
# Check if file already exists
if skip_existing and local_path.exists():
if local_path.stat().st_size >= self.min_file_size:
logger.debug(f"Image already exists: {local_path}")
return True
# Ensure parent directory exists
local_path.parent.mkdir(parents=True, exist_ok=True)
delay = self.retry_delay
last_error = None
for attempt in range(self.max_retries):
try:
logger.debug(
f"Downloading image from {url} "
f"(attempt {attempt + 1})"
)
# Use persistent session
session = self._get_session()
async with session.get(url) as resp:
if resp.status == 404:
logger.warning(f"Image not found: {url}")
return False
resp.raise_for_status()
# Download image data
data = await resp.read()
# Check file size
if len(data) < self.min_file_size:
raise ImageDownloadError(
f"Downloaded file too small: {len(data)} bytes"
)
# Write to file
with open(local_path, "wb") as f:
f.write(data)
# Validate image if requested
if validate and not self.validate_image(local_path):
local_path.unlink(missing_ok=True)
raise ImageDownloadError("Image validation failed")
logger.info(f"Downloaded image to {local_path}")
return True
except (aiohttp.ClientError, IOError, ImageDownloadError) as e:
last_error = e
if attempt < self.max_retries - 1:
logger.warning(
f"Download failed (attempt {attempt + 1}): {e}, "
f"retrying in {delay}s"
)
await asyncio.sleep(delay)
delay *= 2
else:
logger.error(
f"Download failed after {self.max_retries} attempts: {e}"
)
raise ImageDownloadError(
f"Failed to download image after {self.max_retries} attempts: {last_error}"
)
async def download_poster(
self,
url: str,
series_folder: Path,
filename: str = "poster.jpg",
skip_existing: bool = True
) -> bool:
"""Download poster image.
Args:
url: Poster URL
series_folder: Series folder path
filename: Output filename (default: poster.jpg)
skip_existing: Skip if file exists
Returns:
True if successful
"""
local_path = series_folder / filename
try:
return await self.download_image(url, local_path, skip_existing)
except ImageDownloadError as e:
logger.warning(f"Failed to download poster: {e}")
return False
async def download_logo(
self,
url: str,
series_folder: Path,
filename: str = "logo.png",
skip_existing: bool = True
) -> bool:
"""Download logo image.
Args:
url: Logo URL
series_folder: Series folder path
filename: Output filename (default: logo.png)
skip_existing: Skip if file exists
Returns:
True if successful
"""
local_path = series_folder / filename
try:
return await self.download_image(url, local_path, skip_existing)
except ImageDownloadError as e:
logger.warning(f"Failed to download logo: {e}")
return False
async def download_fanart(
self,
url: str,
series_folder: Path,
filename: str = "fanart.jpg",
skip_existing: bool = True
) -> bool:
"""Download fanart/backdrop image.
Args:
url: Fanart URL
series_folder: Series folder path
filename: Output filename (default: fanart.jpg)
skip_existing: Skip if file exists
Returns:
True if successful
"""
local_path = series_folder / filename
try:
return await self.download_image(url, local_path, skip_existing)
except ImageDownloadError as e:
logger.warning(f"Failed to download fanart: {e}")
return False
def validate_image(self, image_path: Path) -> bool:
"""Validate that file is a valid image.
Args:
image_path: Path to image file
Returns:
True if valid image, False otherwise
"""
try:
with Image.open(image_path) as img:
# Verify it's a valid image
img.verify()
# Check file size
if image_path.stat().st_size < self.min_file_size:
logger.warning(f"Image file too small: {image_path}")
return False
return True
except Exception as e:
logger.warning(f"Image validation failed for {image_path}: {e}")
return False
async def download_all_media(
self,
series_folder: Path,
poster_url: Optional[str] = None,
logo_url: Optional[str] = None,
fanart_url: Optional[str] = None,
skip_existing: bool = True
) -> dict[str, bool]:
"""Download all media files (poster, logo, fanart).
Args:
series_folder: Series folder path
poster_url: Poster URL (optional)
logo_url: Logo URL (optional)
fanart_url: Fanart URL (optional)
skip_existing: Skip existing files
Returns:
Dictionary with download status for each file type
"""
results = {
"poster": None,
"logo": None,
"fanart": None
}
tasks = []
if poster_url:
tasks.append(("poster", self.download_poster(
poster_url, series_folder, skip_existing=skip_existing
)))
if logo_url:
tasks.append(("logo", self.download_logo(
logo_url, series_folder, skip_existing=skip_existing
)))
if fanart_url:
tasks.append(("fanart", self.download_fanart(
fanart_url, series_folder, skip_existing=skip_existing
)))
# Download concurrently
if tasks:
task_results = await asyncio.gather(
*[task for _, task in tasks],
return_exceptions=True
)
for (media_type, _), result in zip(tasks, task_results):
if isinstance(result, Exception):
logger.error(f"Error downloading {media_type}: {result}")
results[media_type] = False
else:
results[media_type] = result
return results

View File

@@ -1,213 +0,0 @@
"""NFO XML generator for Kodi/XBMC format.
This module provides functions to generate tvshow.nfo XML files from
TVShowNFO Pydantic models, adapted from the scraper project.
Example:
>>> from src.core.entities.nfo_models import TVShowNFO
>>> nfo = TVShowNFO(title="Test Show", year=2020, tmdbid=12345)
>>> xml_string = generate_tvshow_nfo(nfo)
"""
import logging
from typing import Optional
from lxml import etree
from src.config.settings import settings
from src.core.entities.nfo_models import TVShowNFO
logger = logging.getLogger(__name__)
def generate_tvshow_nfo(tvshow: TVShowNFO, pretty_print: bool = True) -> str:
"""Generate tvshow.nfo XML content from TVShowNFO model.
Args:
tvshow: TVShowNFO Pydantic model with metadata
pretty_print: Whether to format XML with indentation
Returns:
XML string in Kodi/XBMC tvshow.nfo format
Example:
>>> nfo = TVShowNFO(title="Attack on Titan", year=2013)
>>> xml = generate_tvshow_nfo(nfo)
"""
root = etree.Element("tvshow")
# Basic information
_add_element(root, "title", tvshow.title)
_add_element(root, "originaltitle", tvshow.originaltitle)
_add_element(root, "showtitle", tvshow.showtitle)
_add_element(root, "sorttitle", tvshow.sorttitle)
_add_element(root, "year", str(tvshow.year) if tvshow.year else None)
# Plot and description always write <plot> even when empty so that
# all NFO files have a consistent set of tags regardless of whether they
# were produced by create or update.
_add_element(root, "plot", tvshow.plot, always_write=True)
_add_element(root, "outline", tvshow.outline)
_add_element(root, "tagline", tvshow.tagline)
# Technical details
_add_element(root, "runtime", str(tvshow.runtime) if tvshow.runtime else None)
# Content rating - prefer FSK if available and configured
if getattr(settings, 'nfo_prefer_fsk_rating', True) and tvshow.fsk:
_add_element(root, "mpaa", tvshow.fsk)
else:
_add_element(root, "mpaa", tvshow.mpaa)
_add_element(root, "certification", tvshow.certification)
# Status and dates
_add_element(root, "premiered", tvshow.premiered)
_add_element(root, "status", tvshow.status)
_add_element(root, "dateadded", tvshow.dateadded)
# Ratings
if tvshow.ratings:
ratings_elem = etree.SubElement(root, "ratings")
for rating in tvshow.ratings:
rating_elem = etree.SubElement(ratings_elem, "rating")
if rating.name:
rating_elem.set("name", rating.name)
if rating.max_rating:
rating_elem.set("max", str(rating.max_rating))
if rating.default:
rating_elem.set("default", "true")
_add_element(rating_elem, "value", str(rating.value))
if rating.votes is not None:
_add_element(rating_elem, "votes", str(rating.votes))
_add_element(root, "userrating", str(tvshow.userrating) if tvshow.userrating is not None else None)
# IDs
_add_element(root, "tmdbid", str(tvshow.tmdbid) if tvshow.tmdbid else None)
_add_element(root, "imdbid", tvshow.imdbid)
_add_element(root, "tvdbid", str(tvshow.tvdbid) if tvshow.tvdbid else None)
# Legacy ID fields for compatibility
_add_element(root, "id", str(tvshow.tvdbid) if tvshow.tvdbid else None)
_add_element(root, "imdb_id", tvshow.imdbid)
# Unique IDs
for uid in tvshow.uniqueid:
uid_elem = etree.SubElement(root, "uniqueid")
uid_elem.set("type", uid.type)
if uid.default:
uid_elem.set("default", "true")
uid_elem.text = uid.value
# Multi-value fields
for genre in tvshow.genre:
_add_element(root, "genre", genre)
for studio in tvshow.studio:
_add_element(root, "studio", studio)
for country in tvshow.country:
_add_element(root, "country", country)
for tag in tvshow.tag:
_add_element(root, "tag", tag)
# Thumbnails (posters, logos)
for thumb in tvshow.thumb:
thumb_elem = etree.SubElement(root, "thumb")
if thumb.aspect:
thumb_elem.set("aspect", thumb.aspect)
if thumb.season is not None:
thumb_elem.set("season", str(thumb.season))
if thumb.type:
thumb_elem.set("type", thumb.type)
thumb_elem.text = str(thumb.url)
# Fanart
if tvshow.fanart:
fanart_elem = etree.SubElement(root, "fanart")
for fanart in tvshow.fanart:
fanart_thumb = etree.SubElement(fanart_elem, "thumb")
fanart_thumb.text = str(fanart.url)
# Named seasons
for named_season in tvshow.namedseason:
season_elem = etree.SubElement(root, "namedseason")
season_elem.set("number", str(named_season.number))
season_elem.text = named_season.name
# Actors
for actor in tvshow.actors:
actor_elem = etree.SubElement(root, "actor")
_add_element(actor_elem, "name", actor.name)
_add_element(actor_elem, "role", actor.role)
_add_element(actor_elem, "thumb", str(actor.thumb) if actor.thumb else None)
_add_element(actor_elem, "profile", str(actor.profile) if actor.profile else None)
_add_element(actor_elem, "tmdbid", str(actor.tmdbid) if actor.tmdbid else None)
# Additional fields
_add_element(root, "trailer", str(tvshow.trailer) if tvshow.trailer else None)
_add_element(root, "watched", "true" if tvshow.watched else "false")
if tvshow.playcount is not None:
_add_element(root, "playcount", str(tvshow.playcount))
# Generate XML string
xml_str = etree.tostring(
root,
pretty_print=pretty_print,
encoding="unicode",
xml_declaration=False
)
# Add XML declaration
xml_declaration = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
return xml_declaration + xml_str
def _add_element(
parent: etree.Element,
tag: str,
text: Optional[str],
always_write: bool = False,
) -> Optional[etree.Element]:
"""Add a child element to parent if text is not None or empty.
Args:
parent: Parent XML element
tag: Tag name for child element
text: Text content (None or empty strings are skipped
unless *always_write* is True)
always_write: When True the element is created even when
*text* is None/empty (the element will have
no text content). Useful for tags like
``<plot>`` that should always be present.
Returns:
Created element or None if skipped
"""
if text is not None and text != "":
elem = etree.SubElement(parent, tag)
elem.text = text
return elem
if always_write:
return etree.SubElement(parent, tag)
return None
def validate_nfo_xml(xml_string: str) -> bool:
"""Validate NFO XML structure.
Args:
xml_string: XML content to validate
Returns:
True if valid XML, False otherwise
"""
try:
etree.fromstring(xml_string.encode('utf-8'))
return True
except etree.XMLSyntaxError as e:
logger.error(f"Invalid NFO XML: {e}")
return False

View File

@@ -1,234 +0,0 @@
"""TMDB to NFO model mapper.
This module converts TMDB API data to TVShowNFO Pydantic models,
keeping the mapping logic separate from the service orchestration.
Example:
>>> model = tmdb_to_nfo_model(tmdb_data, content_ratings, get_image_url, "original")
"""
import logging
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional
from src.core.entities.nfo_models import (
ActorInfo,
ImageInfo,
NamedSeason,
RatingInfo,
TVShowNFO,
UniqueID,
)
logger = logging.getLogger(__name__)
def _extract_rating_by_country(
content_ratings: Dict[str, Any],
country_code: str,
) -> Optional[str]:
"""Extract content rating for a specific country from TMDB content ratings.
Args:
content_ratings: TMDB content ratings response dict with "results" list.
country_code: ISO 3166-1 alpha-2 country code (e.g., "DE", "US").
Returns:
Raw rating string for the requested country, or None if not found.
Example:
>>> _extract_rating_by_country({"results": [{"iso_3166_1": "US", "rating": "TV-14"}]}, "US")
'TV-14'
"""
if not content_ratings or "results" not in content_ratings:
return None
for rating in content_ratings["results"]:
if rating.get("iso_3166_1") == country_code:
return rating.get("rating") or None
return None
def _extract_fsk_rating(content_ratings: Dict[str, Any]) -> Optional[str]:
"""Extract German FSK rating from TMDB content ratings.
Delegates to :func:`_extract_rating_by_country` and then normalises the
raw TMDB string into the 'FSK XX' format expected by Kodi/Jellyfin.
Args:
content_ratings: TMDB content ratings response.
Returns:
Formatted FSK string (e.g., 'FSK 12') or None.
"""
raw = _extract_rating_by_country(content_ratings, "DE")
if raw is None:
return None
fsk_mapping: Dict[str, str] = {
"0": "FSK 0",
"6": "FSK 6",
"12": "FSK 12",
"16": "FSK 16",
"18": "FSK 18",
}
if raw in fsk_mapping:
return fsk_mapping[raw]
# Try to extract numeric part (ordered high→low to avoid partial matches)
for key in ["18", "16", "12", "6", "0"]:
if key in raw:
return fsk_mapping[key]
if raw.startswith("FSK"):
return raw
logger.debug("Unmapped German rating: %s", raw)
return None
def tmdb_to_nfo_model(
tmdb_data: Dict[str, Any],
content_ratings: Optional[Dict[str, Any]],
get_image_url: Callable[[str, str], str],
image_size: str = "original",
) -> TVShowNFO:
"""Convert TMDB API data to a fully-populated TVShowNFO model.
All required NFO tags are explicitly set in this function so that newly
created files are complete without a subsequent repair pass.
Args:
tmdb_data: TMDB TV show details (with credits, external_ids, images
appended via ``append_to_response``).
content_ratings: TMDB content ratings response, or None.
get_image_url: Callable ``(path, size) -> url`` for TMDB images.
image_size: TMDB image size parameter (e.g., ``"original"``, ``"w500"``).
Returns:
TVShowNFO Pydantic model with all available fields populated.
"""
title: str = tmdb_data["name"]
original_title: str = tmdb_data.get("original_name") or title
# --- Year and dates ---
first_air_date: Optional[str] = tmdb_data.get("first_air_date") or None
year: Optional[int] = int(first_air_date[:4]) if first_air_date else None
# --- Ratings ---
ratings: List[RatingInfo] = []
if tmdb_data.get("vote_average"):
ratings.append(RatingInfo(
name="themoviedb",
value=float(tmdb_data["vote_average"]),
votes=tmdb_data.get("vote_count", 0),
max_rating=10,
default=True,
))
# --- External IDs ---
external_ids: Dict[str, Any] = tmdb_data.get("external_ids", {})
imdb_id: Optional[str] = external_ids.get("imdb_id")
tvdb_id: Optional[int] = external_ids.get("tvdb_id")
# --- Images ---
thumb_images: List[ImageInfo] = []
fanart_images: List[ImageInfo] = []
if tmdb_data.get("poster_path"):
thumb_images.append(ImageInfo(
url=get_image_url(tmdb_data["poster_path"], image_size),
aspect="poster",
))
if tmdb_data.get("backdrop_path"):
fanart_images.append(ImageInfo(
url=get_image_url(tmdb_data["backdrop_path"], image_size),
))
logos: List[Dict[str, Any]] = tmdb_data.get("images", {}).get("logos", [])
if logos:
thumb_images.append(ImageInfo(
url=get_image_url(logos[0]["file_path"], image_size),
aspect="clearlogo",
))
# --- Cast (top 10) ---
actors: List[ActorInfo] = []
for member in tmdb_data.get("credits", {}).get("cast", [])[:10]:
actor_thumb: Optional[str] = None
if member.get("profile_path"):
actor_thumb = get_image_url(member["profile_path"], "h632")
actors.append(ActorInfo(
name=member["name"],
role=member.get("character"),
thumb=actor_thumb,
tmdbid=member["id"],
))
# --- Named seasons ---
named_seasons: List[NamedSeason] = []
for season_info in tmdb_data.get("seasons", []):
season_name = season_info.get("name")
season_number = season_info.get("season_number")
if season_name and season_number is not None:
named_seasons.append(NamedSeason(
number=season_number,
name=season_name,
))
# --- Unique IDs ---
unique_ids: List[UniqueID] = []
if tmdb_data.get("id"):
unique_ids.append(UniqueID(type="tmdb", value=str(tmdb_data["id"]), default=False))
if imdb_id:
unique_ids.append(UniqueID(type="imdb", value=imdb_id, default=False))
if tvdb_id:
unique_ids.append(UniqueID(type="tvdb", value=str(tvdb_id), default=True))
# --- Content ratings ---
fsk_rating: Optional[str] = _extract_fsk_rating(content_ratings) if content_ratings else None
mpaa_rating: Optional[str] = (
_extract_rating_by_country(content_ratings, "US") if content_ratings else None
)
# --- Country: prefer origin_country codes; fall back to production_countries names ---
country_list: List[str] = list(tmdb_data.get("origin_country", []))
if not country_list:
country_list = [c["name"] for c in tmdb_data.get("production_countries", [])]
# --- Runtime ---
runtime_list: List[int] = tmdb_data.get("episode_run_time", [])
runtime: Optional[int] = runtime_list[0] if runtime_list else None
return TVShowNFO(
title=title,
originaltitle=original_title,
showtitle=title,
sorttitle=title,
year=year,
plot=tmdb_data.get("overview") or None,
outline=tmdb_data.get("overview") or None,
tagline=tmdb_data.get("tagline") or None,
runtime=runtime,
premiered=first_air_date,
status=tmdb_data.get("status"),
genre=[g["name"] for g in tmdb_data.get("genres", [])],
studio=[n["name"] for n in tmdb_data.get("networks", [])],
country=country_list,
ratings=ratings,
fsk=fsk_rating,
mpaa=mpaa_rating,
tmdbid=tmdb_data.get("id"),
imdbid=imdb_id,
tvdbid=tvdb_id,
uniqueid=unique_ids,
thumb=thumb_images,
fanart=fanart_images,
actors=actors,
namedseason=named_seasons,
watched=False,
dateadded=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)

View File

@@ -229,6 +229,37 @@ class DatabaseIntegrityChecker:
logger.warning(msg)
issues_found += count
# Check for invalid progress percentages
stmt = select(DownloadQueueItem).where(
(DownloadQueueItem.progress < 0) |
(DownloadQueueItem.progress > 100)
)
invalid_progress = self.session.execute(stmt).scalars().all()
if invalid_progress:
count = len(invalid_progress)
msg = (
f"Found {count} queue items with invalid progress "
f"percentages"
)
self.issues.append(msg)
logger.warning(msg)
issues_found += count
# Check for queue items with invalid status
valid_statuses = {'pending', 'downloading', 'completed', 'failed'}
stmt = select(DownloadQueueItem).where(
~DownloadQueueItem.status.in_(valid_statuses)
)
invalid_status = self.session.execute(stmt).scalars().all()
if invalid_status:
count = len(invalid_status)
msg = f"Found {count} queue items with invalid status"
self.issues.append(msg)
logger.warning(msg)
issues_found += count
if issues_found == 0:
logger.info("No data consistency issues found")

View File

@@ -4,27 +4,14 @@ from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.entities.series import Serie
from src.server.database.service import AnimeSeriesService
from src.server.exceptions import (
BadRequestError,
NotFoundError,
ServerError,
ValidationError,
)
from src.server.services.anime_service import AnimeService, AnimeServiceError
from src.server.services.background_loader_service import BackgroundLoaderService
from src.server.utils.dependencies import (
get_anime_service,
get_background_loader_service,
get_optional_database_session,
get_series_app,
require_auth,
)
from src.server.utils.filesystem import sanitize_folder_name
from src.server.utils.validators import validate_filter_value, validate_search_query
logger = logging.getLogger(__name__)
@@ -65,8 +52,9 @@ async def get_anime_status(
"series_count": series_count
}
except Exception as exc:
raise ServerError(
message=f"Failed to get status: {str(exc)}"
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get status: {str(exc)}",
) from exc
@@ -85,13 +73,7 @@ class AnimeSummary(BaseModel):
site: Provider site URL
folder: Filesystem folder name (metadata only)
missing_episodes: Episode dictionary mapping seasons to episode numbers
has_missing: Boolean flag indicating if series has missing episodes
link: Optional link to the series page (used when adding new series)
has_nfo: Whether the series has NFO metadata
nfo_created_at: ISO timestamp when NFO was created
nfo_updated_at: ISO timestamp when NFO was last updated
tmdb_id: The Movie Database (TMDB) ID
tvdb_id: TheTVDB ID
"""
key: str = Field(
...,
@@ -113,34 +95,10 @@ class AnimeSummary(BaseModel):
...,
description="Episode dictionary: {season: [episode_numbers]}"
)
has_missing: bool = Field(
default=False,
description="Whether the series has any missing episodes"
)
link: Optional[str] = Field(
default="",
description="Link to the series page (for adding new series)"
)
has_nfo: bool = Field(
default=False,
description="Whether the series has NFO metadata"
)
nfo_created_at: Optional[str] = Field(
default=None,
description="ISO timestamp when NFO was created"
)
nfo_updated_at: Optional[str] = Field(
default=None,
description="ISO timestamp when NFO was last updated"
)
tmdb_id: Optional[int] = Field(
default=None,
description="The Movie Database (TMDB) ID"
)
tvdb_id: Optional[int] = Field(
default=None,
description="TheTVDB ID"
)
class Config:
"""Pydantic model configuration."""
@@ -151,13 +109,7 @@ class AnimeSummary(BaseModel):
"site": "aniworld.to",
"folder": "beheneko the elf girls cat (2025)",
"missing_episodes": {"1": [1, 2, 3, 4]},
"has_missing": True,
"link": "https://aniworld.to/anime/stream/beheneko",
"has_nfo": True,
"nfo_created_at": "2025-01-15T10:30:00Z",
"nfo_updated_at": "2025-01-15T10:30:00Z",
"tmdb_id": 12345,
"tvdb_id": 67890
"link": "https://aniworld.to/anime/stream/beheneko"
}
}
@@ -219,27 +171,22 @@ async def list_anime(
sort_by: Optional[str] = None,
filter: Optional[str] = None,
_auth: dict = Depends(require_auth),
anime_service: AnimeService = Depends(get_anime_service),
series_app: Any = Depends(get_series_app),
) -> List[AnimeSummary]:
"""List all library series with their missing episodes status.
"""List library series that still have missing episodes.
Returns AnimeSummary objects where `key` is the primary identifier
used for all operations. The `folder` field is metadata only and
should not be used for lookups.
All series are returned, with `has_missing` flag indicating whether
a series has any missing episodes.
Args:
page: Page number for pagination (must be positive)
per_page: Items per page (must be positive, max 1000)
sort_by: Optional sorting parameter. Allowed: title, id, name,
missing_episodes
filter: Optional filter parameter. Allowed values:
- "no_episodes": Show only series with no downloaded
episodes in folder
filter: Optional filter parameter (validated for security)
_auth: Ensures the caller is authenticated (value unused)
anime_service: AnimeService instance provided via dependency
series_app: Core SeriesApp instance provided via dependency.
Returns:
List[AnimeSummary]: Summary entries with `key` as primary identifier.
@@ -249,7 +196,6 @@ async def list_anime(
- site: Provider site
- folder: Filesystem folder name (metadata only)
- missing_episodes: Dict mapping seasons to episode numbers
- has_missing: Whether the series has any missing episodes
Raises:
HTTPException: When the underlying lookup fails or params invalid.
@@ -259,30 +205,35 @@ async def list_anime(
try:
page_num = int(page)
if page_num < 1:
raise ValidationError(
message="Page number must be positive"
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Page number must be positive"
)
page = page_num
except (ValueError, TypeError):
raise ValidationError(
message="Page must be a valid number"
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Page must be a valid number"
)
if per_page is not None:
try:
per_page_num = int(per_page)
if per_page_num < 1:
raise ValidationError(
message="Per page must be positive"
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Per page must be positive"
)
if per_page_num > 1000:
raise ValidationError(
message="Per page cannot exceed 1000"
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Per page cannot exceed 1000"
)
per_page = per_page_num
except (ValueError, TypeError):
raise ValidationError(
message="Per page must be a valid number"
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Per page must be a valid number"
)
# Validate sort_by parameter to prevent ORM injection
@@ -291,48 +242,86 @@ async def list_anime(
allowed_sort_fields = ["title", "id", "missing_episodes", "name"]
if sort_by not in allowed_sort_fields:
allowed = ", ".join(allowed_sort_fields)
raise ValidationError(
message=f"Invalid sort_by parameter. Allowed: {allowed}"
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid sort_by parameter. Allowed: {allowed}"
)
# Validate filter parameter
if filter:
try:
allowed_filters = ["no_episodes"]
validate_filter_value(filter, allowed_filters)
except ValueError as e:
raise ValidationError(message=str(e))
# Check for dangerous patterns in filter
dangerous_patterns = [
";", "--", "/*", "*/",
"drop", "delete", "insert", "update"
]
lower_filter = filter.lower()
for pattern in dangerous_patterns:
if pattern in lower_filter:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid filter parameter"
)
try:
# Use AnimeService to get series with metadata from database
series_list = await anime_service.list_series_with_filters(
filter_type=filter
)
# Try to get series from database first
summaries: List[AnimeSummary] = []
for series_dict in series_list:
# Convert episode dict keys to strings for JSON serialization
episode_dict = series_dict.get("episodeDict", {}) or {}
missing_episodes = {str(k): v for k, v in episode_dict.items()}
# Determine if series has missing episodes
has_missing = bool(episode_dict)
try:
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
summaries.append(
AnimeSummary(
key=series_dict["key"],
name=series_dict["name"],
site=series_dict["site"],
folder=series_dict["folder"],
missing_episodes=missing_episodes,
has_missing=has_missing,
has_nfo=series_dict.get("has_nfo", False),
nfo_created_at=series_dict.get("nfo_created_at"),
nfo_updated_at=series_dict.get("nfo_updated_at"),
tmdb_id=series_dict.get("tmdb_id"),
tvdb_id=series_dict.get("tvdb_id"),
async with get_db_session() as db:
db_series = await AnimeSeriesService.get_all(db)
for series in db_series:
episode_dict = series.episode_dict or {}
# Only include series with missing episodes
if episode_dict:
# Ensure episode dict keys are strings
missing_episodes = {
str(k): v for k, v in episode_dict.items()
}
summaries.append(
AnimeSummary(
key=series.key,
name=series.name,
site=series.site,
folder=series.folder,
missing_episodes=missing_episodes,
)
)
logger.debug(
"Loaded %d series from database",
len(summaries)
)
except Exception as db_error:
# Fall back to in-memory series_app if database fails
logger.warning(
"Database query failed, using in-memory fallback: %s",
db_error
)
if series_app and hasattr(series_app, "list"):
series = series_app.list.GetMissingEpisode()
for serie in series:
key = getattr(serie, "key", "")
name = getattr(serie, "name", "")
site = getattr(serie, "site", "")
folder = getattr(serie, "folder", "")
episode_dict = getattr(serie, "episodeDict", {}) or {}
# Convert episode dict keys to strings
missing_episodes = {
str(k): v for k, v in episode_dict.items()
}
summaries.append(
AnimeSummary(
key=key,
name=name,
site=site,
folder=folder,
missing_episodes=missing_episodes,
)
)
# Apply sorting if requested
if sort_by:
@@ -351,11 +340,12 @@ async def list_anime(
)
return summaries
except (ValidationError, BadRequestError, NotFoundError, ServerError):
except HTTPException:
raise
except Exception as exc:
raise ServerError(
message="Failed to retrieve anime list"
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve anime list",
) from exc
@@ -386,40 +376,17 @@ async def trigger_rescan(
"message": "Rescan started successfully",
}
except AnimeServiceError as e:
raise ServerError(
message=f"Rescan failed: {str(e)}"
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Rescan failed: {str(e)}",
) from e
except Exception as exc:
raise ServerError(
message="Failed to start rescan"
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to start rescan",
) from exc
@router.get("/scan/status")
async def get_scan_status(
_auth: dict = Depends(require_auth),
anime_service: AnimeService = Depends(get_anime_service),
) -> dict:
"""Get the current scan status.
Returns the current state of any ongoing library scan,
useful for restoring UI state after page reload.
Args:
_auth: Ensures the caller is authenticated (value unused)
anime_service: AnimeService instance provided via dependency.
Returns:
Dict[str, Any]: Current scan status including:
- is_scanning: Whether a scan is in progress
- total_items: Total items to scan
- directories_scanned: Items scanned so far
- current_directory: Current item being scanned
- directory: Root scan directory
"""
return anime_service.get_scan_status()
class AddSeriesRequest(BaseModel):
"""Request model for adding a new series."""
@@ -427,8 +394,8 @@ class AddSeriesRequest(BaseModel):
name: str
def _validate_search_query_extended(query: str) -> str:
"""Validate and sanitize search query with additional checks.
def validate_search_query(query: str) -> str:
"""Validate and sanitize search query.
Args:
query: The search query string
@@ -459,16 +426,25 @@ def _validate_search_query_extended(query: str) -> str:
detail="Search query too long (max 200 characters)"
)
# Validate and normalize the search query using utility function
try:
normalized = validate_search_query(query)
return normalized
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(e)
)
# Strip and normalize whitespace
normalized = " ".join(query.strip().split())
# Prevent SQL-like injection patterns
dangerous_patterns = [
"--", "/*", "*/", "xp_", "sp_", "exec", "execute",
"union", "select", "insert", "update", "delete", "drop",
"create", "alter", "truncate", "sleep", "waitfor", "benchmark",
" or ", "||", " and ", "&&"
]
lower_query = normalized.lower()
for pattern in dangerous_patterns:
if pattern in lower_query:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid character sequence detected"
)
return normalized
class SearchAnimeRequest(BaseModel):
@@ -557,7 +533,7 @@ async def _perform_search(
"""
try:
# Validate and sanitize the query
validated_query = _validate_search_query_extended(query)
validated_query = validate_search_query(query)
# Check if series_app is available
if not series_app:
@@ -634,31 +610,21 @@ async def _perform_search(
) from exc
@router.post("/add", status_code=status.HTTP_202_ACCEPTED)
@router.post("/add")
async def add_series(
request: AddSeriesRequest,
_auth: dict = Depends(require_auth),
series_app: Any = Depends(get_series_app),
anime_service: AnimeService = Depends(get_anime_service),
db: Optional[AsyncSession] = Depends(get_optional_database_session),
background_loader: BackgroundLoaderService = Depends(get_background_loader_service),
) -> dict:
"""Add a new series to the library with asynchronous data loading.
"""Add a new series to the library.
This endpoint performs immediate series addition and queues background loading:
1. Validates inputs and extracts the series key from the link URL
2. Creates a sanitized folder name from the display name
3. Saves the series to the database with loading_status="pending"
4. Creates the folder on disk with the sanitized name
5. Queues background loading task for episodes, NFO, and images
6. Returns immediately (202 Accepted) without waiting for data loading
Data loading happens asynchronously in the background, with real-time
status updates via WebSocket.
Creates a database entry for the series and also updates
the in-memory cache. The filesystem folder is created for downloads.
Extracts the series `key` from the provided link URL.
The `key` is the URL-safe identifier used for all lookups.
The `name` is stored as display metadata and used to derive
the filesystem folder name (sanitized for filesystem safety).
The `name` is stored as display metadata along with a
filesystem-friendly `folder` name derived from the name.
Args:
request: Request containing the series link and name.
@@ -666,24 +632,21 @@ async def add_series(
- name: Display name for the series
_auth: Ensures the caller is authenticated (value unused)
series_app: Core `SeriesApp` instance provided via dependency
db: Optional database session for async operations
background_loader: BackgroundLoaderService for async data loading
Returns:
Dict[str, Any]: Status payload with:
- status: "success" or "exists"
- message: Human-readable status message
- key: Series unique identifier
- folder: Created folder path
- db_id: Database ID (if saved to DB)
- loading_status: Current loading status
- loading_progress: Dict of what data is being loaded
Dict[str, Any]: Status payload with success message and key
Raises:
HTTPException: If adding the series fails or link is invalid
"""
import os
from src.config.settings import settings
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
try:
# Step A: Validate inputs
# Validate inputs
if not request.link or not request.link.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -716,280 +679,87 @@ async def add_series(
detail="Could not extract series key from link",
)
# Step B: Fetch year from provider and create folder name with year
name = request.name.strip()
# Fetch year from provider
year = None
if series_app and hasattr(series_app, 'loader'):
try:
year = series_app.loader.get_year(key)
logger.info(f"Fetched year for {key}: {year}")
except Exception as e:
logger.warning(f"Could not fetch year for {key}: {e}")
# Create folder name with year if available
if year:
folder_name_with_year = f"{name} ({year})"
else:
folder_name_with_year = name
# Create folder from name (filesystem-friendly)
folder = request.name.strip()
# Try database storage first, fall back to in-memory/file storage
db_stored = False
try:
folder = sanitize_folder_name(folder_name_with_year)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid series name for folder: {str(e)}",
)
async with get_db_session() as db:
# Check if series already exists in database
existing = await AnimeSeriesService.get_by_key(db, key)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Series with key '{key}' already exists",
)
db_id = None
# Step C: Save to database if available
if db is not None:
# Check if series already exists in database
existing = await AnimeSeriesService.get_by_key(db, key)
if existing:
return {
"status": "exists",
"message": f"Series already exists: {name}",
"key": key,
"folder": existing.folder,
"db_id": existing.id,
"loading_status": existing.loading_status,
"loading_progress": {
"episodes": existing.episodes_loaded,
"nfo": existing.has_nfo,
"logo": existing.logo_loaded,
"images": existing.images_loaded
}
}
# Save to database using AnimeSeriesService with loading status
anime_series = await AnimeSeriesService.create(
db=db,
key=key,
name=name,
site="aniworld.to",
folder=folder,
year=year,
loading_status="pending",
episodes_loaded=False,
logo_loaded=False,
images_loaded=False,
loading_started_at=None,
)
db_id = anime_series.id
logger.info(
"Added series to database: %s (key=%s, db_id=%d, year=%s, loading=pending)",
name,
key,
db_id,
year
)
# Step D: Add to SerieList (in-memory only, no folder creation)
if series_app and hasattr(series_app, "list"):
serie = Serie(
key=key,
name=name,
site="aniworld.to",
folder=folder,
episodeDict={},
year=year
)
# Add to in-memory cache without creating folder on disk
if hasattr(series_app.list, 'keyDict'):
series_app.list.keyDict[key] = serie
logger.info(
"Added series to in-memory cache: %s (key=%s, folder=%s, year=%s)",
name,
key,
folder,
year
)
# Step E: Queue background loading task for episodes, NFO, and images
try:
await background_loader.add_series_loading_task(
key=key,
folder=folder,
name=name,
year=year
)
logger.info(
"Queued background loading for %s (key=%s)",
name,
key
)
except Exception as e:
# Background loading queue failure is not critical - series was still added
logger.warning(
"Failed to queue background loading for %s: %s",
key,
e
)
# Step F: Scan missing episodes immediately if background loader is not running
# Uses existing SerieScanner and AnimeService sync to avoid duplicates
try:
loader_running = bool(
background_loader.worker_tasks
and any(not t.done() for t in background_loader.worker_tasks)
)
if (
not loader_running
and series_app
and hasattr(series_app, "serie_scanner")
):
missing_episodes = series_app.serie_scanner.scan_single_series(
# Create database entry
await AnimeSeriesService.create(
db,
key=key,
folder=folder
)
total_missing = sum(
len(eps) for eps in missing_episodes.values()
name=request.name.strip(),
site="aniworld.to",
folder=folder,
episode_dict={},
)
await db.commit()
db_stored = True
logger.info(
"Scanned %d missing episodes for %s",
total_missing,
"Created database entry for series: %s (key=%s)",
request.name,
key
)
# Persist scan results to database (includes episodes)
# scan_single_series updates serie_scanner.keyDict with episodeDict
# sync_single_series_after_scan retrieves from there and saves to DB
await anime_service.sync_single_series_after_scan(key)
except Exception as e:
except HTTPException:
raise
except RuntimeError as db_error:
# Database not initialized - fall back to file storage
logger.warning(
"Failed to scan missing episodes for %s: %s",
key,
e
"Database unavailable, using file storage: %s",
db_error
)
# Step G: Return immediate response (202 Accepted)
response = {
# Create filesystem folder (for downloads)
if settings.anime_directory:
anime_path = os.path.join(settings.anime_directory, folder)
os.makedirs(anime_path, exist_ok=True)
logger.debug("Created folder: %s", anime_path)
# Create Serie object for in-memory cache
serie = Serie(
key=key,
name=request.name.strip(),
site="aniworld.to",
folder=folder,
episodeDict={},
)
# Update in-memory cache and/or file storage
if series_app and hasattr(series_app, "list"):
# If database wasn't available, use file-based storage
if not db_stored:
series_app.list.add(serie)
else:
# Just update in-memory cache
series_app.list.keyDict[key] = serie
logger.debug("Updated in-memory cache for series: %s", key)
return {
"status": "success",
"message": f"Series added successfully: {name}. Data will be loaded in background.",
"message": f"Successfully added series: {request.name}",
"key": key,
"folder": folder,
"db_id": db_id,
"loading_status": "pending",
"loading_progress": {
"episodes": False,
"nfo": False,
"logo": False,
"images": False
}
}
return response
except HTTPException:
raise
except Exception as exc:
logger.error("Failed to add series: %s", exc, exc_info=True)
# Attempt to rollback database entry if folder creation failed
# (This is a best-effort cleanup)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add series: {str(exc)}",
) from exc
@router.get("/{anime_key}/loading-status")
async def get_loading_status(
anime_key: str,
_auth: dict = Depends(require_auth),
db: Optional[AsyncSession] = Depends(get_optional_database_session),
) -> dict:
"""Get current loading status for a series.
Returns the current background loading status including what data
has been loaded and what is still pending.
Args:
anime_key: Series unique identifier (key)
_auth: Ensures the caller is authenticated
db: Optional database session
Returns:
Dict with loading status information:
- key: Series identifier
- loading_status: Current status (pending, loading_*, completed, failed)
- progress: Dict of what data is loaded
- started_at: When loading started
- completed_at: When loading completed (if done)
- message: Human-readable status message
- error: Error message if failed
Raises:
HTTPException: If series not found or database unavailable
"""
if db is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Database not available"
)
try:
from src.server.database.service import AnimeSeriesService
# Get series from database
series = await AnimeSeriesService.get_by_key(db, anime_key)
if not series:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series not found: {anime_key}"
)
# Build status message
message = ""
if series.loading_status == "pending":
message = "Queued for loading..."
elif series.loading_status == "loading_episodes":
message = "Loading episodes..."
elif series.loading_status == "loading_nfo":
message = "Generating NFO file..."
elif series.loading_status == "loading_logo":
message = "Downloading logo..."
elif series.loading_status == "loading_images":
message = "Downloading images..."
elif series.loading_status == "completed":
message = "All data loaded successfully"
elif series.loading_status == "failed":
message = f"Loading failed: {series.loading_error}"
else:
message = "Loading..."
return {
"key": series.key,
"loading_status": series.loading_status,
"progress": {
"episodes": series.episodes_loaded,
"nfo": series.has_nfo,
"logo": series.logo_loaded,
"images": series.images_loaded
},
"started_at": series.loading_started_at.isoformat() if series.loading_started_at else None,
"completed_at": series.loading_completed_at.isoformat() if series.loading_completed_at else None,
"message": message,
"error": series.loading_error
}
except HTTPException:
raise
except Exception as exc:
logger.error("Failed to get loading status: %s", exc, exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get loading status: {str(exc)}"
) from exc
@router.get("/{anime_id}", response_model=AnimeDetail)
async def get_anime(
anime_id: str,
@@ -1081,6 +851,112 @@ async def get_anime(
) from exc
@router.delete("/{anime_key}")
async def delete_series(
anime_key: str,
_auth: dict = Depends(require_auth),
series_app: Any = Depends(get_series_app),
) -> dict:
"""Delete a series from the library.
Removes the series from the database. The `anime_key` should be
the unique series key (provider identifier).
Note: This does NOT delete the filesystem folder or downloaded files.
To remove files, use the filesystem operations separately.
Args:
anime_key: Series key (primary identifier)
_auth: Ensures the caller is authenticated (value unused)
series_app: Core `SeriesApp` instance for cache updates
Returns:
Dict[str, Any]: Status payload with success message
Raises:
HTTPException: If series not found or deletion fails
"""
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
try:
if not anime_key or not anime_key.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Series key cannot be empty",
)
anime_key = anime_key.strip()
series_name = anime_key # Default if not found
db_deleted = False
# Try database deletion first
try:
async with get_db_session() as db:
# Find series by key
series = await AnimeSeriesService.get_by_key(db, anime_key)
if series:
series_name = series.name
series_id = series.id
# Delete from database
deleted = await AnimeSeriesService.delete(db, series_id)
await db.commit()
if deleted:
db_deleted = True
logger.info(
"Deleted series from database: %s (key=%s)",
series_name,
anime_key
)
except RuntimeError as db_error:
# Database not available
logger.warning(
"Database unavailable for deletion: %s",
db_error
)
# Remove from in-memory cache if available
in_memory_deleted = False
if series_app and hasattr(series_app, "list"):
if anime_key in series_app.list.keyDict:
serie = series_app.list.keyDict[anime_key]
series_name = getattr(serie, "name", anime_key)
del series_app.list.keyDict[anime_key]
in_memory_deleted = True
logger.debug(
"Removed series from in-memory cache: %s",
anime_key
)
# Check if anything was deleted
if not db_deleted and not in_memory_deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series with key '{anime_key}' not found",
)
return {
"status": "success",
"message": f"Successfully deleted series: {series_name}",
"key": anime_key,
}
except HTTPException:
raise
except Exception as exc:
logger.error(
"Failed to delete series %s: %s",
anime_key,
exc,
exc_info=True
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete series: {str(exc)}",
) from exc
# Maximum allowed input size for security
MAX_INPUT_LENGTH = 100000 # 100KB

View File

@@ -26,12 +26,11 @@ optional_bearer = HTTPBearer(auto_error=False)
@router.post("/setup", status_code=http_status.HTTP_201_CREATED)
async def setup_auth(req: SetupRequest):
def setup_auth(req: SetupRequest):
"""Initial setup endpoint to configure the master password.
This endpoint also initializes the configuration with all provided values
and saves them to config.json. It triggers background initialization
and redirects to a loading page that shows real-time progress.
This endpoint also initializes the configuration with default values
and saves the anime directory and master password hash.
"""
if auth_service.is_configured():
raise HTTPException(
@@ -45,168 +44,30 @@ async def setup_auth(req: SetupRequest):
req.master_password
)
# Initialize or update config with all provided values
# Initialize or update config with master password hash
# and anime directory
config_service = get_config_service()
try:
config = config_service.load_config()
except Exception:
# If config doesn't exist, create default
from src.server.models.config import (
BackupConfig,
LoggingConfig,
NFOConfig,
SchedulerConfig,
)
config = AppConfig()
# Update basic settings
if req.name:
config.name = req.name
if req.data_dir:
config.data_dir = req.data_dir
# Update scheduler configuration
if req.scheduler_enabled is not None:
config.scheduler.enabled = req.scheduler_enabled
if req.scheduler_interval_minutes is not None:
config.scheduler.interval_minutes = req.scheduler_interval_minutes
if req.scheduler_schedule_time is not None:
config.scheduler.schedule_time = req.scheduler_schedule_time
if req.scheduler_schedule_days is not None:
config.scheduler.schedule_days = req.scheduler_schedule_days
if req.scheduler_auto_download_after_rescan is not None:
config.scheduler.auto_download_after_rescan = req.scheduler_auto_download_after_rescan
# Update logging configuration
if req.logging_level:
config.logging.level = req.logging_level.upper()
if req.logging_file is not None:
config.logging.file = req.logging_file
if req.logging_max_bytes is not None:
config.logging.max_bytes = req.logging_max_bytes
if req.logging_backup_count is not None:
config.logging.backup_count = req.logging_backup_count
# Update backup configuration
if req.backup_enabled is not None:
config.backup.enabled = req.backup_enabled
if req.backup_path:
config.backup.path = req.backup_path
if req.backup_keep_days is not None:
config.backup.keep_days = req.backup_keep_days
# Update NFO configuration
if req.nfo_tmdb_api_key is not None:
config.nfo.tmdb_api_key = req.nfo_tmdb_api_key
if req.nfo_auto_create is not None:
config.nfo.auto_create = req.nfo_auto_create
if req.nfo_update_on_scan is not None:
config.nfo.update_on_scan = req.nfo_update_on_scan
if req.nfo_download_poster is not None:
config.nfo.download_poster = req.nfo_download_poster
if req.nfo_download_logo is not None:
config.nfo.download_logo = req.nfo_download_logo
if req.nfo_download_fanart is not None:
config.nfo.download_fanart = req.nfo_download_fanart
if req.nfo_image_size:
config.nfo.image_size = req.nfo_image_size.lower()
# Store master password hash in config's other field
config.other['master_password_hash'] = password_hash
# Store anime directory in config's other field if provided
anime_directory = None
if req.anime_directory:
anime_directory = req.anime_directory.strip()
if anime_directory:
config.other['anime_directory'] = anime_directory
if hasattr(req, 'anime_directory') and req.anime_directory:
config.other['anime_directory'] = req.anime_directory
# Save the config with all updates
# Save the config with the password hash and anime directory
config_service.save_config(config, create_backup=False)
# Sync config.json values to settings object
# (mirroring the logic in fastapi_app.py lifespan)
from src.config.settings import settings
other_settings = dict(config.other) if config.other else {}
if other_settings.get("anime_directory"):
settings.anime_directory = str(other_settings["anime_directory"])
if config.nfo:
if config.nfo.tmdb_api_key:
settings.tmdb_api_key = config.nfo.tmdb_api_key
settings.nfo_auto_create = config.nfo.auto_create
settings.nfo_update_on_scan = config.nfo.update_on_scan
settings.nfo_download_poster = config.nfo.download_poster
settings.nfo_download_logo = config.nfo.download_logo
settings.nfo_download_fanart = config.nfo.download_fanart
settings.nfo_image_size = config.nfo.image_size
# Trigger initialization in background task
import asyncio
from src.server.services.initialization_service import (
perform_initial_setup,
perform_nfo_scan_if_needed,
)
from src.server.services.progress_service import get_progress_service
progress_service = get_progress_service()
async def run_initialization():
"""Run initialization steps with progress updates."""
try:
# Perform the initial series sync and mark as completed
await perform_initial_setup(progress_service)
# Perform NFO scan if configured
await perform_nfo_scan_if_needed(progress_service)
# Send completion event
from src.server.services.progress_service import ProgressType
await progress_service.start_progress(
progress_id="initialization_complete",
progress_type=ProgressType.SYSTEM,
title="Initialization Complete",
total=100,
message="All initialization tasks completed successfully",
metadata={"initialization_complete": True}
)
await progress_service.complete_progress(
progress_id="initialization_complete",
message="All initialization tasks completed successfully",
metadata={"initialization_complete": True}
)
except Exception as e:
# Send error event
from src.server.services.progress_service import ProgressType
await progress_service.start_progress(
progress_id="initialization_error",
progress_type=ProgressType.ERROR,
title="Initialization Failed",
total=100,
message=str(e),
metadata={"initialization_complete": True, "error": str(e)}
)
await progress_service.fail_progress(
progress_id="initialization_error",
error_message=str(e),
metadata={"initialization_complete": True, "error": str(e)}
)
# Start initialization in background
asyncio.create_task(run_initialization())
# Return redirect to loading page
return {"status": "ok", "redirect": "/loading"}
# Note: Media scan is skipped during setup as it requires
# background_loader service which is only available during
# application lifespan. It will run on first application startup.
return {"status": "ok"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"status": "ok"}
@router.post("/login", response_model=LoginResponse)
def login(req: LoginRequest):

View File

@@ -1,4 +1,4 @@
from typing import Any, Dict, List, Optional
from typing import Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
@@ -210,10 +210,10 @@ def update_advanced_config(
) from e
@router.post("/directory", response_model=Dict[str, Any])
async def update_directory(
@router.post("/directory", response_model=Dict[str, str])
def update_directory(
directory_config: Dict[str, str], auth: dict = Depends(require_auth)
) -> Dict[str, Any]:
) -> Dict[str, str]:
"""Update anime directory configuration.
Args:
@@ -235,37 +235,13 @@ async def update_directory(
app_config = config_service.load_config()
# Store directory in other section
app_config.other["anime_directory"] = directory
if "anime_directory" not in app_config.other:
app_config.other["anime_directory"] = directory
else:
app_config.other["anime_directory"] = directory
config_service.save_config(app_config)
# Sync series from data files to database
sync_count = 0
try:
import structlog
from src.server.services.anime_service import sync_series_from_data_files
logger = structlog.get_logger(__name__)
sync_count = await sync_series_from_data_files(directory, logger)
logger.info(
"Directory updated: synced series from data files",
directory=directory,
count=sync_count
)
except Exception as e:
# Log but don't fail the directory update if sync fails
import structlog
structlog.get_logger(__name__).warning(
"Failed to sync series after directory update",
error=str(e)
)
response: Dict[str, Any] = {
"message": "Anime directory updated successfully",
"synced_series": sync_count
}
return response
return {"message": "Anime directory updated successfully"}
except ConfigServiceError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -371,59 +347,3 @@ def reset_config(
detail=f"Failed to reset config: {e}"
) from e
@router.post("/tmdb/validate", response_model=Dict[str, Any])
async def validate_tmdb_key(
api_key_data: Dict[str, str], auth: dict = Depends(require_auth)
) -> Dict[str, Any]:
"""Validate TMDB API key by making a test request.
Args:
api_key_data: Dictionary with 'api_key' field
auth: Authentication token (required)
Returns:
Validation result with success status and message
"""
import aiohttp
api_key = api_key_data.get("api_key", "").strip()
if not api_key:
return {
"valid": False,
"message": "API key is required"
}
try:
# Test the API key with a simple configuration request
url = f"https://api.themoviedb.org/3/configuration?api_key={api_key}"
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=timeout) as response:
if response.status == 200:
return {
"valid": True,
"message": "TMDB API key is valid"
}
elif response.status == 401:
return {
"valid": False,
"message": "Invalid API key"
}
else:
return {
"valid": False,
"message": f"TMDB API error: {response.status}"
}
except aiohttp.ClientError as e:
return {
"valid": False,
"message": f"Connection error: {str(e)}"
}
except Exception as e:
return {
"valid": False,
"message": f"Validation error: {str(e)}"
}

View File

@@ -4,10 +4,9 @@ This module provides REST API endpoints for managing the anime download queue,
including adding episodes, removing items, controlling queue processing, and
retrieving queue status and statistics.
"""
from fastapi import APIRouter, Depends, Path, status
from fastapi import APIRouter, Depends, HTTPException, Path, status
from fastapi.responses import JSONResponse
from src.server.exceptions import BadRequestError, NotFoundError, ServerError
from src.server.models.download import (
DownloadRequest,
QueueOperationRequest,
@@ -53,8 +52,9 @@ async def get_queue_status(
return response
except Exception as e:
raise ServerError(
message=f"Failed to retrieve queue status: {str(e)}"
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to retrieve queue status: {str(e)}",
)
@@ -91,8 +91,9 @@ async def add_to_queue(
try:
# Validate request
if not request.episodes:
raise BadRequestError(
message="At least one episode must be specified"
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one episode must be specified",
)
# Add to queue
@@ -121,12 +122,16 @@ async def add_to_queue(
)
except DownloadServiceError as e:
raise BadRequestError(message=str(e))
except (BadRequestError, NotFoundError, ServerError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except HTTPException:
raise
except Exception as e:
raise ServerError(
message=f"Failed to add episodes to queue: {str(e)}"
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add episodes to queue: {str(e)}",
)
@@ -158,8 +163,9 @@ async def clear_completed(
}
except Exception as e:
raise ServerError(
message=f"Failed to clear completed items: {str(e)}"
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to clear completed items: {str(e)}",
)
@@ -191,8 +197,9 @@ async def clear_failed(
}
except Exception as e:
raise ServerError(
message=f"Failed to clear failed items: {str(e)}"
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to clear failed items: {str(e)}",
)
@@ -224,8 +231,9 @@ async def clear_pending(
}
except Exception as e:
raise ServerError(
message=f"Failed to clear pending items: {str(e)}"
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to clear pending items: {str(e)}",
)
@@ -254,19 +262,22 @@ async def remove_from_queue(
removed_ids = await download_service.remove_from_queue([item_id])
if not removed_ids:
raise NotFoundError(
message=f"Download item {item_id} not found in queue",
resource_type="download_item",
resource_id=item_id
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Download item {item_id} not found in queue",
)
except DownloadServiceError as e:
raise BadRequestError(message=str(e))
except (BadRequestError, NotFoundError, ServerError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except HTTPException:
raise
except Exception as e:
raise ServerError(
message=f"Failed to remove item from queue: {str(e)}"
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to remove item from queue: {str(e)}",
)
@@ -296,18 +307,22 @@ async def remove_multiple_from_queue(
)
if not removed_ids:
raise NotFoundError(
message="No matching items found in queue",
resource_type="download_items"
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No matching items found in queue",
)
except DownloadServiceError as e:
raise BadRequestError(message=str(e))
except (BadRequestError, NotFoundError, ServerError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except HTTPException:
raise
except Exception as e:
raise ServerError(
message=f"Failed to remove items from queue: {str(e)}"
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to remove items from queue: {str(e)}",
)
@@ -339,8 +354,9 @@ async def start_queue(
result = await download_service.start_queue_processing()
if result is None:
raise BadRequestError(
message="No pending downloads in queue"
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No pending downloads in queue",
)
return {
@@ -349,12 +365,16 @@ async def start_queue(
}
except DownloadServiceError as e:
raise BadRequestError(message=str(e))
except (BadRequestError, NotFoundError, ServerError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except HTTPException:
raise
except Exception as e:
raise ServerError(
message=f"Failed to start queue processing: {str(e)}"
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to start queue processing: {str(e)}",
)
@@ -388,8 +408,9 @@ async def stop_queue(
}
except Exception as e:
raise ServerError(
message=f"Failed to stop queue processing: {str(e)}"
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to stop queue processing: {str(e)}",
)
@@ -421,8 +442,9 @@ async def pause_queue(
}
except Exception as e:
raise ServerError(
message=f"Failed to pause queue processing: {str(e)}"
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to pause queue processing: {str(e)}",
)
@@ -458,8 +480,9 @@ async def reorder_queue(
}
except Exception as e:
raise ServerError(
message=f"Failed to reorder queue: {str(e)}"
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to reorder queue: {str(e)}",
)
@@ -499,6 +522,7 @@ async def retry_failed(
}
except Exception as e:
raise ServerError(
message=f"Failed to retry downloads: {str(e)}"
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to retry downloads: {str(e)}",
)

View File

@@ -23,9 +23,6 @@ class HealthStatus(BaseModel):
status: str
timestamp: str
version: str = "1.0.0"
service: str = "aniworld-api"
series_app_initialized: bool = False
anime_directory_configured: bool = False
class DatabaseHealth(BaseModel):
@@ -174,23 +171,13 @@ def get_system_metrics() -> SystemMetrics:
async def basic_health_check() -> HealthStatus:
"""Basic health check endpoint.
This endpoint does not depend on anime_directory configuration
and should always return 200 OK for basic health monitoring.
Includes service information for identification.
Returns:
HealthStatus: Simple health status with timestamp and service info.
HealthStatus: Simple health status with timestamp.
"""
from src.config.settings import settings
from src.server.utils.dependencies import _series_app
logger.debug("Basic health check requested")
return HealthStatus(
status="healthy",
timestamp=datetime.now().isoformat(),
service="aniworld-api",
series_app_initialized=_series_app is not None,
anime_directory_configured=bool(settings.anime_directory),
)

View File

@@ -1,230 +0,0 @@
"""Logging API endpoints for AniWorld.
Provides endpoints for reading log configuration, listing log files,
tailing/downloading individual log files, testing logging, and cleanup.
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import FileResponse
from src.server.services.config_service import get_config_service
from src.server.utils.dependencies import require_auth
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/logging", tags=["logging"])
_LOG_DIR = Path("logs")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _log_dir() -> Path:
"""Return the log directory, creating it if necessary."""
_LOG_DIR.mkdir(exist_ok=True)
return _LOG_DIR
def _list_log_files() -> List[Dict[str, Any]]:
"""Return metadata for all .log files in the log directory."""
result: List[Dict[str, Any]] = []
log_dir = _log_dir()
for entry in sorted(log_dir.iterdir()):
if entry.is_file() and entry.suffix in {".log", ".txt"}:
stat = entry.stat()
result.append(
{
"name": entry.name,
"size_mb": round(stat.st_size / (1024 * 1024), 2),
"modified": stat.st_mtime,
}
)
return result
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/config")
def get_logging_config(
auth: Optional[dict] = Depends(require_auth),
) -> Dict[str, Any]:
"""Return current logging configuration as used by the frontend.
Maps the internal ``LoggingConfig`` model fields to the shape expected
by ``logging-config.js``.
"""
try:
config_service = get_config_service()
app_config = config_service.load_config()
lc = app_config.logging
return {
"success": True,
"config": {
# Primary fields (match the model)
"log_level": lc.level,
"log_file": lc.file,
"max_bytes": lc.max_bytes,
"backup_count": lc.backup_count,
# UI-only flags defaults; not yet persisted in the model
"enable_console_logging": True,
"enable_console_progress": False,
"enable_fail2ban_logging": False,
},
}
except Exception as exc:
logger.exception("Failed to read logging config")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to read logging config: {exc}",
) from exc
@router.get("/files")
def list_files(
auth: Optional[dict] = Depends(require_auth),
) -> Dict[str, Any]:
"""List all available log files with metadata."""
try:
return {"success": True, "files": _list_log_files()}
except Exception as exc:
logger.exception("Failed to list log files")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to list log files: {exc}",
) from exc
@router.get("/files/{filename}/tail")
def tail_file(
filename: str,
lines: int = 100,
auth: Optional[dict] = Depends(require_auth),
) -> Dict[str, Any]:
"""Return the last *lines* lines of a log file.
Args:
filename: Name of the log file (no path traversal).
lines: Number of lines to return (default 100).
Returns:
Dict with ``success``, ``lines``, ``showing_lines``, ``total_lines``.
"""
# Prevent path traversal
safe_name = Path(filename).name
file_path = _log_dir() / safe_name
if not file_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Log file not found: {safe_name}",
)
try:
all_lines = file_path.read_text(encoding="utf-8", errors="replace").splitlines()
tail = all_lines[-lines:] if len(all_lines) > lines else all_lines
return {
"success": True,
"lines": tail,
"showing_lines": len(tail),
"total_lines": len(all_lines),
}
except Exception as exc:
logger.exception("Failed to tail log file %s", safe_name)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to read log file: {exc}",
) from exc
@router.get("/files/{filename}/download")
def download_file(
filename: str,
auth: Optional[dict] = Depends(require_auth),
) -> FileResponse:
"""Download a log file as an attachment."""
safe_name = Path(filename).name
file_path = _log_dir() / safe_name
if not file_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Log file not found: {safe_name}",
)
return FileResponse(
path=str(file_path),
filename=safe_name,
media_type="text/plain",
)
@router.post("/test")
def test_logging(
auth: dict = Depends(require_auth),
) -> Dict[str, Any]:
"""Write test log messages at all levels."""
logging.getLogger("aniworld.test").debug("Test DEBUG message")
logging.getLogger("aniworld.test").info("Test INFO message")
logging.getLogger("aniworld.test").warning("Test WARNING message")
logging.getLogger("aniworld.test").error("Test ERROR message")
return {"success": True, "message": "Test messages written to log"}
@router.post("/cleanup")
def cleanup_logs(
payload: Dict[str, Any],
auth: dict = Depends(require_auth),
) -> Dict[str, Any]:
"""Delete log files older than *days* days.
Args:
payload: JSON body with ``days`` (int) field.
Returns:
Dict with ``success`` and ``message`` describing what was deleted.
"""
import time
days = payload.get("days", 30)
try:
days = int(days)
if days < 1:
raise ValueError("days must be >= 1")
except (TypeError, ValueError) as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid days value: {exc}",
) from exc
cutoff = time.time() - days * 86400
removed: List[str] = []
errors: List[str] = []
for entry in _log_dir().iterdir():
if entry.is_file() and entry.suffix in {".log", ".txt"}:
if entry.stat().st_mtime < cutoff:
try:
entry.unlink()
removed.append(entry.name)
except OSError as exc:
errors.append(f"{entry.name}: {exc}")
message = f"Removed {len(removed)} file(s) older than {days} days."
if errors:
message += f" Errors: {'; '.join(errors)}"
logger.info(
"Log cleanup by %s: removed=%s days=%s",
auth.get("username", "unknown"),
removed,
days,
)
return {"success": True, "message": message, "removed": removed}

View File

@@ -1,758 +0,0 @@
"""NFO Management API endpoints.
This module provides REST API endpoints for managing tvshow.nfo files
and associated media (poster, logo, fanart).
"""
import asyncio
import logging
from datetime import datetime
from pathlib import Path
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from src.config.settings import settings
from src.core.entities.series import Serie
from src.core.SeriesApp import SeriesApp
from src.core.services.nfo_factory import get_nfo_factory
from src.core.services.nfo_service import NFOService
from src.core.services.tmdb_client import TMDBAPIError
from src.server.models.nfo import (
MediaDownloadRequest,
MediaFilesStatus,
NFOBatchCreateRequest,
NFOBatchCreateResponse,
NFOBatchResult,
NFOCheckResponse,
NFOContentResponse,
NFOCreateRequest,
NFOCreateResponse,
NFOMissingResponse,
NFOMissingSeries,
)
from src.server.utils.dependencies import get_series_app, require_auth
from src.server.utils.media import check_media_files, get_media_file_paths
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/nfo", tags=["nfo"])
async def get_nfo_service() -> NFOService:
"""Get NFO service dependency.
Returns:
NFOService instance
Raises:
HTTPException: If NFO service not configured
"""
try:
# Use centralized factory for consistent initialization
factory = get_nfo_factory()
return factory.create()
except ValueError as e:
# Factory raises ValueError if API key not configured
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(e)
) from e
# =============================================================================
# IMPORTANT: Literal path routes must be defined BEFORE path parameter routes
# to avoid route matching conflicts. For example, /batch/create must come
# before /{serie_id}/create, otherwise "batch" is treated as a serie_id.
# =============================================================================
@router.post("/batch/create", response_model=NFOBatchCreateResponse)
async def batch_create_nfo(
request: NFOBatchCreateRequest,
_auth: dict = Depends(require_auth),
series_app: SeriesApp = Depends(get_series_app),
nfo_service: NFOService = Depends(get_nfo_service)
) -> NFOBatchCreateResponse:
"""Batch create NFO files for multiple series.
Args:
request: Batch creation options
_auth: Authentication dependency
series_app: Series app dependency
nfo_service: NFO service dependency
Returns:
NFOBatchCreateResponse with results
"""
results: List[NFOBatchResult] = []
successful = 0
failed = 0
skipped = 0
# Get all series
series_list = series_app.list.GetList()
series_map = {
getattr(s, 'key', None): s
for s in series_list
if getattr(s, 'key', None)
}
# Process each series
semaphore = asyncio.Semaphore(request.max_concurrent)
async def process_serie(serie_id: str) -> NFOBatchResult:
"""Process a single series."""
async with semaphore:
try:
serie = series_map.get(serie_id)
if not serie:
return NFOBatchResult(
serie_id=serie_id,
serie_folder="",
success=False,
message="Series not found"
)
# Ensure folder name includes year if available
serie_folder = serie.ensure_folder_with_year()
# Check if NFO exists
if request.skip_existing:
has_nfo = await nfo_service.check_nfo_exists(serie_folder)
if has_nfo:
return NFOBatchResult(
serie_id=serie_id,
serie_folder=serie_folder,
success=False,
message="Skipped - NFO already exists"
)
# Create NFO
nfo_path = await nfo_service.create_tvshow_nfo(
serie_name=serie.name or serie_folder,
serie_folder=serie_folder,
download_poster=request.download_media,
download_logo=request.download_media,
download_fanart=request.download_media
)
return NFOBatchResult(
serie_id=serie_id,
serie_folder=serie_folder,
success=True,
message="NFO created successfully",
nfo_path=str(nfo_path)
)
except Exception as e:
logger.error(
f"Error creating NFO for {serie_id}: {e}",
exc_info=True
)
return NFOBatchResult(
serie_id=serie_id,
serie_folder=serie.folder if serie else "",
success=False,
message=f"Error: {str(e)}"
)
# Process all series concurrently
tasks = [process_serie(sid) for sid in request.serie_ids]
results = await asyncio.gather(*tasks)
# Count results
for result in results:
if result.success:
successful += 1
elif "Skipped" in result.message:
skipped += 1
else:
failed += 1
return NFOBatchCreateResponse(
total=len(request.serie_ids),
successful=successful,
failed=failed,
skipped=skipped,
results=list(results)
)
@router.get("/missing", response_model=NFOMissingResponse)
async def get_missing_nfo(
_auth: dict = Depends(require_auth),
series_app: SeriesApp = Depends(get_series_app),
nfo_service: NFOService = Depends(get_nfo_service)
) -> NFOMissingResponse:
"""Get list of series without NFO files.
Args:
_auth: Authentication dependency
series_app: Series app dependency
nfo_service: NFO service dependency
Returns:
NFOMissingResponse with series list
"""
try:
series_list = series_app.list.GetList()
missing_series: List[NFOMissingSeries] = []
for serie in series_list:
serie_id = getattr(serie, 'key', None)
if not serie_id:
continue
# Ensure folder name includes year if available
serie_folder = serie.ensure_folder_with_year()
has_nfo = await nfo_service.check_nfo_exists(serie_folder)
if not has_nfo:
# Build full path and check media files
folder_path = Path(settings.anime_directory) / serie_folder
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
)
has_media = (
media_files.has_poster
or media_files.has_logo
or media_files.has_fanart
)
missing_series.append(NFOMissingSeries(
serie_id=serie_id,
serie_folder=serie_folder,
serie_name=serie.name or serie_folder,
has_media=has_media,
media_files=media_files
))
return NFOMissingResponse(
total_series=len(series_list),
missing_nfo_count=len(missing_series),
series=missing_series
)
except Exception as e:
logger.error(f"Error getting missing NFOs: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get missing NFOs: {str(e)}"
) from e
# =============================================================================
# Series-specific endpoints (with {serie_id} path parameter)
# These must come AFTER literal path routes like /batch/create and /missing
# =============================================================================
@router.get("/{serie_id}/check", response_model=NFOCheckResponse)
async def check_nfo(
serie_id: str,
_auth: dict = Depends(require_auth),
series_app: SeriesApp = Depends(get_series_app),
nfo_service: NFOService = Depends(get_nfo_service)
) -> NFOCheckResponse:
"""Check if NFO and media files exist for a series.
Args:
serie_id: Series identifier
_auth: Authentication dependency
series_app: Series app dependency
nfo_service: NFO service dependency
Returns:
NFOCheckResponse with NFO and media status
Raises:
HTTPException: If series not found
"""
try:
# Get series info
series_list = series_app.list.GetList()
serie = next(
(s for s in series_list if getattr(s, 'key', None) == serie_id),
None
)
if not serie:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series not found: {serie_id}"
)
# Ensure folder name includes year if available
serie_folder = serie.ensure_folder_with_year()
folder_path = Path(settings.anime_directory) / serie_folder
# Check NFO
has_nfo = await nfo_service.check_nfo_exists(serie_folder)
nfo_path = None
if has_nfo:
nfo_path = str(folder_path / "tvshow.nfo")
# Check media files using utility function
media_status = check_media_files(
folder_path,
check_poster=True,
check_logo=True,
check_fanart=True,
check_nfo=False # Already checked above
)
# Get file paths
file_paths = get_media_file_paths(folder_path)
# Build MediaFilesStatus model
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["poster"] else None,
logo_path=str(file_paths["logo"]) if file_paths["logo"] else None,
fanart_path=str(file_paths["fanart"]) if file_paths["fanart"] else None
)
return NFOCheckResponse(
serie_id=serie_id,
serie_folder=serie_folder,
has_nfo=has_nfo,
nfo_path=nfo_path,
media_files=media_files
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error checking NFO for {serie_id}: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to check NFO: {str(e)}"
) from e
@router.post("/{serie_id}/create", response_model=NFOCreateResponse)
async def create_nfo(
serie_id: str,
request: NFOCreateRequest,
_auth: dict = Depends(require_auth),
series_app: SeriesApp = Depends(get_series_app),
nfo_service: NFOService = Depends(get_nfo_service)
) -> NFOCreateResponse:
"""Create NFO file and download media for a series.
Args:
serie_id: Series identifier
request: NFO creation options
_auth: Authentication dependency
series_app: Series app dependency
nfo_service: NFO service dependency
Returns:
NFOCreateResponse with creation result
Raises:
HTTPException: If series not found or creation fails
"""
try:
# Get series info
series_list = series_app.list.GetList()
serie = next(
(s for s in series_list if getattr(s, 'key', None) == serie_id),
None
)
if not serie:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series not found: {serie_id}"
)
# Ensure folder name includes year if available
serie_folder = serie.ensure_folder_with_year()
# If year not provided in request but serie has year, use it
year = request.year or serie.year
# Check if NFO already exists
if not request.overwrite_existing:
has_nfo = await nfo_service.check_nfo_exists(serie_folder)
if has_nfo:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="NFO already exists. Use overwrite_existing=true"
)
# Create NFO
serie_name = request.serie_name or serie.name or serie_folder
nfo_path = await nfo_service.create_tvshow_nfo(
serie_name=serie_name,
serie_folder=serie_folder,
year=year,
download_poster=request.download_poster,
download_logo=request.download_logo,
download_fanart=request.download_fanart
)
# Check media files
folder_path = Path(settings.anime_directory) / serie_folder
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="NFO and media files created successfully"
)
except HTTPException:
raise
except TMDBAPIError as e:
logger.warning(f"TMDB API error creating NFO for {serie_id}: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"TMDB API error: {str(e)}"
) from e
except Exception as e:
logger.error(
f"Error creating NFO for {serie_id}: {e}",
exc_info=True
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create NFO: {str(e)}"
) from e
@router.put("/{serie_id}/update", response_model=NFOCreateResponse)
async def update_nfo(
serie_id: str,
download_media: bool = True,
_auth: dict = Depends(require_auth),
series_app: SeriesApp = Depends(get_series_app),
nfo_service: NFOService = Depends(get_nfo_service)
) -> NFOCreateResponse:
"""Update existing NFO file with fresh TMDB data.
Args:
serie_id: Series identifier
download_media: Whether to re-download media files
_auth: Authentication dependency
series_app: Series app dependency
nfo_service: NFO service dependency
Returns:
NFOCreateResponse with update result
Raises:
HTTPException: If series or NFO not found
"""
try:
# Get series info
series_list = series_app.list.GetList()
serie = next(
(s for s in series_list if getattr(s, 'key', None) == serie_id),
None
)
if not serie:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series not found: {serie_id}"
)
# Ensure folder name includes year if available
serie_folder = serie.ensure_folder_with_year()
# Check if NFO exists
has_nfo = await nfo_service.check_nfo_exists(serie_folder)
if not has_nfo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="NFO file not found. Use create endpoint instead."
)
# Update NFO
nfo_path = await nfo_service.update_tvshow_nfo(
serie_folder=serie_folder,
download_media=download_media
)
# Check media files
folder_path = Path(settings.anime_directory) / serie_folder
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="NFO updated successfully"
)
except HTTPException:
raise
except TMDBAPIError as e:
logger.warning(f"TMDB API error updating NFO for {serie_id}: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"TMDB API error: {str(e)}"
) from e
except Exception as e:
logger.error(
f"Error updating NFO for {serie_id}: {e}",
exc_info=True
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update NFO: {str(e)}"
) from e
@router.get("/{serie_id}/content", response_model=NFOContentResponse)
async def get_nfo_content(
serie_id: str,
_auth: dict = Depends(require_auth),
series_app: SeriesApp = Depends(get_series_app),
nfo_service: NFOService = Depends(get_nfo_service)
) -> NFOContentResponse:
"""Get NFO file content for a series.
Args:
serie_id: Series identifier
_auth: Authentication dependency
series_app: Series app dependency
nfo_service: NFO service dependency
Returns:
NFOContentResponse with NFO content
Raises:
HTTPException: If series or NFO not found
"""
try:
# Get series info
series_list = series_app.list.GetList()
serie = next(
(s for s in series_list if getattr(s, 'key', None) == serie_id),
None
)
if not serie:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series not found: {serie_id}"
)
# Ensure folder name includes year if available
serie_folder = serie.ensure_folder_with_year()
# Check if NFO exists
nfo_path = (
Path(settings.anime_directory) / serie_folder / "tvshow.nfo"
)
if not nfo_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="NFO file not found"
)
# Read NFO content
content = nfo_path.read_text(encoding="utf-8")
file_size = nfo_path.stat().st_size
last_modified = datetime.fromtimestamp(nfo_path.stat().st_mtime)
return NFOContentResponse(
serie_id=serie_id,
serie_folder=serie_folder,
content=content,
file_size=file_size,
last_modified=last_modified
)
except HTTPException:
raise
except Exception as e:
logger.error(
f"Error reading NFO content for {serie_id}: {e}",
exc_info=True
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to read NFO content: {str(e)}"
) from e
@router.get("/{serie_id}/media/status", response_model=MediaFilesStatus)
async def get_media_status(
serie_id: str,
_auth: dict = Depends(require_auth),
series_app: SeriesApp = Depends(get_series_app)
) -> MediaFilesStatus:
"""Get media files status for a series.
Args:
serie_id: Series identifier
_auth: Authentication dependency
series_app: Series app dependency
Returns:
MediaFilesStatus with file existence info
Raises:
HTTPException: If series not found
"""
try:
# Get series info
series_list = series_app.list.GetList()
serie = next(
(s for s in series_list if getattr(s, 'key', None) == serie_id),
None
)
if not serie:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series not found: {serie_id}"
)
# Build full path and check media files
folder_path = Path(settings.anime_directory) / serie.folder
media_status = check_media_files(folder_path)
file_paths = get_media_file_paths(folder_path)
return 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
)
except HTTPException:
raise
except Exception as e:
logger.error(
f"Error checking media status for {serie_id}: {e}",
exc_info=True
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to check media status: {str(e)}"
) from e
@router.post("/{serie_id}/media/download", response_model=MediaFilesStatus)
async def download_media(
serie_id: str,
request: MediaDownloadRequest,
_auth: dict = Depends(require_auth),
series_app: SeriesApp = Depends(get_series_app),
nfo_service: NFOService = Depends(get_nfo_service)
) -> MediaFilesStatus:
"""Download missing media files for a series.
Args:
serie_id: Series identifier
request: Media download options
_auth: Authentication dependency
series_app: Series app dependency
nfo_service: NFO service dependency
Returns:
MediaFilesStatus after download attempt
Raises:
HTTPException: If series or NFO not found
"""
try:
# Get series info
series_list = series_app.list.GetList()
serie = next(
(s for s in series_list if getattr(s, 'key', None) == serie_id),
None
)
if not serie:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Series not found: {serie_id}"
)
# Ensure folder name includes year if available
serie_folder = serie.ensure_folder_with_year()
# Check if NFO exists (needed for TMDB ID)
has_nfo = await nfo_service.check_nfo_exists(serie_folder)
if not has_nfo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="NFO required for media download. Create NFO first."
)
# For now, update NFO which will re-download media
# In future, could add standalone media download
if (request.download_poster or request.download_logo
or request.download_fanart):
await nfo_service.update_tvshow_nfo(
serie_folder=serie_folder,
download_media=True
)
# Build full path and check media files
folder_path = Path(settings.anime_directory) / serie_folder
media_status = check_media_files(folder_path)
file_paths = get_media_file_paths(folder_path)
return 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
)
except HTTPException:
raise
except Exception as e:
logger.error(
f"Error downloading media for {serie_id}: {e}",
exc_info=True
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to download media: {str(e)}"
) from e

View File

@@ -4,13 +4,12 @@ This module provides endpoints for managing scheduled tasks such as
automatic anime library rescans.
"""
import logging
from typing import Any, Dict, Optional
from typing import Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from src.server.models.config import SchedulerConfig
from src.server.services.config_service import ConfigServiceError, get_config_service
from src.server.services.scheduler_service import get_scheduler_service
from src.server.utils.dependencies import require_auth
logger = logging.getLogger(__name__)
@@ -18,105 +17,78 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/scheduler", tags=["scheduler"])
def _build_response(config: SchedulerConfig) -> Dict[str, Any]:
"""Build a standardised GET/POST response combining config + runtime status."""
scheduler_service = get_scheduler_service()
runtime = scheduler_service.get_status()
return {
"success": True,
"config": {
"enabled": config.enabled,
"interval_minutes": config.interval_minutes,
"schedule_time": config.schedule_time,
"schedule_days": config.schedule_days,
"auto_download_after_rescan": config.auto_download_after_rescan,
},
"status": {
"is_running": runtime.get("is_running", False),
"next_run": runtime.get("next_run"),
"last_run": runtime.get("last_run"),
"scan_in_progress": runtime.get("scan_in_progress", False),
},
}
@router.get("/config")
@router.get("/config", response_model=SchedulerConfig)
def get_scheduler_config(
auth: Optional[dict] = Depends(require_auth),
) -> Dict[str, Any]:
"""Get current scheduler configuration along with runtime status.
auth: Optional[dict] = Depends(require_auth)
) -> SchedulerConfig:
"""Get current scheduler configuration.
Args:
auth: Authentication token (optional for read operations)
Returns:
Combined config and status response.
SchedulerConfig: Current scheduler configuration
Raises:
HTTPException: 500 if configuration cannot be loaded.
HTTPException: If configuration cannot be loaded
"""
try:
config_service = get_config_service()
app_config = config_service.load_config()
return _build_response(app_config.scheduler)
except ConfigServiceError as exc:
logger.error("Failed to load scheduler config: %s", exc)
return app_config.scheduler
except ConfigServiceError as e:
logger.error(f"Failed to load scheduler config: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to load scheduler configuration: {exc}",
) from exc
detail=f"Failed to load scheduler configuration: {e}",
) from e
@router.post("/config")
@router.post("/config", response_model=SchedulerConfig)
def update_scheduler_config(
scheduler_config: SchedulerConfig,
auth: dict = Depends(require_auth),
) -> Dict[str, Any]:
"""Update scheduler configuration and apply changes immediately.
) -> SchedulerConfig:
"""Update scheduler configuration.
Accepts the full SchedulerConfig body; any fields not supplied default
to their model defaults (backward compatible).
Args:
scheduler_config: New scheduler configuration
auth: Authentication token (required)
Returns:
Combined config and status response reflecting the saved config.
SchedulerConfig: Updated scheduler configuration
Raises:
HTTPException: 422 on validation errors (handled by FastAPI/Pydantic),
500 on save or scheduler failure.
HTTPException: If configuration update fails
"""
try:
config_service = get_config_service()
app_config = config_service.load_config()
app_config.scheduler = scheduler_config
config_service.save_config(app_config)
# Update scheduler section
app_config.scheduler = scheduler_config
# Save and return
config_service.save_config(app_config)
logger.info(
"Scheduler config updated by %s: time=%s days=%s auto_dl=%s",
auth.get("username", "unknown"),
scheduler_config.schedule_time,
scheduler_config.schedule_days,
scheduler_config.auto_download_after_rescan,
f"Scheduler config updated by {auth.get('username', 'unknown')}"
)
# Apply changes to the running scheduler without restart
try:
sched_svc = get_scheduler_service()
sched_svc.reload_config(scheduler_config)
except Exception as sched_exc: # pylint: disable=broad-exception-caught
logger.error("Scheduler reload after config update failed: %s", sched_exc)
# Config was saved — don't fail the request, just warn
return _build_response(scheduler_config)
except ConfigServiceError as exc:
logger.error("Failed to update scheduler config: %s", exc)
return scheduler_config
except ConfigServiceError as e:
logger.error(f"Failed to update scheduler config: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update scheduler configuration: {exc}",
) from exc
detail=f"Failed to update scheduler configuration: {e}",
) from e
@router.post("/trigger-rescan", response_model=Dict[str, str])
async def trigger_rescan(auth: dict = Depends(require_auth)) -> Dict[str, str]:
"""Manually trigger a library rescan (and auto-download if configured).
"""Manually trigger a library rescan.
This endpoint triggers an immediate anime library rescan, bypassing
the scheduler interval.
Args:
auth: Authentication token (required)
@@ -128,7 +100,8 @@ async def trigger_rescan(auth: dict = Depends(require_auth)) -> Dict[str, str]:
HTTPException: If rescan cannot be triggered
"""
try:
from src.server.utils.dependencies import get_series_app # noqa: PLC0415
# Import here to avoid circular dependency
from src.server.fastapi_app import get_series_app
series_app = get_series_app()
if not series_app:
@@ -137,19 +110,21 @@ async def trigger_rescan(auth: dict = Depends(require_auth)) -> Dict[str, str]:
detail="SeriesApp not initialized",
)
# Trigger the rescan
logger.info(
"Manual rescan triggered by %s", auth.get("username", "unknown")
f"Manual rescan triggered by {auth.get('username', 'unknown')}"
)
from src.server.api.anime import trigger_rescan as do_rescan # noqa: PLC0415
# Use existing rescan logic from anime API
from src.server.api.anime import trigger_rescan as do_rescan
return await do_rescan()
except HTTPException:
raise
except Exception as exc:
except Exception as e:
logger.exception("Failed to trigger manual rescan")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to trigger rescan: {exc}",
) from exc
detail=f"Failed to trigger rescan: {str(e)}",
) from e

View File

@@ -13,9 +13,8 @@ in their data payload. The `folder` field is optional for display purposes.
"""
from __future__ import annotations
import time
import uuid
from typing import Dict, Optional, Set
from typing import Optional
import structlog
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, status
@@ -35,73 +34,6 @@ logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/ws", tags=["websocket"])
# Valid room names - explicit allow-list for security
VALID_ROOMS: Set[str] = {
"downloads", # Download progress updates
"queue", # Queue status changes
"scan", # Scan progress updates
"system", # System notifications
"errors", # Error notifications
}
# Rate limiting configuration for WebSocket messages
WS_RATE_LIMIT_MESSAGES_PER_MINUTE = 60
WS_RATE_LIMIT_WINDOW_SECONDS = 60
# In-memory rate limiting for WebSocket connections
# WARNING: This resets on process restart. For production, consider Redis.
_ws_rate_limits: Dict[str, Dict[str, float]] = {}
def _check_ws_rate_limit(connection_id: str) -> bool:
"""Check if a WebSocket connection has exceeded its rate limit.
Args:
connection_id: Unique identifier for the WebSocket connection
Returns:
bool: True if within rate limit, False if exceeded
"""
now = time.time()
if connection_id not in _ws_rate_limits:
_ws_rate_limits[connection_id] = {
"count": 0,
"window_start": now,
}
record = _ws_rate_limits[connection_id]
# Reset window if expired
if now - record["window_start"] > WS_RATE_LIMIT_WINDOW_SECONDS:
record["window_start"] = now
record["count"] = 0
record["count"] += 1
return record["count"] <= WS_RATE_LIMIT_MESSAGES_PER_MINUTE
def _cleanup_ws_rate_limits(connection_id: str) -> None:
"""Remove rate limit record for a disconnected connection.
Args:
connection_id: Unique identifier for the WebSocket connection
"""
_ws_rate_limits.pop(connection_id, None)
def _validate_room_name(room: str) -> bool:
"""Validate that a room name is in the allowed set.
Args:
room: Room name to validate
Returns:
bool: True if room is valid, False otherwise
"""
return room in VALID_ROOMS
@router.websocket("/connect")
async def websocket_endpoint(
@@ -198,19 +130,6 @@ async def websocket_endpoint(
# Receive message from client
data = await websocket.receive_json()
# Check rate limit
if not _check_ws_rate_limit(connection_id):
logger.warning(
"WebSocket rate limit exceeded",
connection_id=connection_id,
)
await ws_service.send_error(
connection_id,
"Rate limit exceeded. Please slow down.",
"RATE_LIMIT_EXCEEDED",
)
continue
# Parse client message
try:
client_msg = ClientMessage(**data)
@@ -230,26 +149,9 @@ async def websocket_endpoint(
# Handle room subscription requests
if client_msg.action in ["join", "leave"]:
try:
room_name = client_msg.data.get("room", "")
# Validate room name against allow-list
if not _validate_room_name(room_name):
logger.warning(
"Invalid room name requested",
connection_id=connection_id,
room=room_name,
)
await ws_service.send_error(
connection_id,
f"Invalid room name: {room_name}. "
f"Valid rooms: {', '.join(sorted(VALID_ROOMS))}",
"INVALID_ROOM",
)
continue
room_req = RoomSubscriptionRequest(
action=client_msg.action,
room=room_name,
room=client_msg.data.get("room", ""),
)
if room_req.action == "join":
@@ -339,8 +241,7 @@ async def websocket_endpoint(
error=str(e),
)
finally:
# Cleanup connection and rate limit record
_cleanup_ws_rate_limits(connection_id)
# Cleanup connection
await ws_service.disconnect(connection_id)
logger.info("WebSocket connection closed", connection_id=connection_id)
@@ -362,6 +263,5 @@ async def websocket_status(
"status": "operational",
"active_connections": connection_count,
"supported_message_types": [t.value for t in WebSocketMessageType],
"valid_rooms": sorted(VALID_ROOMS),
},
)

View File

@@ -8,7 +8,7 @@ Environment Variables:
JWT_SECRET_KEY: Secret key for JWT token signing (default: dev-secret)
PASSWORD_SALT: Salt for password hashing (default: dev-salt)
DATABASE_URL: Development database connection string (default: SQLite)
LOG_LEVEL: Logging level (default: INFO)
LOG_LEVEL: Logging level (default: DEBUG)
CORS_ORIGINS: Comma-separated list of allowed CORS origins
API_RATE_LIMIT: API rate limit per minute (default: 1000)
"""
@@ -91,8 +91,8 @@ class DevelopmentSettings(BaseSettings):
# Logging Settings
# ============================================================================
log_level: str = Field(default="INFO", env="LOG_LEVEL")
"""Logging level (INFO for standard output)."""
log_level: str = Field(default="DEBUG", env="LOG_LEVEL")
"""Logging level (DEBUG for detailed output)."""
log_file: str = Field(default="logs/development.log", env="LOG_FILE")
"""Path to development log file."""

View File

@@ -60,7 +60,7 @@ def setup_logging() -> Dict[str, logging.Logger]:
# File handler for general server logs
server_file_handler = logging.FileHandler(server_log_file, mode='a', encoding='utf-8')
server_file_handler.setLevel(logging.INFO)
server_file_handler.setLevel(logging.DEBUG)
server_file_handler.setFormatter(detailed_format)
root_logger.addHandler(server_file_handler)

View File

@@ -0,0 +1,27 @@
"""
Health check controller for monitoring and status endpoints.
This module provides health check endpoints for application monitoring.
"""
from fastapi import APIRouter
from src.config.settings import settings
from src.server.utils.dependencies import _series_app
router = APIRouter(prefix="/health", tags=["health"])
@router.get("")
async def health_check():
"""Health check endpoint for monitoring.
This endpoint does not depend on anime_directory configuration
and should always return 200 OK for basic health monitoring.
"""
return {
"status": "healthy",
"service": "aniworld-api",
"version": "1.0.0",
"series_app_initialized": _series_app is not None,
"anime_directory_configured": bool(settings.anime_directory)
}

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