Compare commits
481 Commits
86eaa8a680
...
v1.4.15
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f61ded92a | |||
| d6082b5cf6 | |||
| e76cd3a708 | |||
| 08f7f7453c | |||
| 023ddd182f | |||
| 288b03cbb4 | |||
| f73cc530c3 | |||
| 4b835a2439 | |||
| 7c1dccfe64 | |||
| e0be00dce6 | |||
| 14f7b2f28a | |||
| de250bdd37 | |||
| b800158648 | |||
| 4e0c66ea9e | |||
| 07c311c1cd | |||
| cf00c9f7c5 | |||
| f3042206a8 | |||
| 657e7f9bf5 | |||
| fd3ec5df83 | |||
| 275aeb4544 | |||
| be7b210959 | |||
| 486c5440f2 | |||
| 4076b9dd43 | |||
| df93e8a81f | |||
| 576d9f7a7b | |||
| af93daeddc | |||
| a05795bb35 | |||
| d22df947e4 | |||
| 8bb8c6aa64 | |||
| 109d3c8ac9 | |||
| 6a934db8ac | |||
| ac7302b1dd | |||
| ac5ee3bb27 | |||
| a9084202e3 | |||
| be9f2a4c0c | |||
| 53fe09351f | |||
| dc7d9ee5f7 | |||
| da3cae2812 | |||
| 2876cef24b | |||
| 6a402623c4 | |||
| ebfbec1225 | |||
| 01e4dec8d7 | |||
| ecef21eec4 | |||
| d9738ffb78 | |||
| 6aec2a1733 | |||
| 84487d7571 | |||
| e02d65778f | |||
| 45d259bab2 | |||
| 7b8de8d988 | |||
| 18d10b44b5 | |||
| 5c2be3f7c4 | |||
| 2c47713339 | |||
| e74b04c1ee | |||
| 8b21f1243f | |||
| 3d33626546 | |||
| 7d9f80a0c6 | |||
| 25dc66fec3 | |||
| 2be7b692b9 | |||
| 2b5c969a83 | |||
| 830f6b4c93 | |||
| 5526ab884a | |||
| 09d454d4c0 | |||
| 13504c3172 | |||
| 82493d41ea | |||
| 274f773988 | |||
| 21af502184 | |||
| 97caaf0d18 | |||
| dc5d6506bc | |||
| dbaf80e941 | |||
| 4fc597c5de | |||
| a77bb371df | |||
| 420d10bb34 | |||
| e29918488c | |||
| 9c3f03d610 | |||
| 9d64241230 | |||
| 49cd84f3e5 | |||
| e46759347e | |||
| 75f743e6cc | |||
| 4dc5ffa19e | |||
| 1649a22418 | |||
| 246752e2fc | |||
| 84b24ed79e | |||
| bf3954587a | |||
| ed8f5cae10 | |||
| a54c285994 | |||
| c58b42dfa5 | |||
| 6dfb24de7e | |||
| 6021cdef28 | |||
| 5517ccbab0 | |||
| 94ed013172 | |||
| 76b849fc91 | |||
| 00b26c8cbc | |||
| a6f2399aca | |||
| cf001563b3 | |||
| 38c12638a4 | |||
| 765e43c684 | |||
| 5190d32665 | |||
| 4e6afa31b5 | |||
| 1ef59c5283 | |||
| 239341629c | |||
| 51b7f349f8 | |||
| 14b8ef7f06 | |||
| 7abba0dae2 | |||
| 30858f441c | |||
| 33f63ca304 | |||
| fe9284b80e | |||
| 12e5526991 | |||
| bc87bee416 | |||
| 7ded5a6e4d | |||
| d596902ca3 | |||
| d358a07290 | |||
| b9c55f9e7a | |||
| fc4e52f1a2 | |||
| 6d30747f25 | |||
| ceb6a2aeb4 | |||
| 53d6da5dac | |||
| 102d83e947 | |||
| 841368bf85 | |||
| cbd53ef2a0 | |||
| 50a77976d5 | |||
| dfc28b8e66 | |||
| 6c9605e896 | |||
| 3947f6d266 | |||
| a3176f5ac1 | |||
| 9a81b04b65 | |||
| a336733ea9 | |||
| ca93bb740a | |||
| d5e955a731 | |||
| e2a373816a | |||
| a115215416 | |||
| c579235af0 | |||
| 0ba2587bc8 | |||
| bda1fe4445 | |||
| 810346bc8b | |||
| daa937bcb7 | |||
| 1c505bd722 | |||
| 3551838887 | |||
| 9a20541598 | |||
| 3f7651404d | |||
| bee24406e6 | |||
| 31eb0026cf | |||
| 24ea12bbaf | |||
| e74b602f60 | |||
| db65e28854 | |||
| 11e231a4ab | |||
| a11f8c4fa0 | |||
| cf5a06af11 | |||
| e07f75432e | |||
| 1696d5c65b | |||
| c8b386f47a | |||
| 3888da352a | |||
| 06e104db42 | |||
| d4594bd1d9 | |||
| d866e836f6 | |||
| 195dae13cb | |||
| 51be777e7d | |||
| 7930e49701 | |||
| 75c22fe296 | |||
| 7bcd0600d5 | |||
| a333329ae2 | |||
| 363f7899f8 | |||
| a08a8f7408 | |||
| 54ac5e9ab7 | |||
| c93ac3e7b8 | |||
| 68c4335348 | |||
| be87f2e230 | |||
| c56e0f507d | |||
| cb0a36ccc2 | |||
| 3644b16447 | |||
| d5116e378e | |||
| 50a7083ce5 | |||
| 52c0ff2337 | |||
| a5fd88e224 | |||
| 98d4edad14 | |||
| bc8059b453 | |||
| 815a4f1520 | |||
| e3509f5c8f | |||
| 69c2fd01f9 | |||
| 0f36afd88c | |||
| ceac22fc34 | |||
| 9c0f7ce08d | |||
| 756731cd5d | |||
| eb0e6e8ccb | |||
| eb2fc3c5ab | |||
| c39ae9d0fc | |||
| 079f1f99e3 | |||
| 9373f500d3 | |||
| 2274403899 | |||
| 6ad14c03b5 | |||
| b10cce0489 | |||
| 2aa184c870 | |||
| 92bd55ada1 | |||
| e5fae0a0a2 | |||
| 151a08e033 | |||
| e44a8190d0 | |||
| 94720f2d61 | |||
| 0ec120e08f | |||
| db58ea9396 | |||
| 69b409f42d | |||
| b34ee59bca | |||
| 624c0db16e | |||
| e6d9f9f342 | |||
| fc8cdc538d | |||
| d8248be67d | |||
| 6c7dc66c5d | |||
| d951963d87 | |||
| f6000b1fff | |||
| ddf10327c7 | |||
| 747e1acc21 | |||
| 1885fed4bd | |||
| dd45494717 | |||
| 4ac51a789a | |||
| 1712dfd776 | |||
| ddcac5a96d | |||
| c186e0d4f7 | |||
| 759cd09ded | |||
| bbf0a0815a | |||
| 87bf0d71cd | |||
| 8e262c947c | |||
| adea1e2ede | |||
| d71feb64dd | |||
| 3e5ad8a4a6 | |||
| e1abf90c81 | |||
| 228964e928 | |||
| dee2601bda | |||
| 61f35632b9 | |||
| eed75ff08b | |||
| 0265ae2a70 | |||
| ac7e15e1eb | |||
| 850207d9a8 | |||
| 1c39dd5c6a | |||
| 76f02ec822 | |||
| e84a220f55 | |||
| d7ab689fe1 | |||
| 0d2ce07ad7 | |||
| e4d328bb45 | |||
| f283e581d6 | |||
| 88043ed749 | |||
| 7effc02f33 | |||
| 60e5b5ccda | |||
| 88f3219126 | |||
| c6da967893 | |||
| 9275747b6d | |||
| 5b3fbf36b9 | |||
| 46dab1dbc1 | |||
| d1d30dde9e | |||
| 4b35cb63d1 | |||
| af208882f5 | |||
| cf754860f1 | |||
| 53b628efd9 | |||
| 06fb6630ea | |||
| d72b8cb1ab | |||
| d74c181556 | |||
| c757123429 | |||
| 436dc8b338 | |||
| f8122099c3 | |||
| 8174cf73c4 | |||
| 6208cae5c7 | |||
| 708bf42f89 | |||
| 27c6087d88 | |||
| 9157c4b274 | |||
| 700415af57 | |||
| 7f21d3236f | |||
| 253750ad45 | |||
| b1d9714123 | |||
| 562fcdc811 | |||
| 212b971bba | |||
| 08123d40e4 | |||
| 30ff7c7a93 | |||
| bd5538be59 | |||
| a92340aa8b | |||
| 9ab96398b0 | |||
| aceaba5849 | |||
| a345f9b4e9 | |||
| e3de8a4c9a | |||
| aa601daf88 | |||
| 7100b3c968 | |||
| ab40cdcf2c | |||
| 26532ea592 | |||
| 1f551a3fbe | |||
| eb0f6cdc85 | |||
| 63da2daa53 | |||
| 0ab9adbd04 | |||
| 1a4fce16d6 | |||
| c693c6572b | |||
| f409b81aa2 | |||
| f5a42f269e | |||
| cf4e698454 | |||
| 58fb9fdd3e | |||
| dc6c113707 | |||
| 3b1ab36786 | |||
| cc6f190cb6 | |||
| 954d571a80 | |||
| 7693828621 | |||
| 10246df78b | |||
| 846176f114 | |||
| 732181b709 | |||
| 6854d72d56 | |||
| ab1836575e | |||
| 0ffcfac674 | |||
| 797bba4151 | |||
| 458fc483e4 | |||
| 3f2e15669d | |||
| 7c1242a122 | |||
| fb8f0bdbd2 | |||
| 52d82ab6bc | |||
| 8647da8474 | |||
| 46271a9845 | |||
| 4abaf8def7 | |||
| c4080e4e57 | |||
| ed3882991f | |||
| 35a7aeac9e | |||
| b89da0d7a0 | |||
| 14dce41de8 | |||
| 6d0259d4b4 | |||
| f7cc296aa7 | |||
| 8ff558cb07 | |||
| 04f26d5cfc | |||
| 5af72c33b8 | |||
| c7bf232fe1 | |||
| 2b904fd01e | |||
| e09bb0451c | |||
| 800790fc8f | |||
| 0e58a49cdd | |||
| fed6162452 | |||
| 611798b786 | |||
| 314f535446 | |||
| a8011eb6a3 | |||
| ba6429bb2f | |||
| 168b4c5ac4 | |||
| 925f408699 | |||
| 9fb93794e6 | |||
| faac14346f | |||
| f8634bf605 | |||
| 7bf02ac8f8 | |||
| 026e96b66c | |||
| c586e9f69d | |||
| f89649fe20 | |||
| 33406fef1a | |||
| 5e233bcba0 | |||
| 48a2fd0f2a | |||
| 77ffdac84b | |||
| 92c8d42c4d | |||
| ae162d9a6d | |||
| 4c606faa0e | |||
| 50e0b21669 | |||
| 8e8487b7b7 | |||
| 61c86dc698 | |||
| 88c00b761c | |||
| 125892abe5 | |||
| 050db40af3 | |||
| 9f1158b9af | |||
| db7e21a14c | |||
| bf3cfa00d5 | |||
| 35c82e68b7 | |||
| b2379e05cf | |||
| f9e4970615 | |||
| 5aba36c40a | |||
| d425d711bd | |||
| 6215477eef | |||
| 0b580f2fab | |||
| bfbae88ade | |||
| 01f828c799 | |||
| 6d40ddbfe5 | |||
| d6a82f4329 | |||
| 7d95c180a9 | |||
| 62bdcf35cb | |||
| c97da7db2e | |||
| 09a5eccea7 | |||
| 265d7fe435 | |||
| 0bbdd46fc7 | |||
| 8b0a4abca9 | |||
| 5ca6a27573 | |||
| 9d5bd12ec8 | |||
| 0b4fb10d65 | |||
| f18c31a035 | |||
| df19f8ad95 | |||
| 2495b07fc4 | |||
| ea9e959a7b | |||
| 7a77dff194 | |||
| 1b4526d050 | |||
| 491daa2e50 | |||
| 03901a8c2d | |||
| c92e2d340e | |||
| e502dcb8bd | |||
| 4e56093ff9 | |||
| 9877f9400c | |||
| db1e7fa54b | |||
| 4408874d37 | |||
| 390cafc0dc | |||
| a06abaa2e5 | |||
| c6919ac124 | |||
| 22a41ba93f | |||
| fd5e85d5ea | |||
| 4e29c4ed80 | |||
| d676cb7dca | |||
| d1a966cc0d | |||
| c88e2d2b7b | |||
| 2f04b2a862 | |||
| 120b26b9f7 | |||
| ecfa8d3c10 | |||
| d642234814 | |||
| 56b4975d10 | |||
| 94f4cc69c4 | |||
| b27cd5fb82 | |||
| 45a37a8c08 | |||
| c5dbc9a22b | |||
| 6f2a8f26e1 | |||
| 9078a6f3dc | |||
| a1865a41c6 | |||
| 99a5086158 | |||
| 4b636979f9 | |||
| 9f6606f1e1 | |||
| b9f3149679 | |||
| 1c476003d6 | |||
| e32098fb94 | |||
| 67119d0627 | |||
| a62cec2090 | |||
| 6901df11c4 | |||
| 36e663c556 | |||
| 2f00c3feac | |||
| c163b076a0 | |||
| 3a0243da1f | |||
| 641fa09251 | |||
| 4895e487c0 | |||
| 5e8815d143 | |||
| 65b116c39f | |||
| 9a1c9b39ee | |||
| 40ffb99c97 | |||
| ccbd9768a2 | |||
| 281b982abe | |||
| 5c0a019e72 | |||
| 3d2ef53463 | |||
| f63d615364 | |||
| 2a85a2bc18 | |||
| 489c37357e | |||
| 4f2d652a69 | |||
| bd655cb0f0 | |||
| 60070395e9 | |||
| f39a08d985 | |||
| 055bbf4de6 | |||
| ab7d78261e | |||
| b1726968e5 | |||
| ff9dea0488 | |||
| 803f35ef39 | |||
| 4780f68a23 | |||
| 08f816a954 | |||
| 778d16b21a | |||
| a67a16d6bf | |||
| 2e5731b5d6 | |||
| 94cf36bff3 | |||
| dfdac68ecc | |||
| 3d3b97bdc2 | |||
| 1b7ca7b4da | |||
| f28dc756c5 | |||
| d70d70e193 | |||
| 1ba67357dc | |||
| b2728a7cf4 | |||
| f7ee9a40da | |||
| 9f4ea84b47 | |||
| 9e393adb00 | |||
| 458ca1d776 | |||
| b6d44ca7d8 | |||
| 19cb8c11a0 | |||
| 72ac201153 | |||
| a24f07a36e | |||
| 9b071fe370 | |||
| 32dc893434 | |||
| 700f491ef9 | |||
| 4c9bf6b982 | |||
| bf332f27e0 | |||
| 596476f9ac | |||
| 27108aacda | |||
| 54790a7ebb | |||
| 1652f2f6af | |||
| 3cb644add4 | |||
| 63742bb369 | |||
| 8373da8547 | |||
| 38e0ba0484 | |||
| 5f6ac8e507 | |||
| 684337fd0c |
37
.dockerignore
Normal file
37
.dockerignore
Normal file
@@ -0,0 +1,37 @@
|
||||
__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/
|
||||
|
||||
# Exception: VERSION is needed by Dockerfile.app
|
||||
!Docker/VERSION
|
||||
|
||||
# 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
26
.gitignore
vendored
@@ -4,6 +4,7 @@
|
||||
/src/__pycache__/*
|
||||
/src/__pycache__/
|
||||
/.vs/*
|
||||
/.venv/*
|
||||
/src/Temp/*
|
||||
/src/Loaders/__pycache__/*
|
||||
/src/Loaders/provider/__pycache__/*
|
||||
@@ -51,12 +52,35 @@ wheels/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Database
|
||||
# Database files (including SQLite journal/WAL files)
|
||||
*.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
4
.gitmodules
vendored
@@ -1,4 +0,0 @@
|
||||
[submodule "src/AniWorld-Downloader"]
|
||||
path = src/AniWorld-Downloader
|
||||
url = https://github.com/lukaspupkalipinski/AniWorld-Downloader.git
|
||||
branch = next
|
||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"python.defaultInterpreterPath": "C:\\Users\\lukas\\anaconda3\\envs\\AniWorld\\python.exe",
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
|
||||
"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,
|
||||
|
||||
25
Docker/Containerfile
Normal file
25
Docker/Containerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
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 version file and entrypoint
|
||||
COPY VERSION /etc/wireguard/VERSION
|
||||
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"]
|
||||
35
Docker/Dockerfile.app
Normal file
35
Docker/Dockerfile.app
Normal file
@@ -0,0 +1,35 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies for compiled Python packages and ffmpeg for HLS support
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
g++ \
|
||||
libffi-dev \
|
||||
ffmpeg \
|
||||
&& 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
|
||||
COPY Docker/VERSION ./Docker/VERSION
|
||||
|
||||
# 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"]
|
||||
1
Docker/VERSION
Normal file
1
Docker/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
v1.4.15
|
||||
91
Docker/dispatcher.d-99-wg-routes.sh
Normal file
91
Docker/dispatcher.d-99-wg-routes.sh
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/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
|
||||
387
Docker/entrypoint.sh
Normal file
387
Docker/entrypoint.sh
Normal file
@@ -0,0 +1,387 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
VERSION_FILE="/etc/wireguard/VERSION"
|
||||
if [ -f "$VERSION_FILE" ]; then
|
||||
VERSION=$(cat "$VERSION_FILE")
|
||||
else
|
||||
VERSION="unknown"
|
||||
fi
|
||||
echo "[init] VPN Container Entrypoint ${VERSION}"
|
||||
|
||||
INTERFACE="wg0"
|
||||
MOUNT_CONFIG="/etc/wireguard/${INTERFACE}.conf"
|
||||
CONFIG_DIR="/run/wireguard"
|
||||
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 (VPN DNS servers are routed through wg0; allow before routing decision)
|
||||
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
|
||||
iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT
|
||||
iptables -A INPUT -p udp --sport 53 -j ACCEPT
|
||||
iptables -A INPUT -p tcp --sport 53 -j ACCEPT
|
||||
|
||||
# Allow DHCP (for container networking)
|
||||
iptables -A OUTPUT -p udp --dport 67:68 -j ACCEPT
|
||||
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)
|
||||
# Filter out wg-quick directives that wg setconf doesn't understand
|
||||
wg setconf "$INTERFACE" <(grep -v -i '^\(Address\|DNS\|MTU\|Table\|PreUp\|PostUp\|PreDown\|PostDown\|SaveConfig\)' "$CONFIG_FILE")
|
||||
|
||||
# Log public key so it can be verified against the server's peer list
|
||||
local PUBKEY
|
||||
PUBKEY=$(wg show "$INTERFACE" public-key 2>/dev/null || echo "unknown")
|
||||
echo "[vpn] Public key: ${PUBKEY}"
|
||||
|
||||
# Assign the address
|
||||
ip -4 address add "$VPN_ADDRESS" dev "$INTERFACE"
|
||||
|
||||
# Set MTU and bring up
|
||||
ip link set mtu 1420 up dev "$INTERFACE"
|
||||
|
||||
# ── fwmark-based routing (mirrors wg-quick behavior) ──
|
||||
# WireGuard marks its own encapsulated UDP packets with this fwmark.
|
||||
# Policy rules then ensure:
|
||||
# - Normal packets (no mark) → VPN routing table → wg0
|
||||
# - WireGuard-encapsulated packets (marked) → main table → eth0
|
||||
local FW_MARK=51820
|
||||
local FW_TABLE=51820
|
||||
wg set "$INTERFACE" fwmark "$FW_MARK"
|
||||
|
||||
# Remove any auto-created default route on wg0
|
||||
ip route del default dev "$INTERFACE" 2>/dev/null || true
|
||||
|
||||
# VPN routing table: send everything through the tunnel
|
||||
ip -4 route add default dev "$INTERFACE" table "$FW_TABLE"
|
||||
|
||||
# Policy rules:
|
||||
# 1. Packets NOT marked by WireGuard use the VPN table (→ wg0)
|
||||
# 2. suppress_prefixlength 0: ignore bare default routes in main table,
|
||||
# but keep more-specific routes (e.g. LAN, endpoint) working
|
||||
ip -4 rule add not fwmark "$FW_MARK" table "$FW_TABLE"
|
||||
ip -4 rule add table main suppress_prefixlength 0
|
||||
|
||||
# Find default gateway/interface
|
||||
DEFAULT_GW=$(ip route | grep '^default' | head -1 | awk '{print $3}')
|
||||
DEFAULT_IF=$(ip route | grep '^default' | head -1 | awk '{print $5}')
|
||||
|
||||
# ── 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 (handle comma-separated DNS servers)
|
||||
VPN_DNS=$(grep -i '^DNS' "$CONFIG_FILE" | head -1 | sed 's/.*= *//;s/ //g')
|
||||
if [ -n "$VPN_DNS" ]; then
|
||||
# Clear resolv.conf and add each DNS server on its own line
|
||||
> /etc/resolv.conf
|
||||
for dns in $(echo "$VPN_DNS" | tr ',' ' '); do
|
||||
echo "nameserver $dns" >> /etc/resolv.conf
|
||||
# Add explicit route to DNS server through wg0 so it's found in main table
|
||||
# (suppress_prefixlength 0 ignores default routes but allows host routes)
|
||||
ip -4 route add "$dns" dev "$INTERFACE" 2>/dev/null || true
|
||||
done
|
||||
echo "[vpn] DNS set to: ${VPN_DNS}"
|
||||
fi
|
||||
|
||||
# Add explicit host route for the health-check target so it is picked up by
|
||||
# the 'lookup main suppress_prefixlength 0' rule (same as DNS servers above).
|
||||
# Without this, CHECK_HOST falls through to the VPN table default route whose
|
||||
# source-address selection can be defeated by the priority-100 'from ETH0_IP'
|
||||
# policy rule, causing pings to bypass wg0 and be dropped by the kill switch.
|
||||
ip -4 route add "${CHECK_HOST}" dev "$INTERFACE" 2>/dev/null || true
|
||||
echo "[vpn] Health-check route: ${CHECK_HOST} → ${INTERFACE}"
|
||||
|
||||
echo "[vpn] WireGuard interface ${INTERFACE} is up."
|
||||
echo "[vpn] Main routes:"
|
||||
ip route show | sed 's/^/[vpn] /'
|
||||
echo "[vpn] VPN table ($FW_TABLE):"
|
||||
ip route show table "$FW_TABLE" 2>/dev/null | sed 's/^/[vpn] /'
|
||||
echo "[vpn] Policy rules:"
|
||||
ip rule show | sed 's/^/[vpn] /'
|
||||
echo "[vpn] WireGuard status:"
|
||||
wg show "$INTERFACE" 2>/dev/null | sed 's/^/[vpn] /'
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Stop WireGuard manually
|
||||
# ──────────────────────────────────────────────
|
||||
stop_vpn() {
|
||||
echo "[vpn] Stopping WireGuard interface ${INTERFACE}..."
|
||||
|
||||
local FW_MARK=51820
|
||||
local FW_TABLE=51820
|
||||
|
||||
# Remove fwmark-based policy rules
|
||||
ip -4 rule del not fwmark "$FW_MARK" table "$FW_TABLE" 2>/dev/null || true
|
||||
ip -4 rule del table main suppress_prefixlength 0 2>/dev/null || true
|
||||
|
||||
# Flush VPN routing table
|
||||
ip -4 route flush table "$FW_TABLE" 2>/dev/null || true
|
||||
|
||||
# Remove LAN policy routing
|
||||
ip -4 rule del table 100 2>/dev/null || true
|
||||
ip -4 route flush table 100 2>/dev/null || true
|
||||
|
||||
ip link del "$INTERFACE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 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 ping -c 1 -W 5 "$CHECK_HOST" > /dev/null 2>&1; then
|
||||
if [ "$failures" -gt 0 ]; then
|
||||
echo "[health] VPN recovered."
|
||||
failures=0
|
||||
fi
|
||||
# Secondary DNS check
|
||||
if ping -c 1 -W 5 "google.com" > /dev/null 2>&1; then
|
||||
: # DNS OK — silent
|
||||
else
|
||||
echo "[health] WARN google.com unreachable — possible DNS issue"
|
||||
fi
|
||||
else
|
||||
failures=$((failures + 1))
|
||||
echo "[health] Check failed ($failures/$max_failures) — ping ${CHECK_HOST} failed"
|
||||
# Secondary check: distinguish IP failure from DNS failure
|
||||
if ping -c 1 -W 5 "google.com" > /dev/null 2>&1; then
|
||||
echo "[health] INFO google.com reachable — DNS works, ${CHECK_HOST} may be filtered"
|
||||
else
|
||||
echo "[health] INFO google.com also unreachable — DNS or general routing failure"
|
||||
fi
|
||||
# Dump WireGuard stats to show if handshake is stale and how much data flows
|
||||
echo "[health] wg stats:"
|
||||
wg show "$INTERFACE" 2>/dev/null | grep -E 'latest handshake|transfer|endpoint' | sed 's/^/[health] /' || echo "[health] wg0 not found"
|
||||
echo "[health] routes:"
|
||||
ip route show | grep -E 'wg0|default' | sed 's/^/[health] /'
|
||||
|
||||
if [ "$failures" -ge "$max_failures" ]; then
|
||||
echo "[health] VPN appears down. Restarting WireGuard..."
|
||||
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
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Startup connectivity checks — diagnose issues early
|
||||
# ──────────────────────────────────────────────
|
||||
check_vpn_connectivity() {
|
||||
echo "[check] ── Startup connectivity checks ──"
|
||||
|
||||
# 1. Wait for WireGuard handshake (up to 15s)
|
||||
local elapsed=0
|
||||
local handshake_ts=0
|
||||
echo "[check] Waiting for WireGuard handshake (up to 15s)..."
|
||||
while [ "$elapsed" -lt 15 ]; do
|
||||
handshake_ts=$(wg show "$INTERFACE" latest-handshakes 2>/dev/null | awk '{print $2}' | head -1)
|
||||
if [ -n "$handshake_ts" ] && [ "$handshake_ts" != "0" ]; then
|
||||
local age=$(( $(date +%s) - handshake_ts ))
|
||||
echo "[check] OK Handshake established ${age}s ago"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
if [ "$elapsed" -ge 15 ]; then
|
||||
echo "[check] FAIL No WireGuard handshake after 15s — tunnel is not up"
|
||||
echo "[check] This container's public key (must be on the server):"
|
||||
echo "[check] PublicKey = $(wg show "$INTERFACE" public-key 2>/dev/null || echo 'unknown')"
|
||||
echo "[check] AllowedIPs = ${VPN_ADDRESS}"
|
||||
echo "[check] Verify on server: wg show"
|
||||
fi
|
||||
|
||||
# 2. Check whether traffic actually flows through the tunnel
|
||||
echo "[check] Testing traffic through tunnel (ping ${CHECK_HOST})..."
|
||||
local rx_before
|
||||
rx_before=$(wg show "$INTERFACE" transfer 2>/dev/null | awk '{print $2}' | head -1)
|
||||
|
||||
if ping -c 1 -W 8 "${CHECK_HOST}" > /dev/null 2>&1; then
|
||||
echo "[check] OK Traffic flows — tunnel is fully working"
|
||||
else
|
||||
local rx_after
|
||||
rx_after=$(wg show "$INTERFACE" transfer 2>/dev/null | awk '{print $2}' | head -1)
|
||||
echo "[check] FAIL ping ${CHECK_HOST} unreachable through tunnel"
|
||||
|
||||
if [ -n "$rx_before" ] && [ -n "$rx_after" ]; then
|
||||
if [ "$rx_after" -le "$rx_before" ]; then
|
||||
echo "[check] RX bytes unchanged (${rx_before} → ${rx_after})"
|
||||
echo "[check] Server receives packets but does NOT route them back"
|
||||
echo "[check] Fix on VPN server (${VPN_ENDPOINT}):"
|
||||
echo "[check] sysctl net.ipv4.ip_forward # must output 1"
|
||||
echo "[check] iptables -t nat -L POSTROUTING -v -n # must have MASQUERADE"
|
||||
echo "[check] wg show # check peer + AllowedIPs"
|
||||
else
|
||||
echo "[check] RX increased (${rx_before} → ${rx_after}) — tunnel passes data"
|
||||
echo "[check] Issue may be specific to ${CHECK_HOST} or DNS"
|
||||
fi
|
||||
fi
|
||||
|
||||
local transfer
|
||||
transfer=$(wg show "$INTERFACE" transfer 2>/dev/null | awk '{printf "rx=%s tx=%s", $2, $3}')
|
||||
echo "[check] wg transfer: ${transfer}"
|
||||
fi
|
||||
|
||||
# 3. DNS check
|
||||
echo "[check] Testing DNS resolution..."
|
||||
if nslookup 1.1.1.1 > /dev/null 2>&1 || nslookup google.com > /dev/null 2>&1; then
|
||||
echo "[check] OK DNS resolves"
|
||||
else
|
||||
echo "[check] FAIL DNS resolution failed"
|
||||
echo "[check] resolv.conf: $(tr '\n' ' ' < /etc/resolv.conf)"
|
||||
echo "[check] Check that DNS servers are reachable through wg0"
|
||||
echo "[check] ── End of checks ──"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[check] ── End of checks ──"
|
||||
}
|
||||
|
||||
# ── Main ──
|
||||
enable_forwarding
|
||||
setup_killswitch
|
||||
start_vpn
|
||||
check_vpn_connectivity
|
||||
health_loop
|
||||
16
Docker/nl.conf
Normal file
16
Docker/nl.conf
Normal file
@@ -0,0 +1,16 @@
|
||||
[Interface]
|
||||
PrivateKey = EPRa2f/v72LvIXAY4yqIRJifsSb+nCcYHqC2rwA94UI=
|
||||
Address = 100.64.244.78/32
|
||||
DNS = 198.18.0.1,198.18.0.2
|
||||
|
||||
# Route zum VPN-Server direkt über dein lokales Netz
|
||||
PostUp = ip route add 91.148.236.64 via 192.168.178.1 dev wlp4s0f0
|
||||
PostUp = ip route add 192.168.178.0/24 via 192.168.178.1 dev wlp4s0f0
|
||||
PostDown = ip route del 91.148.236.64 via 192.168.178.1 dev wlp4s0f0
|
||||
PostDown = ip route del 192.168.178.0/24 via 192.168.178.1 dev wlp4s0f0
|
||||
|
||||
[Peer]
|
||||
PublicKey = KgTUh3KLijVluDvNpzDCJJfrJ7EyLzYLmdHCksG4sRg=
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 91.148.236.64:51820
|
||||
|
||||
56
Docker/podman-compose.prod.yml
Normal file
56
Docker/podman-compose.prod.yml
Normal file
@@ -0,0 +1,56 @@
|
||||
# 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
|
||||
- NET_RAW
|
||||
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:
|
||||
- "8000: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
|
||||
- /media/serien/Serien:/data
|
||||
restart: unless-stopped
|
||||
49
Docker/podman-compose.yml
Normal file
49
Docker/podman-compose.yml
Normal file
@@ -0,0 +1,49 @@
|
||||
services:
|
||||
vpn:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Containerfile
|
||||
container_name: vpn-wireguard
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- SYS_MODULE
|
||||
- NET_RAW
|
||||
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
|
||||
- LOG_LEVEL=DEBUG
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
- app-logs:/app/logs
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
app-data:
|
||||
app-logs:
|
||||
140
Docker/push.sh
Normal file
140
Docker/push.sh
Normal file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build and push AniWorld container images to the Gitea registry.
|
||||
#
|
||||
# Usage:
|
||||
# ./push.sh # builds & pushes app with tag "latest"
|
||||
# ./push.sh app # builds & pushes app image
|
||||
# ./push.sh vpn # builds & pushes vpn image
|
||||
# ./push.sh all # builds & pushes both images
|
||||
# ./push.sh app v1.2.3 # builds & pushes app with tag "v1.2.3"
|
||||
# ./push.sh vpn v1.2.3 # builds & pushes vpn with tag "v1.2.3"
|
||||
# ./push.sh all v1.2.3 # builds & pushes both images
|
||||
# ./push.sh app v1.2.3 --no-build # pushes existing image only
|
||||
#
|
||||
# Prerequisites:
|
||||
# podman login git.lpl-mind.de (or: docker 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"
|
||||
|
||||
# Parse arguments
|
||||
TARGET="${1:-app}"
|
||||
TAG="${2:-latest}"
|
||||
SKIP_BUILD=false
|
||||
if [[ "${3:-}" == "--no-build" ]]; then
|
||||
SKIP_BUILD=true
|
||||
fi
|
||||
|
||||
# Validate target
|
||||
if [[ "${TARGET}" != "app" && "${TARGET}" != "vpn" && "${TARGET}" != "all" ]]; then
|
||||
echo "ERROR: Invalid target '${TARGET}'. Must be one of: app, vpn, all" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
log() { echo -e "\n>>> $*"; }
|
||||
err() { echo -e "\nERROR: $*" >&2; exit 1; }
|
||||
|
||||
# Detect container engine (podman preferred, docker fallback)
|
||||
if command -v podman &>/dev/null; then
|
||||
ENGINE="podman"
|
||||
elif command -v docker &>/dev/null; then
|
||||
ENGINE="docker"
|
||||
else
|
||||
err "Neither podman nor docker is installed."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-flight checks
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "============================================"
|
||||
echo " AniWorld — Build & Push"
|
||||
echo " Engine : ${ENGINE}"
|
||||
echo " Registry : ${REGISTRY}"
|
||||
echo " Target : ${TARGET}"
|
||||
echo " Tag : ${TAG}"
|
||||
echo "============================================"
|
||||
|
||||
log "Logging in to ${REGISTRY}"
|
||||
"${ENGINE}" login "${REGISTRY}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build
|
||||
# ---------------------------------------------------------------------------
|
||||
build_app() {
|
||||
log "Building app image → ${APP_IMAGE}:${TAG}"
|
||||
"${ENGINE}" build \
|
||||
-t "${APP_IMAGE}:${TAG}" \
|
||||
-f "${SCRIPT_DIR}/Dockerfile.app" \
|
||||
"${PROJECT_ROOT}"
|
||||
}
|
||||
|
||||
build_vpn() {
|
||||
log "Building vpn image → ${VPN_IMAGE}:${TAG}"
|
||||
"${ENGINE}" build \
|
||||
-t "${VPN_IMAGE}:${TAG}" \
|
||||
-f "${SCRIPT_DIR}/Containerfile" \
|
||||
"${SCRIPT_DIR}"
|
||||
}
|
||||
|
||||
if [[ "${SKIP_BUILD}" == false ]]; then
|
||||
case "${TARGET}" in
|
||||
app) build_app ;;
|
||||
vpn) build_vpn ;;
|
||||
all) build_app; build_vpn ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Push
|
||||
# ---------------------------------------------------------------------------
|
||||
push_app() {
|
||||
log "Pushing ${APP_IMAGE}:${TAG}"
|
||||
"${ENGINE}" push "${APP_IMAGE}:${TAG}"
|
||||
}
|
||||
|
||||
push_vpn() {
|
||||
log "Pushing ${VPN_IMAGE}:${TAG}"
|
||||
"${ENGINE}" push "${VPN_IMAGE}:${TAG}"
|
||||
}
|
||||
|
||||
case "${TARGET}" in
|
||||
app) push_app ;;
|
||||
vpn) push_vpn ;;
|
||||
all) push_app; push_vpn ;;
|
||||
esac
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " Push complete!"
|
||||
echo ""
|
||||
echo " Images:"
|
||||
case "${TARGET}" in
|
||||
app) echo " ${APP_IMAGE}:${TAG}" ;;
|
||||
vpn) echo " ${VPN_IMAGE}:${TAG}" ;;
|
||||
all) echo " ${APP_IMAGE}:${TAG}"; echo " ${VPN_IMAGE}:${TAG}" ;;
|
||||
esac
|
||||
echo ""
|
||||
echo " Deploy on server:"
|
||||
echo " ${ENGINE} login ${REGISTRY}"
|
||||
echo " ${ENGINE} compose -f Docker/podman-compose.prod.yml pull"
|
||||
echo " ${ENGINE} compose -f Docker/podman-compose.prod.yml up -d"
|
||||
echo "============================================"
|
||||
129
Docker/release.sh
Normal file
129
Docker/release.sh
Normal file
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Bump the project version and push images to the registry.
|
||||
#
|
||||
# Usage:
|
||||
# ./release.sh
|
||||
#
|
||||
# The current version is stored in VERSION (next to this script).
|
||||
# You will be asked whether to bump major, minor, or patch.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERSION_FILE="${SCRIPT_DIR}/VERSION"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read current version
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ ! -f "${VERSION_FILE}" ]]; then
|
||||
echo "0.0.0" > "${VERSION_FILE}"
|
||||
fi
|
||||
|
||||
CURRENT="$(cat "${VERSION_FILE}")"
|
||||
# Strip leading 'v' for arithmetic
|
||||
VERSION="${CURRENT#v}"
|
||||
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "${VERSION}"
|
||||
|
||||
echo "============================================"
|
||||
echo " AniWorld — Release"
|
||||
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "Which image(s) would you like to release?"
|
||||
echo " 1) app (Dockerfile.app)"
|
||||
echo " 2) vpn (Containerfile)"
|
||||
echo " 3) all (both images)"
|
||||
echo ""
|
||||
read -rp "Enter choice [1/2/3]: " TARGET_CHOICE
|
||||
|
||||
case "${TARGET_CHOICE}" in
|
||||
1) TARGET="app" ;;
|
||||
2) TARGET="vpn" ;;
|
||||
3) TARGET="all" ;;
|
||||
*)
|
||||
echo "Invalid choice. Aborting." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "How would you like to bump the version?"
|
||||
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.$((PATCH + 1)))"
|
||||
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.$((MINOR + 1)).0)"
|
||||
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH} → v$((MAJOR + 1)).0.0)"
|
||||
echo ""
|
||||
read -rp "Enter choice [1/2/3]: " CHOICE
|
||||
|
||||
case "${CHOICE}" in
|
||||
1) NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))" ;;
|
||||
2) NEW_TAG="v${MAJOR}.$((MINOR + 1)).0" ;;
|
||||
3) NEW_TAG="v$((MAJOR + 1)).0.0" ;;
|
||||
*)
|
||||
echo "Invalid choice. Aborting." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "New version: ${NEW_TAG}"
|
||||
echo "Target: ${TARGET}"
|
||||
read -rp "Confirm? [y/N]: " CONFIRM
|
||||
if [[ ! "${CONFIRM}" =~ ^[yY]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Write new version
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "${NEW_TAG}" > "${VERSION_FILE}"
|
||||
echo "Version file updated → ${VERSION_FILE}"
|
||||
|
||||
# Keep root package.json in sync.
|
||||
FRONT_VERSION="${NEW_TAG#v}"
|
||||
FRONT_PKG="${SCRIPT_DIR}/../package.json"
|
||||
if [[ -f "${FRONT_PKG}" ]]; then
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_PKG}"
|
||||
echo "package.json version updated → ${FRONT_VERSION}"
|
||||
else
|
||||
echo "Warning: package.json not found, skipping package.json version sync" >&2
|
||||
fi
|
||||
|
||||
# Keep root pyproject.toml in sync.
|
||||
BACKEND_PYPROJECT="${SCRIPT_DIR}/../pyproject.toml"
|
||||
if [[ -f "${BACKEND_PYPROJECT}" ]]; then
|
||||
# Update version under [project] section if present
|
||||
if grep -q '^\[project\]' "${BACKEND_PYPROJECT}"; then
|
||||
sed -i "/^\[project\]/,/^\[/ s/^version = \".*\"/version = \"${FRONT_VERSION}\"/" "${BACKEND_PYPROJECT}"
|
||||
else
|
||||
sed -i "s/^version = \".*\"/version = \"${FRONT_VERSION}\"/" "${BACKEND_PYPROJECT}"
|
||||
fi
|
||||
echo "pyproject.toml version updated → ${FRONT_VERSION}"
|
||||
else
|
||||
echo "Warning: pyproject.toml not found, skipping pyproject.toml version sync" >&2
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Push containers
|
||||
# ---------------------------------------------------------------------------
|
||||
bash "${SCRIPT_DIR}/push.sh" "${TARGET}" "${NEW_TAG}"
|
||||
bash "${SCRIPT_DIR}/push.sh" "${TARGET}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Git tag (local only; push after container build)
|
||||
# ---------------------------------------------------------------------------
|
||||
cd "${SCRIPT_DIR}/.."
|
||||
git add Docker/VERSION package.json pyproject.toml
|
||||
git commit -m "chore: bump version"
|
||||
git tag -a "${NEW_TAG}" -m "Release ${NEW_TAG}"
|
||||
echo "Local git commit + tag ${NEW_TAG} created."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Push git commits & tag
|
||||
# ---------------------------------------------------------------------------
|
||||
git push origin HEAD
|
||||
git push origin "${NEW_TAG}"
|
||||
echo "Git commit and tag ${NEW_TAG} pushed."
|
||||
253
Docker/test_vpn.py
Normal file
253
Docker/test_vpn.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
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.
|
||||
5. AllowedIPs routes are set dynamically from the config.
|
||||
|
||||
Requirements:
|
||||
- podman installed
|
||||
- Root/sudo (NET_ADMIN capability) for container runtime tests
|
||||
- A valid WireGuard config at ./wg0.conf (or ./nl.conf)
|
||||
|
||||
Usage:
|
||||
# Build-only test (no sudo needed):
|
||||
python3 -m pytest test_vpn.py::TestVPNImage::test_00_build_image -v
|
||||
|
||||
# Full integration test (requires sudo):
|
||||
sudo python3 -m pytest test_vpn.py -v
|
||||
# or
|
||||
sudo python3 test_vpn.py
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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 is_root() -> bool:
|
||||
"""Check if running as root."""
|
||||
return os.geteuid() == 0
|
||||
|
||||
|
||||
def run(cmd: list[str], timeout: int = 30, check: bool = True) -> subprocess.CompletedProcess:
|
||||
"""Run a command and return the result."""
|
||||
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=check)
|
||||
|
||||
|
||||
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 = ""
|
||||
container_id: 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 ──
|
||||
logger.info("Fetching host public IP...")
|
||||
cls.host_ip = get_host_ip()
|
||||
logger.info("Host public IP: %s", cls.host_ip)
|
||||
assert cls.host_ip, "Could not determine host public IP"
|
||||
|
||||
# ── 2. Build the image ──
|
||||
logger.info("Building image '%s'...", IMAGE_NAME)
|
||||
result = run(
|
||||
["podman", "build", "-t", IMAGE_NAME, BUILD_DIR],
|
||||
timeout=180,
|
||||
)
|
||||
logger.debug(
|
||||
"Build output: %s",
|
||||
result.stdout[-500:] if len(result.stdout) > 500 else result.stdout,
|
||||
)
|
||||
assert result.returncode == 0, f"Build failed:\n{result.stderr}"
|
||||
logger.info("Image built successfully.")
|
||||
|
||||
# Skip container runtime tests if not root
|
||||
if not is_root():
|
||||
logger.warning("Not running as root — skipping container runtime tests.")
|
||||
cls.container_id = ""
|
||||
return
|
||||
|
||||
# ── 3. Start the container ──
|
||||
logger.info("Starting container '%s'...", CONTAINER_NAME)
|
||||
result = run(
|
||||
[
|
||||
"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()
|
||||
logger.info("Container started: %s", 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 ──
|
||||
logger.info("Waiting up to %d seconds for VPN tunnel...", STARTUP_TIMEOUT)
|
||||
vpn_up = cls._wait_for_vpn_cls(STARTUP_TIMEOUT)
|
||||
assert vpn_up, f"VPN did not come up within {STARTUP_TIMEOUT}s"
|
||||
logger.info("VPN tunnel is up. Running tests.")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""Stop and remove the container."""
|
||||
if not is_root():
|
||||
return
|
||||
logger.info("Cleaning up test container...")
|
||||
subprocess.run(["podman", "rm", "-f", CONTAINER_NAME], capture_output=True, check=False)
|
||||
logger.info("Cleanup complete.")
|
||||
|
||||
@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()
|
||||
|
||||
def _skip_if_not_root(self):
|
||||
"""Skip test if not running as root."""
|
||||
if not is_root():
|
||||
self.skipTest("This test requires root/sudo privileges")
|
||||
|
||||
# ── Tests ────────────────────────────────────────────────
|
||||
|
||||
def test_00_build_image(self):
|
||||
"""The image builds successfully."""
|
||||
# This is already verified in setUpClass, just confirm here
|
||||
result = run(["podman", "images", "--format", "{{.Repository}}:{{.Tag}}"])
|
||||
self.assertIn(IMAGE_NAME, result.stdout, "Image was not built")
|
||||
|
||||
def test_01_ip_differs_from_host(self):
|
||||
"""Public IP inside VPN is different from host IP."""
|
||||
self._skip_if_not_root()
|
||||
vpn_ip = self._get_vpn_ip()
|
||||
logger.info("VPN public IP: %s", vpn_ip)
|
||||
logger.info("Host public IP: %s", self.host_ip)
|
||||
|
||||
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."""
|
||||
self._skip_if_not_root()
|
||||
result = podman_exec(CONTAINER_NAME, ["wg", "show", "wg0"])
|
||||
self.assertEqual(result.returncode, 0, f"wg show failed:\n{result.stderr}")
|
||||
self.assertIn("peer", result.stdout.lower(), "No peer information in wg show output")
|
||||
# AllowedIPs should be present in wg show output
|
||||
self.assertIn("allowed ips", result.stdout.lower(), "AllowedIPs not found in wg show output")
|
||||
|
||||
def test_03_allowedips_routes_set(self):
|
||||
"""Routes are set dynamically based on AllowedIPs from config."""
|
||||
self._skip_if_not_root()
|
||||
# Check that routes exist for the AllowedIPs
|
||||
result = podman_exec(CONTAINER_NAME, ["ip", "route", "show", "dev", "wg0"])
|
||||
self.assertEqual(result.returncode, 0, f"ip route show failed:\n{result.stderr}")
|
||||
# The config has AllowedIPs = 0.0.0.0/0, which should result in:
|
||||
# 0.0.0.0/1 dev wg0 and 128.0.0.0/1 dev wg0
|
||||
self.assertIn("0.0.0.0/1", result.stdout, "Route 0.0.0.0/1 not found")
|
||||
self.assertIn("128.0.0.0/1", result.stdout, "Route 128.0.0.0/1 not found")
|
||||
# Make sure there is NO default route through wg0 (Table = off should prevent this)
|
||||
self.assertNotIn("default dev wg0", result.stdout, "Default route through wg0 found — Table = off not working!")
|
||||
logger.info("AllowedIPs routes verified: %s", result.stdout.strip())
|
||||
|
||||
def test_03b_dns_configured(self):
|
||||
"""DNS is configured correctly with multiple nameserver lines."""
|
||||
self._skip_if_not_root()
|
||||
result = podman_exec(CONTAINER_NAME, ["cat", "/etc/resolv.conf"])
|
||||
self.assertEqual(result.returncode, 0, f"cat /etc/resolv.conf failed:\n{result.stderr}")
|
||||
# Should have two separate nameserver lines, not one with commas
|
||||
self.assertIn("nameserver 198.18.0.1", result.stdout, "DNS 198.18.0.1 not found")
|
||||
self.assertIn("nameserver 198.18.0.2", result.stdout, "DNS 198.18.0.2 not found")
|
||||
# Make sure there are no commas in nameserver lines
|
||||
self.assertNotIn("nameserver 198.18.0.1,198.18.0.2", result.stdout, "DNS servers written on one line with comma!")
|
||||
logger.info("DNS config verified: %s", result.stdout.strip())
|
||||
|
||||
def test_04_kill_switch_blocks_traffic(self):
|
||||
"""When WireGuard is down, traffic is blocked (kill switch)."""
|
||||
self._skip_if_not_root()
|
||||
# Bring down the WireGuard interface by deleting it
|
||||
down_result = podman_exec(CONTAINER_NAME, ["ip", "link", "del", "wg0"], timeout=10)
|
||||
self.assertEqual(down_result.returncode, 0, f"ip link del wg0 failed:\n{down_result.stderr}")
|
||||
|
||||
# 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!",
|
||||
)
|
||||
logger.info("Kill switch confirmed: traffic blocked with VPN down")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
18
Docker/wg0.conf
Normal file
18
Docker/wg0.conf
Normal file
@@ -0,0 +1,18 @@
|
||||
[Interface]
|
||||
PrivateKey = EPRa2f/v72LvIXAY4yqIRJifsSb+nCcYHqC2rwA94UI=
|
||||
Address = 100.64.244.78/32
|
||||
#DNS = 198.18.0.1,198.18.0.2
|
||||
DNS = 8.8.8.8
|
||||
|
||||
# Route zum VPN-Server direkt über dein lokales Netz
|
||||
PostUp = ip route add 91.148.236.64 via 192.168.178.1 dev wlp4s0f0
|
||||
PostUp = ip route add 192.168.178.0/24 via 192.168.178.1 dev wlp4s0f0
|
||||
PostDown = ip route del 91.148.236.64 via 192.168.178.1 dev wlp4s0f0
|
||||
PostDown = ip route del 192.168.178.0/24 via 192.168.178.1 dev wlp4s0f0
|
||||
|
||||
[Peer]
|
||||
PublicKey = KgTUh3KLijVluDvNpzDCJJfrJ7EyLzYLmdHCksG4sRg=
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 91.148.236.64:51820
|
||||
PersistentKeepalive = 25
|
||||
|
||||
1596
Docs/API.md
Normal file
1596
Docs/API.md
Normal file
File diff suppressed because it is too large
Load Diff
817
Docs/ARCHITECTURE.md
Normal file
817
Docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,817 @@
|
||||
# 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
|
||||
| +-- setup_service.py # Series key resolution from folder names
|
||||
| +-- folder_scan_service.py # Daily folder maintenance scan
|
||||
+-- 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
|
||||
+-- Cron-based library rescans configured
|
||||
+-- Optional: auto-download missing episodes after rescan
|
||||
+-- Optional: folder maintenance (NFO repair, key resolution, renaming, poster checks) during scheduled runs
|
||||
```
|
||||
|
||||
### 12.2 Temp Folder Guarantee
|
||||
|
||||
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)
|
||||
243
Docs/CHANGELOG.md
Normal file
243
Docs/CHANGELOG.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# 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/).
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased] - 2026-06-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Folder scan series key resolution**: Fixed "Could not resolve series key for folder, skipping" warnings during library setup. `_resolve_key_via_search()` now uses fuzzy title matching instead of exact string comparison.
|
||||
- Added `_normalize_title()` to strip anime suffixes: `(TV)`, `(Anime)`, `(OAD)`, `(OVA)`, `(Special)`, `(Movie)`, `(Spin-Off)`
|
||||
- Added `_titles_match()` using `difflib.SequenceMatcher` with 0.85 similarity threshold for tolerance of minor title variations
|
||||
- Added debug logging for title mismatches and multiple search results
|
||||
|
||||
---
|
||||
|
||||
## [1.3.1] - 2026-02-22
|
||||
|
||||
### Added
|
||||
|
||||
- **Encoding detection for HTML parsing** (`src/core/providers/aniworld_provider.py`):
|
||||
Added `_decode_html_content()` function that uses `chardet` to detect the actual
|
||||
encoding of HTML content before parsing. Falls back to UTF-8 with `errors='replace'`
|
||||
to handle pages with mismatched encoding declarations. Applied to all BeautifulSoup
|
||||
parsing calls to prevent "Some characters could not be decoded" warnings.
|
||||
- **chardet dependency**: Added `chardet>=5.2.0` to `requirements.txt` for encoding detection.
|
||||
|
||||
### Added
|
||||
|
||||
- **Temp file cleanup after every download** (`src/core/providers/aniworld_provider.py`,
|
||||
`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()`
|
||||
(`src/server/services/folder_scan_service.py`)**: New async function
|
||||
that iterates every series directory, checks whether `tvshow.nfo` is missing
|
||||
required tags using `nfo_needs_repair()`, and queues the series for background
|
||||
reload via `asyncio.create_task`. Skips gracefully when `tmdb_api_key` or
|
||||
`anime_directory` is not configured.
|
||||
- **NFO repair wired into scheduled folder scan (`src/server/services/folder_scan_service.py`)**:
|
||||
`perform_nfo_repair_scan(background_loader=None)` is called during the
|
||||
scheduled daily folder scan, keeping startup fast while ensuring regular
|
||||
maintenance.
|
||||
|
||||
### 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 (Mon–Sun) 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
|
||||
|
||||
### Deprecated
|
||||
|
||||
- **Legacy Series Files (key/data)**: File-based series storage is deprecated. `key` and `data` files in anime folders will be removed in v3.0.0. Database storage is now the primary method. See [docs/MIGRATION_GUIDE.md](docs/MIGRATION_GUIDE.md) for details.
|
||||
|
||||
---
|
||||
|
||||
## Sections for Each Release
|
||||
|
||||
```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._
|
||||
374
Docs/CONFIGURATION.md
Normal file
374
Docs/CONFIGURATION.md
Normal file
@@ -0,0 +1,374 @@
|
||||
# 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,
|
||||
"folder_scan_enabled": 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.1"
|
||||
}
|
||||
```
|
||||
|
||||
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. |
|
||||
| `scheduler.folder_scan_enabled` | bool | `false` | Run folder maintenance (NFO repair, folder renaming, poster checks) during scheduled runs. **When enabled, series folders are automatically renamed to match the `<title> (<year>)` convention derived from their `tvshow.nfo` files.** |
|
||||
|
||||
Valid day abbreviations: `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, `sun`.
|
||||
|
||||
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
|
||||
- `download_poster` also controls whether the scheduled folder scan checks for and re-downloads missing or corrupted `poster.jpg` files (see [NFO_GUIDE.md](NFO_GUIDE.md#6-poster-check))
|
||||
- Image downloads require valid `tmdb_api_key`
|
||||
- `TMDB_API_KEY` environment variable is optional when `nfo.tmdb_api_key` is configured in `data/config.json`
|
||||
- Larger image sizes (`w780`, `original`) consume more storage space
|
||||
|
||||
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
|
||||
642
Docs/DATABASE.md
Normal file
642
Docs/DATABASE.md
Normal file
@@ -0,0 +1,642 @@
|
||||
# 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. Corresponds to the core `Serie` class.
|
||||
|
||||
| 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) |
|
||||
| `year` | INTEGER | NULLABLE | Release year of the series |
|
||||
| `nfo_path` | VARCHAR(1000) | NULLABLE | Path to tvshow.nfo metadata file |
|
||||
| `tmdb_id` | INTEGER | NULLABLE, INDEX | TMDB (The Movie Database) ID for metadata |
|
||||
| `tvdb_id` | INTEGER | NULLABLE, INDEX | TVDB (TheTVDB) ID for metadata |
|
||||
| `has_nfo` | BOOLEAN | NOT NULL, DEFAULT FALSE | Whether tvshow.nfo exists |
|
||||
| `loading_status` | VARCHAR(50) | NOT NULL, DEFAULT 'completed' | Status: pending, loading_episodes, loading_nfo, completed, failed |
|
||||
| `created_at` | DATETIME | NOT NULL, DEFAULT NOW | Record creation timestamp |
|
||||
| `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
|
||||
|
||||
**EpisodeDict Mapping:**
|
||||
|
||||
The `episodeDict` (season → episode numbers mapping) is stored as individual `Episode` records:
|
||||
- Each `Episode` has `season` and `episode_number` columns
|
||||
- Relationship: `AnimeSeries.episodes` returns all Episode records for that series
|
||||
|
||||
Source: [src/server/database/models.py](../src/server/database/models.py#L23-L150)
|
||||
|
||||
### 3.3 episodes
|
||||
|
||||
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. Series Storage: Database vs Files (Deprecated)
|
||||
|
||||
### File-Based Storage (Removed in v2.0)
|
||||
|
||||
Prior to v2.0, series metadata was stored in two files per anime folder:
|
||||
|
||||
| File | Contents |
|
||||
| -------- | ------------------------------------------------------- |
|
||||
| `key` | Series provider key (e.g., `"attack-on-titan"`) |
|
||||
| `data` | JSON serialization of `Serie` object |
|
||||
|
||||
File structure example:
|
||||
```
|
||||
/anime/Attack on Titan (2013)/
|
||||
├── key # Contains: attack-on-titan
|
||||
├── data # Contains: {"key": "...", "name": "...", "episodeDict": {...}}
|
||||
├── Season 1/
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
### Database Storage (Current)
|
||||
|
||||
Since v2.0, all series metadata is stored in the `anime_series` table with `Episode` records for episode tracking. This provides:
|
||||
|
||||
- **ACID transactions** for data consistency
|
||||
- **Foreign key constraints** (cascade delete)
|
||||
- **Indexed queries** for fast lookups
|
||||
- **No filesystem dependency** for metadata
|
||||
|
||||
### Migration from Files to Database
|
||||
|
||||
The `Serie.save_to_file()` and `Serie.load_from_file()` methods are deprecated but still functional for backward compatibility during migration:
|
||||
|
||||
```python
|
||||
from src.core.entities.series import Serie
|
||||
|
||||
# Old file-based loading (deprecated)
|
||||
serie = Serie.load_from_file("/anime/Attack on Titan (2013)/data")
|
||||
|
||||
# New database-based loading
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
serie = await AnimeSeriesService.get_by_key(db, "attack-on-titan")
|
||||
```
|
||||
|
||||
### Removing File Dependencies
|
||||
|
||||
After verifying database schema supports all fields, file-based storage can be removed:
|
||||
|
||||
1. ✅ Schema verified: All `Serie` fields have corresponding DB columns
|
||||
2. ✅ Migration complete: All existing series migrated to database
|
||||
3. ❌ File cleanup: Remove `key` and `data` files (pending)
|
||||
|
||||
**Note:** The `save_to_file()` and `load_from_file()` methods will be removed in v3.0.0.
|
||||
|
||||
---
|
||||
|
||||
## 12. Series Persistence Flow
|
||||
|
||||
When a directory scan discovers or updates series, the scanner persists data to the database instead of writing to disk files.
|
||||
|
||||
### Scan Flow
|
||||
|
||||
```
|
||||
Scan Directory
|
||||
│
|
||||
▼
|
||||
Find MP4 Files → Extract Serie Key
|
||||
│
|
||||
▼
|
||||
Check DB for Existing Series (by key)
|
||||
│
|
||||
├─── EXISTS ──────────────────────► Update Series Metadata
|
||||
│ │
|
||||
│ ▼
|
||||
│ Sync Episodes to DB
|
||||
│ │
|
||||
│◄──────────────────────────────────────┘
|
||||
│
|
||||
└─── NEW ───────────────────────────► Create New Series Record
|
||||
│
|
||||
▼
|
||||
Create Episode Records
|
||||
│
|
||||
▼
|
||||
Return to Scan Loop
|
||||
```
|
||||
|
||||
### Key Methods
|
||||
|
||||
**SerieScanner._persist_serie_to_db()**
|
||||
- Called after `get_missing_episodes_and_season()` computes episodeDict
|
||||
- Uses `AnimeSeriesService.get_by_key()` to check if series exists
|
||||
- If exists: calls `AnimeSeriesService.update()` + `_sync_episodes_to_db()`
|
||||
- If new: calls `AnimeSeriesService.create()` + creates episodes
|
||||
|
||||
**SerieScanner._sync_episodes_to_db()**
|
||||
- Gets existing episodes from DB via `EpisodeService.get_by_series()`
|
||||
- Compares with new episodeDict
|
||||
- Removes episodes no longer missing (unless `is_downloaded=True`)
|
||||
- Adds new missing episodes
|
||||
- Preserves `is_downloaded=True` episodes when removing missing ones
|
||||
|
||||
**SerieList.add_to_db()**
|
||||
- Used when adding a new discovered series via API
|
||||
- Creates filesystem folder + database record + episode records
|
||||
|
||||
### Episode Sync Logic
|
||||
|
||||
```python
|
||||
# For each episode in DB but not in new episodeDict:
|
||||
if episode.is_downloaded:
|
||||
# Keep - file exists, don't remove
|
||||
pass
|
||||
else:
|
||||
# Remove - no longer missing
|
||||
EpisodeService.delete()
|
||||
|
||||
# For each episode in new episodeDict but not in DB:
|
||||
# Add as new missing episode
|
||||
EpisodeService.create(is_downloaded=False)
|
||||
```
|
||||
|
||||
### Transaction Handling
|
||||
|
||||
- DB operations use their own session with commit/rollback
|
||||
- If DB write fails, error is logged and scan continues
|
||||
- File-based `save_to_file()` no longer called during scan
|
||||
|
||||
### Migration Path
|
||||
|
||||
1. v2.x: Scanner writes to both DB (primary) and files (fallback)
|
||||
2. v3.0: Scanner writes only to DB, file methods removed
|
||||
|
||||
---
|
||||
|
||||
## 13. Series Persistence
|
||||
|
||||
### Schema
|
||||
|
||||
**AnimeSeries Table**: Stores series metadata (key, name, site, folder, year)
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|-----------|--------------|---------------------------|----------------------|
|
||||
| `id` | INTEGER | PRIMARY KEY | Auto-increment |
|
||||
| `key` | VARCHAR(255) | UNIQUE, NOT NULL | Series provider key |
|
||||
| `name` | VARCHAR(500) | NOT NULL | Display name |
|
||||
| `site` | VARCHAR(500) | | Provider site URL |
|
||||
| `folder` | VARCHAR(1000)| | Filesystem folder |
|
||||
|
||||
**Episode Table**: Stores per-episode metadata (season, episode_number, is_downloaded)
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|-----------------|--------------|---------------------------|----------------------|
|
||||
| `id` | INTEGER | PRIMARY KEY | Auto-increment |
|
||||
| `series_id` | INTEGER | FOREIGN KEY → anime_series| Parent series |
|
||||
| `season` | INTEGER | NOT NULL | Season number |
|
||||
| `episode_number`| INTEGER | NOT NULL | Episode number |
|
||||
| `is_downloaded` | BOOLEAN | DEFAULT FALSE | Download status |
|
||||
|
||||
### Relationships
|
||||
|
||||
- `AnimeSeries.episodes` → List of Episode objects (one-to-many)
|
||||
- `Episode.series` → Parent AnimeSeries (many-to-one)
|
||||
- Cascade delete: Deleting a series removes all its episodes
|
||||
|
||||
### Queries
|
||||
|
||||
```python
|
||||
# Get all series with episodes
|
||||
AnimeSeriesService.get_all(db, with_episodes=True)
|
||||
|
||||
# Get by provider key
|
||||
AnimeSeriesService.get_by_key(db, key)
|
||||
|
||||
# Get by folder path
|
||||
AnimeSeriesService.get_by_folder(db, folder)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Database Location
|
||||
|
||||
| Environment | Default Location |
|
||||
| ----------- | ------------------------------------------------- |
|
||||
| Development | `./data/aniworld.db` |
|
||||
| Production | Via `DATABASE_URL` environment variable |
|
||||
| Testing | In-memory SQLite (`sqlite+aiosqlite:///:memory:`) |
|
||||
436
Docs/DEVELOPMENT.md
Normal file
436
Docs/DEVELOPMENT.md
Normal file
@@ -0,0 +1,436 @@
|
||||
# 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
|
||||
|
||||
### Adding Queue Deduplication
|
||||
|
||||
The download queue prevents duplicate entries at two levels:
|
||||
|
||||
**In-Memory Deduplication** (`src/server/services/download_service.py`):
|
||||
- `_pending_by_episode` dict tracks pending episodes: key = `(serie_id, season, episode)`
|
||||
- `_add_to_pending_queue()` updates the dict when adding items
|
||||
- `add_to_queue()` checks this dict before adding episodes (includes batch-local dedup)
|
||||
- `_remove_from_pending_queue()` cleans up the dict when items are removed
|
||||
|
||||
**Database Constraint** (`src/server/models.py`):
|
||||
- `DownloadQueueItem` has a unique index on `episode_id` via `__table_args__`
|
||||
- Prevents duplicate queue entries at the database level
|
||||
- Unique constraint: `Index("ix_download_queue_episode_pending", "episode_id", unique=True)`
|
||||
|
||||
**Scheduler Cooldown** (`src/server/services/scheduler_service.py`):
|
||||
- `_last_auto_download_time` tracks when auto-download last ran
|
||||
- 5-minute cooldown prevents rapid re-triggers
|
||||
- Checked at start of `_auto_download_missing()`
|
||||
|
||||
### Episode Lifecycle
|
||||
|
||||
Episodes transition through states stored in the `episodes` table:
|
||||
|
||||
| State | `is_downloaded` | `file_path` | Description |
|
||||
|-------|----------------|-------------|-------------|
|
||||
| Missing | `False` | `NULL` | Episode not yet downloaded |
|
||||
| Downloaded | `True` | Set | Episode exists on disk |
|
||||
|
||||
**State Transitions:**
|
||||
1. **Missing → Downloaded**: When download completes, `_remove_episode_from_missing_list()` calls `EpisodeService.mark_downloaded()` to set `is_downloaded=True` and populate `file_path`. The episode record is NOT deleted.
|
||||
|
||||
**Query Implications:**
|
||||
- `get_series_with_missing_episodes()`: Filters for `is_downloaded=False` to find series with undownloaded episodes
|
||||
- `get_series_with_no_episodes()`: Finds series with `is_downloaded=False` episodes but NO `is_downloaded=True` episodes (completely unwatched series)
|
||||
|
||||
### Mocking the Download Queue
|
||||
|
||||
When testing components that use the download queue:
|
||||
|
||||
```python
|
||||
# Mock repository for unit tests
|
||||
class MockQueueRepository:
|
||||
def __init__(self):
|
||||
self._items: Dict[str, DownloadItem] = {}
|
||||
|
||||
async def save_item(self, item: DownloadItem) -> DownloadItem:
|
||||
self._items[item.id] = item
|
||||
return item
|
||||
|
||||
async def get_all_items(self) -> List[DownloadItem]:
|
||||
return list(self._items.values())
|
||||
|
||||
# Use in fixture
|
||||
@pytest.fixture
|
||||
def mock_queue_repository():
|
||||
return MockQueueRepository()
|
||||
|
||||
@pytest.fixture
|
||||
def download_service(mock_anime_service, mock_queue_repository):
|
||||
return DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
queue_repository=mock_queue_repository,
|
||||
max_retries=3,
|
||||
)
|
||||
```
|
||||
|
||||
9. Troubleshooting Development Issues
|
||||
|
||||
### Async Context Managers for aiohttp
|
||||
|
||||
All `aiohttp.ClientSession` usages must be wrapped in `async with`:
|
||||
|
||||
```python
|
||||
# Correct — session properly closed on exit
|
||||
async with TMDBClient(api_key="key") as client:
|
||||
result = await client.search_tv_show("Show")
|
||||
|
||||
# Wrong — session may leak if exception occurs
|
||||
client = TMDBClient(api_key="key")
|
||||
result = await client.search_tv_show("Show")
|
||||
await client.close() # May not be called if exception raised earlier
|
||||
```
|
||||
|
||||
**Why:**
|
||||
- `aiohttp.ClientSession` holds TCP connections that must be explicitly closed
|
||||
- If exception occurs before `close()`, session leaks
|
||||
- Context manager guarantees `__aexit__` runs even on exceptions
|
||||
|
||||
**Services that use aiohttp:**
|
||||
- `TMDBClient` — has `__aenter__`/`__aexit__`, use `async with`
|
||||
- `ImageDownloader` — has `__aenter__`/`__aexit__`, use `async with`
|
||||
- `NFOService` — wraps both above, use `async with`
|
||||
|
||||
**Verification:**
|
||||
- Missing context manager usage triggers `__del__` warning on garbage collection
|
||||
- Integration tests verify no "Unclosed client session" errors in logs
|
||||
|
||||
### Scheduler Persistence and Recovery
|
||||
|
||||
The scheduler uses APScheduler's in-memory job store. Jobs are reconstructed from `config.json` on every startup — no separate database is needed.
|
||||
|
||||
```python
|
||||
# Jobs are built from config on startup — no persistence DB required
|
||||
scheduler = AsyncIOScheduler() # default MemoryJobStore
|
||||
scheduler.add_job(..., replace_existing=True)
|
||||
```
|
||||
|
||||
**Startup misfire recovery:** On `start()`, the scheduler checks `system_settings.last_scan_timestamp` in `aniworld.db`. If the last scan is overdue (>23h but <25h ago), an immediate rescan is triggered. This replaces APScheduler's built-in misfire handling which required a separate SQLite database.
|
||||
|
||||
**Grace period:** If the server was down for more than 25 hours, no automatic recovery occurs to avoid surprise rescans after long downtime.
|
||||
|
||||
**Health endpoint:** `GET /health` returns `scheduler_next_run` and `scheduler_last_run` for external monitors (Uptime Kuma, Prometheus, etc.).
|
||||
|
||||
**If server is down too long:** Manual trigger via `POST /api/scheduler/trigger-rescan` or wait for next scheduled run.
|
||||
|
||||
### Database Session Management
|
||||
|
||||
`get_async_session_factory()` returns a **new AsyncSession instance** directly (not a factory). The function name is historical — callers receive the session immediately:
|
||||
|
||||
```python
|
||||
# Correct usage:
|
||||
db = get_async_session_factory() # db IS the session
|
||||
await db.execute(...)
|
||||
await db.commit()
|
||||
await db.close()
|
||||
```
|
||||
|
||||
Do NOT call the result again with `()` — that tries to call an `AsyncSession` object, causing `'AsyncSession' object is not callable`.
|
||||
|
||||
For context manager usage, prefer `get_db_session()` (auto-commits) or `get_transactional_session()` (manual commit).
|
||||
|
||||
### Health Check Endpoints
|
||||
|
||||
The application provides health check endpoints for monitoring and container orchestration:
|
||||
|
||||
#### `GET /health`
|
||||
Basic health check returning service status and startup health check results.
|
||||
|
||||
**Response fields:**
|
||||
- `status`: "healthy", "degraded", or "unhealthy" based on startup checks
|
||||
- `timestamp`: ISO timestamp of the check
|
||||
- `series_app_initialized`: Whether the series app is loaded
|
||||
- `anime_directory_configured`: Whether anime_directory is set
|
||||
- `scheduler_next_run` / `scheduler_last_run`: Scheduler times
|
||||
- `checks`: Detailed startup check results (ffmpeg, DNS, anime_directory)
|
||||
|
||||
#### `GET /health/ready`
|
||||
Readiness check for container orchestrators (Kubernetes, Docker Swarm).
|
||||
|
||||
**Response when ready:**
|
||||
```json
|
||||
{
|
||||
"status": "ready",
|
||||
"ready": true,
|
||||
"timestamp": "2024-01-01T00:00:00",
|
||||
"checks": {...}
|
||||
}
|
||||
```
|
||||
|
||||
**Response when not ready (503):**
|
||||
```json
|
||||
{
|
||||
"status": "not_ready",
|
||||
"ready": false,
|
||||
"timestamp": "2024-01-01T00:00:00",
|
||||
"critical_failures": ["anime_directory: not configured"],
|
||||
"checks": {...}
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /health/detailed`
|
||||
Comprehensive health check including database, filesystem, and system metrics.
|
||||
|
||||
#### Startup Health Checks
|
||||
|
||||
On application startup, the following checks are performed:
|
||||
|
||||
| Check | Failure Status | Impact |
|
||||
|-------|---------------|--------|
|
||||
| `ffmpeg` | warning | HLS downloads may fail |
|
||||
| `dns_aniworld` | warning | Provider requests may fail |
|
||||
| `dns_tmdb` | warning | TMDB API calls may fail |
|
||||
| `anime_directory` | error | Download service disabled |
|
||||
|
||||
DNS checks are warnings because failures can be transient. anime_directory errors disable the download service to prevent failures.
|
||||
|
||||
### Troubleshooting Development Issues
|
||||
|
||||
#### Scheduler missed a run
|
||||
|
||||
1. Server was down at scheduled time (03:00 UTC by default).
|
||||
2. On restart, the scheduler checks `last_scan_timestamp` — if overdue by 23-25h, it triggers immediately.
|
||||
3. If server was down >25 hours, missed job is skipped to avoid surprise rescans.
|
||||
4. Trigger manually: `POST /api/scheduler/trigger-rescan`
|
||||
5. Monitor next run: `GET /health` → `scheduler_next_run`
|
||||
|
||||
#### Scheduler not firing (no events at scheduled time)
|
||||
|
||||
If the scheduler appears configured but never triggers:
|
||||
|
||||
1. **Check application logs for scheduler startup:**
|
||||
```
|
||||
grep "Scheduler service started" fastapi_app.log
|
||||
```
|
||||
- If missing, the scheduler failed to start — check for errors above this line
|
||||
- If present, scheduler started successfully
|
||||
|
||||
2. **Verify the job is registered:**
|
||||
```
|
||||
grep "Scheduler started with cron trigger" fastapi_app.log
|
||||
```
|
||||
|
||||
3. **Verify APScheduler events in logs:**
|
||||
```
|
||||
grep "apscheduler.executors.default" fastapi_app.log
|
||||
```
|
||||
- `Running job` = job triggered
|
||||
- `executed successfully` = job completed
|
||||
- No output = job never fired
|
||||
|
||||
4. **Test manual trigger:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/scheduler/trigger-rescan -H "Authorization: Bearer <token>"
|
||||
```
|
||||
- If manual trigger works but cron doesn't, the issue is APScheduler configuration
|
||||
|
||||
5. **Check next_run_time via health endpoint:**
|
||||
```bash
|
||||
curl http://localhost:8000/health | jq .scheduler_next_run
|
||||
```
|
||||
- If `null`, the job is not scheduled
|
||||
- If set, the scheduler knows when to run next
|
||||
|
||||
6. **Check timezone handling:**
|
||||
- APScheduler uses UTC internally
|
||||
- The schedule_time config (e.g., "03:00") is interpreted as UTC
|
||||
- If you expect local time, adjust the schedule_time accordingly
|
||||
|
||||
#### Startup health check failures
|
||||
|
||||
If `/health` returns `unhealthy` status:
|
||||
|
||||
1. **anime_directory error**: Directory not configured or not writable
|
||||
- Check `ANIME_DIRECTORY` environment variable
|
||||
- Verify directory exists and permissions allow write access
|
||||
- Download service will not initialize until resolved
|
||||
|
||||
2. **ffmpeg warning**: ffmpeg not found in PATH
|
||||
- HLS stream downloads will fail
|
||||
- Install ffmpeg: `apt install ffmpeg` or `brew install ffmpeg`
|
||||
|
||||
3. **DNS warnings**: Domain resolution failed
|
||||
- Check network connectivity
|
||||
- DNS failures are transient — warnings don't block startup
|
||||
- Retry later to verify: `GET /health`
|
||||
|
||||
### Provider Failure Handling
|
||||
|
||||
Download providers (VOE, Doodstream, Vidmoly, Vidoza, SpeedFiles, Streamtape,
|
||||
Luluvdo) regularly break: URLs expire, sites change their player markup, geo
|
||||
blocks appear, and `yt-dlp` extractors lag behind upstream changes. The
|
||||
`AniworldLoader.download()` flow is designed to fail fast and rotate.
|
||||
|
||||
**Rotation order**
|
||||
|
||||
1. The episode page is scraped for the providers AniWorld actually advertises.
|
||||
2. Results are ordered by the preference in `DEFAULT_PROVIDERS`
|
||||
(`provider_config.py`); providers not listed run last.
|
||||
3. For each candidate the loader:
|
||||
1. Calls `_check_url_alive()` — HEAD probe with GET fallback. Any 4xx
|
||||
response or connection error skips the provider immediately.
|
||||
2. Resolves the redirect via `_resolve_direct_link()` to obtain a direct
|
||||
stream URL plus headers. Provider-specific extractors (e.g. `VOE`) are
|
||||
preferred; unknown providers fall back to the embed URL so `yt-dlp` can
|
||||
attempt extraction.
|
||||
3. Tries `_try_direct_stream()` — straight `requests.get(stream=True)` when
|
||||
`Content-Type` is `video/*` or `application/octet-stream`. This avoids
|
||||
`yt-dlp` entirely for direct MP4 links.
|
||||
4. Falls back to `yt-dlp` with the ffmpeg downloader for HLS streams.
|
||||
4. On any failure, temp files are cleaned and the loop moves to the next
|
||||
provider. When the chain is exhausted, the loader logs
|
||||
`All download providers failed for S{season}E{episode} ...; tried=[...]`
|
||||
to both the application log and `logs/download_errors.log`.
|
||||
|
||||
**Do not hardcode provider URLs.** Provider domains shift constantly (e.g.
|
||||
Doodstream alternates between `dood.li`, `dood.so`, `dood.la`). Only the
|
||||
referer hints in `PROVIDER_HEADERS` are persisted — discovery still happens
|
||||
at runtime through AniWorld's redirect endpoint.
|
||||
|
||||
### HLS Stream Handling
|
||||
|
||||
HLS (HTTP Live Streaming) manifests (`.m3u8`) require yt-dlp to use the
|
||||
`ffmpeg` downloader with `--hls-use-mpegts`. Both providers configure this
|
||||
automatically:
|
||||
|
||||
```python
|
||||
ydl_opts = {
|
||||
"downloader": "ffmpeg", # Use ffmpeg instead of native
|
||||
"hls_use_mpegts": True, # Write transport stream (.ts) segments
|
||||
}
|
||||
```
|
||||
|
||||
**Why this matters**: Without ffmpeg, yt-dlp logs:
|
||||
`"Live HLS streams are not supported by the native downloader"`
|
||||
|
||||
**Requirements**:
|
||||
- ffmpeg must be installed and in PATH (`which ffmpeg`)
|
||||
- Install: `apt install ffmpeg` (Debian/Ubuntu) or `brew install ffmpeg` (macOS)
|
||||
- Startup health check (see Health Check Endpoints) verifies ffmpeg presence
|
||||
|
||||
**Trade-offs**:
|
||||
- HLS downloads are slower than direct MP4 (reassembly of .ts segments)
|
||||
- Requires more disk space during download
|
||||
- May need post-processing if .ts format is not desired
|
||||
|
||||
**Detection**: VOE provider extracts HLS URLs via `HLS_PATTERN` regex. Other
|
||||
providers let yt-dlp auto-detect from URL/content-type.
|
||||
|
||||
### Updating yt-dlp
|
||||
|
||||
When extractors break (typical symptoms: every provider HEAD probe succeeds
|
||||
but `yt-dlp` raises `Unable to extract` or `HTTP Error 404`):
|
||||
|
||||
1. Check the upstream tracker first: https://github.com/yt-dlp/yt-dlp/issues
|
||||
2. Upgrade in the conda environment:
|
||||
```bash
|
||||
conda run -n AniWorld pip install --upgrade yt-dlp
|
||||
```
|
||||
3. Smoke-test against a known-good episode before pinning a new floor in
|
||||
`requirements.txt` (`yt-dlp>=YYYY.MM.DD`).
|
||||
4. Re-run the provider test suite:
|
||||
```bash
|
||||
conda run -n AniWorld python -m pytest tests/unit/test_aniworld_provider.py -v
|
||||
```
|
||||
5. If a specific extractor is removed upstream, drop the provider from
|
||||
`DEFAULT_PROVIDERS` rather than patching `yt-dlp` in tree.
|
||||
|
||||
### User Notification on Total Failure
|
||||
|
||||
`SeriesApp.download_episode()` already emits a `download_status="failed"`
|
||||
WebSocket event when `loader.download()` returns `False`. Operators should
|
||||
forward this to `notification_service.notify_download_failed()` so users see
|
||||
a HIGH-priority alert. The loader keeps the failure detail in
|
||||
`logs/download_errors.log` for post-mortem.
|
||||
|
||||
## Series Storage
|
||||
|
||||
### Overview
|
||||
|
||||
Series metadata now stored in the database (SQLAlchemy ORM).
|
||||
Legacy files (`key` and `data` per folder) are deprecated but preserved
|
||||
for backward compatibility.
|
||||
|
||||
### Architecture
|
||||
|
||||
- **Database**: Single source of truth for all series metadata
|
||||
- **In-Memory Cache**: SeriesApp maintains a cache for performance
|
||||
- **Filesystem**: Only used for episode files themselves, not metadata
|
||||
|
||||
### Migration
|
||||
|
||||
First startup after upgrade automatically imports any legacy
|
||||
series files into the database.
|
||||
|
||||
### Legacy Files
|
||||
|
||||
- `key` file: Contains series provider key (deprecated)
|
||||
- `data` file: Contains Serie JSON object (deprecated)
|
||||
|
||||
Both are safe to delete after migration; not needed for normal operation.
|
||||
|
||||
94
Docs/InstructionsLogging.md
Normal file
94
Docs/InstructionsLogging.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Logging Instructions
|
||||
|
||||
This document describes how to write and refactor logging across the AniWorld codebase to make logs **human-readable**, **debug-friendly**, and **noise-free**.
|
||||
|
||||
> ✅ Goal: Logs should help a developer understand what happened, why it happened, and what to inspect next — without overwhelming them with duplicates or irrelevant details.
|
||||
|
||||
---
|
||||
|
||||
## 1. Principles for Great Logs
|
||||
|
||||
### 1.1 Use the Right Log Level
|
||||
|
||||
- `DEBUG`: Detailed internal state useful when debugging a specific issue (e.g., decision points, returned values, request/response payloads). Not for normal operation.
|
||||
- `INFO`: High-level events that represent what the system is doing (e.g., "Import started", "New series added", "Config reloaded"). Use sparingly.
|
||||
- `WARNING`: Something unexpected happened, but the system can continue (e.g., missing optional file, fallback behavior).
|
||||
- `ERROR`: An operation failed and needs attention (e.g., exception caught, failed database write).
|
||||
- `CRITICAL`: The system is in an unusable state (e.g., config corruption, failed startup).
|
||||
|
||||
### 1.2 Keep Logs Human-Readable
|
||||
|
||||
- Write messages in a clear, descriptive sentence-style format.
|
||||
- Avoid cryptic codes or single-word log messages.
|
||||
- Prefer `logger.debug("... %s", value)`-style formatting over f-strings to avoid unnecessary work when the log level is disabled.
|
||||
|
||||
### 1.3 Avoid Log Spam
|
||||
|
||||
- Don’t log inside hot loops unless you explicitly aggregate and log a summary (e.g., "Processed 124 files, 3 failures").
|
||||
- Avoid repeated/logging the same event at the same level (e.g., do not log "Retrying" 10 times at INFO; log once at INFO and then use DEBUG for each retry).
|
||||
- Use rate limiting or debounce patterns for logs that can fire rapidly (e.g., external service health checks).
|
||||
- Prefer a single higher-level log with context rather than many low-level logs that clutter output.
|
||||
|
||||
### 1.4 Log Objects Usefully
|
||||
|
||||
- When logging objects, log the minimal useful representation (e.g., ID, name, status) rather than the full object or its memory address.
|
||||
- If an object has a `.dict()`, `.to_dict()`, or `.as_dict()` helper (common in Pydantic models), log that rather than relying on `repr()`.
|
||||
- Add a `__repr__` or `__str__` implementation to domain models that returns a helpful, concise string with key identifiers.
|
||||
- Use structured logging (e.g., `logger.info("Series added", extra={"series_id": series.id, "title": series.title})`) where supported.
|
||||
- For exceptions, prefer `logger.exception("Failed to ...")` to capture stack traces.
|
||||
|
||||
---
|
||||
|
||||
## 2. Refactoring Existing Logs
|
||||
|
||||
When improving or refactoring existing log statements, aim to make them:
|
||||
|
||||
- **Actionable**: A developer reading the log should know what happened and what to check next.
|
||||
- **Non-redundant**: Remove duplicates and ensure only one log records the same high-level event at a given level.
|
||||
- **Context-rich**: Include identifiers (e.g., `series_id`, `file_path`, `user_id`) and key state that explains why a decision was made.
|
||||
- **Level-appropriate**: Downgrade noisy INFO logs to DEBUG, and elevate critical failures to ERROR/CRITICAL.
|
||||
|
||||
### 2.1 Refactor Checklist
|
||||
|
||||
1. **Locate noisy logs**: Search for repeated messages (e.g., "Start", "Done") and determine whether they should be DEBUG or removed.
|
||||
2. **Replace ad-hoc prints**: Remove `print()` statements or `print(obj)` and replace with `logger.*` calls.
|
||||
3. **Use structured context**: If a function logs multiple related messages, include the same context in each (e.g., `extra={"series_id": series.id}`) or use a context manager that attaches it.
|
||||
4. **Validate object output**: Ensure any logged object produces a useful representation (add methods or translate to dict). If not, log the key fields explicitly.
|
||||
5. **Batch repetitive events**: If a loop logs per item, consider collecting stats and logging a summary at the end.
|
||||
|
||||
## 3. Adding New Logs
|
||||
|
||||
When adding logs to new code paths:
|
||||
|
||||
- Log **important state transitions** (e.g., "Queue started", "Download completed", "Config reloaded").
|
||||
- For error paths, include what failed and why (e.g., "Could not load config from X: {exc}").
|
||||
- Prefer logging at the boundaries of operations, not deep inside utility functions unless it aids debugging.
|
||||
- Write logs in full sentences, with a clear subject, verb, and object.
|
||||
|
||||
---
|
||||
|
||||
## 4. Example Patterns
|
||||
|
||||
```python
|
||||
logger.info("Import completed", extra={"series_id": series.id, "count": len(imported)})
|
||||
|
||||
logger.debug(
|
||||
"Fetched feed items",
|
||||
extra={"feed_url": feed.url, "item_count": len(items)},
|
||||
)
|
||||
|
||||
try:
|
||||
result = download_episode(episode)
|
||||
except Exception:
|
||||
logger.exception("Failed to download episode %s", episode.id)
|
||||
```
|
||||
|
||||
> 💡 When in doubt, favor **fewer, richer logs** over many noisy logs.
|
||||
|
||||
---
|
||||
|
||||
## 5. Logging Audit Task List
|
||||
|
||||
For a guided checklist of files and logging improvements, see **`docs/tasks.md`**. This is where we track which files have been reviewed and which logging items still need attention.
|
||||
|
||||
> ✅ After applying the guidelines above, update `docs/tasks.md` to indicate which tasks are complete.
|
||||
234
Docs/NAVIGATION.md
Normal file
234
Docs/NAVIGATION.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# Navigation & Redirect Logic
|
||||
|
||||
This document describes the setup flow navigation, covering how users progress from initial setup through to the main application.
|
||||
|
||||
## Overview
|
||||
|
||||
The application uses a middleware-based redirect system to enforce a strict state machine. Users must complete each phase before accessing the next. Attempting to bypass the current phase redirects to the appropriate page.
|
||||
|
||||
## State Machine
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ NAVIGATION STATES │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ NO_SETUP ──────────► SETUP_COMPLETE ──────────► UNRESOLVED_PENDING │
|
||||
│ │ │ │ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ /setup /loading /setup/unresolved │
|
||||
│ (series scan) (resolve folders) │
|
||||
│ │
|
||||
│ UNRESOLVED_DONE ───────┐
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ NFO_SCAN_PENDING │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ /loading │
|
||||
│ (NFO scan) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ COMPLETE │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ /login │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## State Definitions
|
||||
|
||||
| State | Condition | Target Page |
|
||||
|-------|-----------|-------------|
|
||||
| `NO_SETUP` | No master password configured | `/setup` |
|
||||
| `SETUP_COMPLETE` | Initial config passed, loading not started | `/loading` |
|
||||
| `UNRESOLVED_PENDING` | Setup done, unresolved exist, not marked done | `/setup/unresolved` |
|
||||
| `UNRESOLVED_DONE` | Unresolved phase marked complete, NFO scan pending | `/loading` |
|
||||
| `NFO_SCAN_PENDING` | Unresolved done, NFO scan incomplete | `/loading` |
|
||||
| `COMPLETE` | All phases finished | `/login` |
|
||||
|
||||
## Middleware: SetupRedirectMiddleware
|
||||
|
||||
**File:** `src/server/middleware/setup_redirect.py`
|
||||
|
||||
The middleware intercepts all requests and enforces the state machine.
|
||||
|
||||
### Exempt Paths (always accessible)
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `/setup` | Initial setup page |
|
||||
| `/setup/unresolved` | Unresolved folder resolution |
|
||||
| `/loading` | Initialization progress page |
|
||||
| `/login` | Authentication |
|
||||
| `/api/auth/*` | Auth endpoints |
|
||||
| `/api/config/*` | Config API |
|
||||
| `/api/health` | Health check |
|
||||
| `/static/*` | Static assets |
|
||||
|
||||
### Middleware Logic
|
||||
|
||||
The middleware checks the current state and redirects accordingly:
|
||||
|
||||
```
|
||||
1. NO_SETUP state:
|
||||
→ Redirect ALL requests to /setup
|
||||
→ Exception: /setup itself is accessible
|
||||
|
||||
2. SETUP_COMPLETE state:
|
||||
→ Redirect /setup to /loading
|
||||
→ Redirect any other page to /loading
|
||||
|
||||
3. UNRESOLVED_PENDING state (unresolved folders exist, not marked done):
|
||||
→ Redirect /setup to /setup/unresolved
|
||||
→ Redirect /loading to /setup/unresolved
|
||||
→ Allow access to /setup/unresolved
|
||||
→ Redirect any other page to /setup/unresolved
|
||||
|
||||
4. UNRESOLVED_DONE state (unresolved marked done, NFO scan pending):
|
||||
→ Redirect /setup to /loading
|
||||
→ Redirect /setup/unresolved to /loading
|
||||
→ Redirect any other page to /loading
|
||||
|
||||
5. NFO_SCAN_PENDING state:
|
||||
→ Redirect /setup to /loading
|
||||
→ Redirect /setup/unresolved to /loading
|
||||
→ Allow access to /loading (NFO phase runs)
|
||||
→ Redirect any other page to /loading
|
||||
|
||||
6. COMPLETE state (loading finished):
|
||||
→ Redirect /setup, /loading, /setup/unresolved to /login
|
||||
→ Allow access to /login and main app
|
||||
```
|
||||
|
||||
### Phase Tracking Flags
|
||||
|
||||
| Flag | Purpose |
|
||||
|------|---------|
|
||||
| `setup_complete` | Initial configuration was saved |
|
||||
| `loading_started` | Loading phase has been initiated (redirected to /loading) |
|
||||
| `unresolved_completed` | User clicked "Done" on unresolved page |
|
||||
| `loading_complete` | Series scan + initial loading finished |
|
||||
| `nfo_scan_complete` | Final NFO scan finished |
|
||||
|
||||
## Pages
|
||||
|
||||
### 1. Setup Page (`/setup`)
|
||||
|
||||
**File:** `src/server/web/templates/setup.html`
|
||||
|
||||
Handles initial configuration:
|
||||
- Master password creation
|
||||
- Anime directory selection
|
||||
- Database initialization
|
||||
|
||||
**Allowed in states:** `NO_SETUP`
|
||||
|
||||
**Post-completion:**
|
||||
- Sets `setup_complete` flag
|
||||
- Redirects to `/loading`
|
||||
|
||||
### 2. Loading Page (`/loading`)
|
||||
|
||||
**File:** `src/server/web/templates/loading.html`
|
||||
|
||||
Shows initialization progress via WebSocket:
|
||||
- Series scanning
|
||||
- Database population
|
||||
- Logo/image loading
|
||||
|
||||
**Allowed in states:** `SETUP_COMPLETE`, `UNRESOLVED_DONE`, `NFO_SCAN_PENDING`
|
||||
|
||||
**Post-initialization (series scan complete):**
|
||||
```javascript
|
||||
async function checkUnresolvedAndProceed() {
|
||||
const res = await fetch('/api/setup/unresolved', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const folders = await res.json();
|
||||
|
||||
if (folders.length > 0) {
|
||||
window.location.href = '/setup/unresolved';
|
||||
} else {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Post-NFO scan:**
|
||||
- Sets `nfo_scan_complete` flag
|
||||
- Redirects to `/login`
|
||||
|
||||
### 3. Unresolved Folders Page (`/setup/unresolved`)
|
||||
|
||||
**File:** `src/server/web/templates/unresolved.html`
|
||||
|
||||
Allows manual resolution of folders that couldn't be auto-matched:
|
||||
- Shows list of unresolved folders
|
||||
- Provides search suggestions
|
||||
- Input field for entering provider key
|
||||
- Resolve/delete actions
|
||||
- **Done button** to complete the phase without resolving all folders
|
||||
|
||||
**Allowed in states:** `UNRESOLVED_PENDING`
|
||||
|
||||
**Done button behavior:**
|
||||
- Sets `unresolved_completed` flag
|
||||
- Redirects to `/loading` for final NFO scan
|
||||
|
||||
**After completion:**
|
||||
- Any access redirects to `/loading`
|
||||
|
||||
### 4. Login Page (`/login`)
|
||||
|
||||
**File:** `src/server/web/templates/login.html`
|
||||
|
||||
Authentication page. After successful login → redirect to `/` (main app).
|
||||
|
||||
**Allowed in states:** `COMPLETE`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Unresolved Folders API
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/setup/unresolved` | List all unresolved folders |
|
||||
| `GET` | `/api/setup/unresolved/{folder_name}` | Get specific folder details |
|
||||
| `POST` | `/api/setup/unresolved/{folder_name}/resolve` | Resolve with provider key |
|
||||
| `POST` | `/api/setup/unresolved/{folder_name}/search` | Re-search for matches |
|
||||
| `DELETE` | `/api/setup/unresolved/{folder_name}` | Remove folder from tracking |
|
||||
| `POST` | `/api/setup/unresolved/done` | Mark unresolved phase as complete |
|
||||
|
||||
### Auth API
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/auth/setup` | Create master password |
|
||||
| `POST` | `/api/auth/login` | Authenticate |
|
||||
| `POST` | `/api/auth/logout` | End session |
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/server/middleware/setup_redirect.py` | Redirect middleware (state machine) |
|
||||
| `src/server/controllers/page_controller.py` | Page route handlers |
|
||||
| `src/server/web/templates/setup.html` | Setup template |
|
||||
| `src/server/web/templates/loading.html` | Loading template |
|
||||
| `src/server/web/templates/unresolved.html` | Unresolved folders template |
|
||||
| `src/server/api/setup_endpoints.py` | Unresolved folders API |
|
||||
| `src/server/database/service.py` | UnresolvedFolderService |
|
||||
|
||||
## Navigation Summary
|
||||
|
||||
| Current State | Access `/setup` | Access `/loading` | Access `/setup/unresolved` |
|
||||
|--------------|-----------------|-------------------|---------------------------|
|
||||
| NO_SETUP | ✅ Allowed | ❌ → `/setup` | ❌ → `/setup` |
|
||||
| SETUP_COMPLETE | ❌ → `/loading` | ✅ Allowed | ❌ → `/loading` |
|
||||
| UNRESOLVED_PENDING | ❌ → `/setup/unresolved` | ❌ → `/setup/unresolved` | ✅ Allowed |
|
||||
| UNRESOLVED_DONE | ❌ → `/loading` | ✅ Allowed (NFO phase) | ❌ → `/loading` |
|
||||
| NFO_SCAN_PENDING | ❌ → `/loading` | ✅ Allowed (NFO phase) | ❌ → `/loading` |
|
||||
| COMPLETE | ❌ → `/login` | ❌ → `/login` | ❌ → `/login` |
|
||||
905
Docs/NFO_GUIDE.md
Normal file
905
Docs/NFO_GUIDE.md
Normal file
@@ -0,0 +1,905 @@
|
||||
# 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
|
||||
}
|
||||
```
|
||||
|
||||
### 3.6 Fallback Behavior When TMDB is Unavailable
|
||||
|
||||
When TMDB lookup fails (network issues, API errors, or no match found), the system creates a **minimal NFO** to ensure the series is still tracked. This behavior applies to:
|
||||
|
||||
- Manual NFO creation via API
|
||||
- Batch NFO creation operations
|
||||
- Automatic NFO creation during downloads
|
||||
|
||||
**What a minimal NFO contains:**
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<tvshow>
|
||||
<title>Series Name</title>
|
||||
<year>2024</year>
|
||||
<plot>No metadata available for Series Name. TMDB lookup failed.</plot>
|
||||
</tvshow>
|
||||
```
|
||||
|
||||
**Limitations of minimal NFOs:**
|
||||
- No poster, logo, or fanart images
|
||||
- No rating, genre, or studio information
|
||||
- No TMDB or other provider IDs
|
||||
- May not display correctly in some media servers
|
||||
|
||||
**To upgrade a minimal NFO:**
|
||||
1. Use the Update endpoint (`PUT /api/nfo/{serie_id}/update`) when TMDB is available
|
||||
2. Or delete the NFO and recreate it with full metadata
|
||||
|
||||
---
|
||||
|
||||
## 4. File Structure
|
||||
|
||||
### 4.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>
|
||||
<tmdbid>1429</tmdbid>
|
||||
<thumb aspect="poster">https://image.tmdb.org/t/p/w500/...</thumb>
|
||||
<fanart>
|
||||
<thumb>https://image.tmdb.org/t/p/original/...</thumb>
|
||||
</fanart>
|
||||
</tvshow>
|
||||
```
|
||||
|
||||
**Manual TMDB ID Override**: To skip TMDB search and use a specific ID directly, include `<tmdbid>YOUR_ID</tmdbid>` in the NFO. This is useful when:
|
||||
- TMDB search fails for your series (e.g., new or obscure anime)
|
||||
- You already know the correct TMDB ID
|
||||
- You want to avoid rate limiting from repeated searches
|
||||
|
||||
Aniworld reads `<tmdbid>` element and `<uniqueid type="tmdb">` first. If found, it uses the ID directly instead of searching.
|
||||
|
||||
### 4.3 Episode NFO Format
|
||||
|
||||
```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. Folder Naming Convention
|
||||
|
||||
### 5.1 Expected Format
|
||||
|
||||
After the daily folder scan (when **Update on library scan** is enabled), Aniworld validates every series folder against its `tvshow.nfo` metadata. If the folder name does not match the expected convention, it is automatically renamed.
|
||||
|
||||
**Format:**
|
||||
|
||||
```
|
||||
{title} ({year})
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
|
||||
| NFO `<title>` | NFO `<year>` | Expected Folder Name |
|
||||
|---------------|--------------|----------------------|
|
||||
| `Attack on Titan` | `2013` | `Attack on Titan (2013)` |
|
||||
| `One Piece` | `1999` | `One Piece (1999)` |
|
||||
| `Demon Slayer: Kimetsu no Yaiba` | `2019` | `Demon Slayer Kimetsu no Yaiba (2019)` |
|
||||
|
||||
### 5.2 Sanitization Rules
|
||||
|
||||
Illegal filesystem characters are removed or replaced to ensure cross-platform compatibility:
|
||||
|
||||
- Removed: `< > : " / \ | ? *` and null bytes
|
||||
- Control characters stripped
|
||||
- Multiple spaces collapsed to one
|
||||
- Leading/trailing dots and whitespace trimmed
|
||||
- Maximum length: 200 characters (truncated at word boundary if possible)
|
||||
|
||||
### 5.3 Skip Conditions
|
||||
|
||||
A folder is **not** renamed when any of the following apply:
|
||||
|
||||
- `tvshow.nfo` is missing `<title>` or `<year>` (or they are empty)
|
||||
- The series has an **active or pending download**
|
||||
- The target folder name already exists (duplicate)
|
||||
- The resulting path would exceed the OS path-length limit
|
||||
- The app lacks write permission to the anime directory
|
||||
|
||||
All skipped and renamed actions are logged.
|
||||
|
||||
---
|
||||
|
||||
## 6. Poster Check
|
||||
|
||||
### 6.1 Overview
|
||||
|
||||
During the daily folder scan, Aniworld checks every series folder for a valid `poster.jpg`. If the file is missing or smaller than 1 KB, the application attempts to re-download it from the URL stored in the series' `tvshow.nfo` file.
|
||||
|
||||
### 6.2 How It Works
|
||||
|
||||
1. **Scan** — After folder renaming, the scan iterates over all series folders that contain a `tvshow.nfo`.
|
||||
2. **Validate** — For each folder, it checks whether `poster.jpg` exists and is at least 1 KB.
|
||||
3. **Parse NFO** — If the poster is missing or too small, the scan reads `tvshow.nfo` and looks for a `<thumb aspect="poster">` (or any `<thumb>`) URL.
|
||||
4. **Download** — If a URL is found, the poster is downloaded using `ImageDownloader` with a concurrency limit of 3 simultaneous downloads.
|
||||
5. **Validate Download** — The downloaded image is validated with PIL to ensure it is not corrupted.
|
||||
|
||||
### 6.3 Skip Conditions
|
||||
|
||||
A folder is **not** processed for poster download when any of the following apply:
|
||||
|
||||
- `tvshow.nfo` does not exist in the folder.
|
||||
- `poster.jpg` already exists and is ≥ 1 KB.
|
||||
- No `<thumb>` URL is found in the NFO (the NFO may have been created before thumb tags were added).
|
||||
- The `nfo.download_poster` setting is `false` (poster checks are still performed, but downloads are skipped if the setting is disabled; see [CONFIGURATION.md](CONFIGURATION.md)).
|
||||
|
||||
### 6.4 Logging
|
||||
|
||||
Every poster check action is logged:
|
||||
|
||||
- **INFO** — When a poster is successfully downloaded.
|
||||
- **WARNING** — When a download fails or no URL is found.
|
||||
- **ERROR** — When an unexpected exception occurs during download.
|
||||
|
||||
---
|
||||
|
||||
## 7. API Reference
|
||||
|
||||
### 5.1 Check NFO Status
|
||||
|
||||
**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
|
||||
|
||||
### 6.7 TMDB Lookup Fails for My Series
|
||||
|
||||
**Problem**: TMDB search fails with "No results found" for a valid series.
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. **Check if series exists on TMDB**: Visit https://www.themoviedb.org and search for your series
|
||||
2. **Use manual ID override**: Add TMDB ID directly to `tvshow.nfo`:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<tvshow>
|
||||
<title>Your Series Name</title>
|
||||
<tmdbid>12345</tmdbid>
|
||||
<uniqueid type="tmdb">12345</uniqueid>
|
||||
</tvshow>
|
||||
```
|
||||
Aniworld will use this ID directly instead of searching.
|
||||
|
||||
3. **Try alternative titles**: Some anime have different titles (Japanese, romaji, English). If you have access to the folder, rename it to match the TMDB title.
|
||||
|
||||
4. **Add to existing NFO**: If `tvshow.nfo` exists but has no TMDB ID, edit it to add:
|
||||
```xml
|
||||
<tmdbid>YOUR_TMDB_ID</tmdbid>
|
||||
```
|
||||
Then use the Update endpoint to refresh metadata.
|
||||
|
||||
5. **Check for rate limiting**: If many lookups fail at once, you may be hitting TMDB rate limits. Wait and retry later.
|
||||
|
||||
6. **Verify API key**: Ensure your TMDB API key is valid and has not exceeded usage limits.
|
||||
|
||||
---
|
||||
|
||||
## 7. Best Practices
|
||||
|
||||
### 7.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
|
||||
|
||||
NFO repair now runs as part of the scheduled daily folder scan rather than on every
|
||||
startup. When the scheduler triggers `FolderScanService.run_folder_scan()`, the first
|
||||
step is `perform_nfo_repair_scan(background_loader=None)`. Each incomplete NFO is
|
||||
queued as a background `asyncio` task, so the scan returns quickly while repairs
|
||||
continue asynchronously.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Scan** — `perform_nfo_repair_scan()` in
|
||||
`src/server/services/initialization_service.py` is called from
|
||||
`FolderScanService.run_folder_scan()` (`src/server/services/folder_scan_service.py`).
|
||||
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 `asyncio.create_task`. Each task creates its own isolated
|
||||
:class:`NFOService` / :class:`TMDBClient` so concurrent tasks never share an
|
||||
``aiohttp`` session — this prevents "Connector is closed" errors when many repairs
|
||||
run in parallel. A semaphore caps TMDB concurrency at 3 to stay within rate limits.
|
||||
|
||||
### 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/folder_scan_service.py` | `perform_nfo_repair_scan` — invoked during the scheduled daily folder scan |
|
||||
|
||||
---
|
||||
|
||||
## 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/
|
||||
39
Docs/README.md
Normal file
39
Docs/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 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
|
||||
146
Docs/TESTING.md
Normal file
146
Docs/TESTING.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# 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
|
||||
|
||||
### Mocking the Download Queue
|
||||
|
||||
Use `MockQueueRepository` for testing download queue functionality:
|
||||
|
||||
```python
|
||||
from src.server.models.download import DownloadItem, EpisodeIdentifier
|
||||
|
||||
class MockQueueRepository:
|
||||
def __init__(self):
|
||||
self._items: Dict[str, DownloadItem] = {}
|
||||
```
|
||||
|
||||
### Testing SetupService
|
||||
|
||||
SetupService handles series key resolution from folder names during library setup. Test file: `tests/unit/test_setup_service.py`.
|
||||
|
||||
Key methods tested:
|
||||
- `_extract_year_from_folder_name()` — parses `(YYYY)` suffix
|
||||
- `_extract_title_from_folder_name()` — strips year suffix
|
||||
- `_resolve_key_via_search()` — resolves provider key via fuzzy title matching
|
||||
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_key_when_single_exact_match(self):
|
||||
"""Search returns 1 result with same name → returns key."""
|
||||
mock_series_app = AsyncMock()
|
||||
mock_series_app.search.return_value = [
|
||||
{'title': 'Attack on Titan', 'link': '/anime/stream/attack-on-titan'}
|
||||
]
|
||||
|
||||
with patch('src.server.services.setup_service.get_series_app', return_value=mock_series_app):
|
||||
result = await SetupService._resolve_key_via_search("Attack on Titan")
|
||||
|
||||
assert result == 'attack-on-titan'
|
||||
```
|
||||
|
||||
### Mocking aiohttp Sessions
|
||||
|
||||
When testing code that uses `aiohttp.ClientSession`:
|
||||
|
||||
```python
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from aiohttp import ClientSession
|
||||
|
||||
# Mock aiohttp session for testing
|
||||
class MockAiohttpSession:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
def get(self, url, **kwargs):
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={"data": "test"})
|
||||
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
mock_response.__aexit__ = AsyncMock(return_value=None)
|
||||
return mock_response
|
||||
|
||||
# Use in fixture
|
||||
@pytest.fixture
|
||||
async def mock_tmdb_session():
|
||||
session = MockAiohttpSession()
|
||||
yield session
|
||||
# Cleanup verification
|
||||
assert session.closed, "Session was not closed"
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- Always verify `session.closed` is `True` after context manager exits
|
||||
- Mock `__aenter__` and `__aexit__` for response context managers
|
||||
- Set `closed = False` on mock session for unclosed warning tests
|
||||
|
||||
7. Coverage Requirements
|
||||
8. CI/CD Integration
|
||||
9. Writing Good Tests
|
||||
- Arrange-Act-Assert pattern
|
||||
- Test isolation
|
||||
- Edge cases
|
||||
10. Common Pitfalls to Avoid
|
||||
23
Docs/diagrams/README.md
Normal file
23
Docs/diagrams/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# 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)
|
||||
44
Docs/diagrams/download-flow.mmd
Normal file
44
Docs/diagrams/download-flow.mmd
Normal file
@@ -0,0 +1,44 @@
|
||||
%%{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
|
||||
88
Docs/diagrams/system-architecture.mmd
Normal file
88
Docs/diagrams/system-architecture.mmd
Normal file
@@ -0,0 +1,88 @@
|
||||
%%{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"]
|
||||
SeriesCache["SeriesCache<br/>(In-Memory)"]
|
||||
SerieScanner["SerieScanner"]
|
||||
SerieList["SerieList"]
|
||||
end
|
||||
|
||||
subgraph Data["Data Layer"]
|
||||
SQLite[("SQLite<br/>aniworld.db")]
|
||||
ConfigJSON[(config.json)]
|
||||
FileSystem[(File System<br/>Anime Episodes)]
|
||||
LegacyFiles[("Legacy Files<br/>key/data<br/>(Deprecated)")]
|
||||
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 --> SeriesCache
|
||||
SeriesCache -.->|Cached Series| SQLite
|
||||
SeriesApp --> SerieScanner
|
||||
SeriesApp --> SerieList
|
||||
SerieScanner -->|Scan Episodes| FileSystem
|
||||
SerieScanner -->|Detect Series| SQLite
|
||||
SerieScanner -->|Migrate Legacy| LegacyFiles
|
||||
SerieScanner --> Provider
|
||||
|
||||
%% Event flow
|
||||
ProgressService -.->|Events| WebSocketService
|
||||
DownloadService -.->|Progress| ProgressService
|
||||
WebSocketService -.->|Broadcast| Browser
|
||||
118
Docs/features.md
Normal file
118
Docs/features.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# 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
|
||||
- **Library Filters**:
|
||||
- "Missing Episodes Only" (shows only series with missing episodes, including series that currently have no downloaded episodes)
|
||||
- "No Episodes" (shows series that are present in the library but have zero downloaded episodes)
|
||||
- "Show All Series" (overrides other filters to show every series)
|
||||
- **Database-Backed Series Storage**: All series metadata and missing episodes stored in SQLite database
|
||||
- **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
|
||||
|
||||
## Folder Management
|
||||
|
||||
- **Fuzzy Series Key Resolution**: Automatic series key resolution from folder names using fuzzy title matching — tolerates title variations like `(TV)`, `(OVA)`, `(Movie)` suffixes and uses similarity matching to resolve provider keys during library setup
|
||||
|
||||
## 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.
|
||||
@@ -8,38 +8,47 @@ The goal is to create a FastAPI-based web application that provides a modern int
|
||||
|
||||
## 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
|
||||
- **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
|
||||
- **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
|
||||
- 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
|
||||
## <EFBFBD> Credentials
|
||||
|
||||
**Admin Login:**
|
||||
|
||||
- Username: `admin`
|
||||
- Password: `Hallo123!`
|
||||
|
||||
---
|
||||
|
||||
## <20>📚 Helpful Commands
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
@@ -86,37 +95,25 @@ conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.
|
||||
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
|
||||
- [ ] 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
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
|
||||
---
|
||||
7
Docs/key
Normal file
7
Docs/key
Normal file
@@ -0,0 +1,7 @@
|
||||
API key : 299ae8f630a31bda814263c551361448
|
||||
9bc3e547caff878615cbdba2cc421d37
|
||||
|
||||
/setup
|
||||
|
||||
|
||||
SeriesApp initialized for directory:
|
||||
154
Docs/runner.csx
Normal file
154
Docs/runner.csx
Normal file
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env dotnet-script
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
|
||||
// ── Ctrl+C: kill active process and exit cleanly ──────────────────────────────
|
||||
var cts = new CancellationTokenSource();
|
||||
Process? activeProcess = null;
|
||||
|
||||
Console.CancelKeyPress += (_, e) =>
|
||||
{
|
||||
e.Cancel = true;
|
||||
Console.WriteLine("\n[runner] Interrupted — shutting down...");
|
||||
cts.Cancel();
|
||||
try { activeProcess?.Kill(entireProcessTree: true); } catch { }
|
||||
};
|
||||
|
||||
// ── Paths ─────────────────────────────────────────────────────────────────────
|
||||
var repoRoot = Directory.GetCurrentDirectory();
|
||||
var tasksFile = Path.Combine(repoRoot, "Docs", "Tasks.md");
|
||||
|
||||
if (!File.Exists(tasksFile))
|
||||
{
|
||||
Console.Error.WriteLine($"[runner] ERROR: Tasks.md not found at {tasksFile}");
|
||||
Console.Error.WriteLine("[runner] Run this script from the repository root.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
// ── Read & split by "---" separator lines ────────────────────────────────────
|
||||
var content = File.ReadAllText(tasksFile);
|
||||
var items = Regex
|
||||
.Split(content, @"\r?\n---\r?\n")
|
||||
.Select(s => s.Trim())
|
||||
.Where(s => s.Length > 0)
|
||||
.ToList();
|
||||
|
||||
Console.WriteLine($"[runner] Found {items.Count} section(s) in Tasks.md");
|
||||
|
||||
// ── Helper: run copilot and stream output, return full output ─────────────────
|
||||
async Task<string> RunCopilot(IEnumerable<string> extraArgs, string prompt)
|
||||
{
|
||||
var output = new StringBuilder();
|
||||
|
||||
var argList = new List<string> { "launch", "copilot", "--model", "minimax-m2.7:cloud", "--yes", "--", "--allow-all-tools" };
|
||||
argList.AddRange(extraArgs);
|
||||
argList.Add("-p");
|
||||
argList.Add(prompt);
|
||||
|
||||
var psi = new ProcessStartInfo("ollama")
|
||||
{
|
||||
WorkingDirectory = repoRoot,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
foreach (var a in argList)
|
||||
psi.ArgumentList.Add(a);
|
||||
|
||||
activeProcess = new Process { StartInfo = psi };
|
||||
|
||||
activeProcess.OutputDataReceived += (_, e) =>
|
||||
{
|
||||
if (e.Data is null) return;
|
||||
Console.WriteLine(e.Data);
|
||||
output.AppendLine(e.Data);
|
||||
};
|
||||
activeProcess.ErrorDataReceived += (_, e) =>
|
||||
{
|
||||
if (e.Data is null) return;
|
||||
Console.Error.WriteLine(e.Data);
|
||||
output.AppendLine(e.Data);
|
||||
};
|
||||
|
||||
activeProcess.Start();
|
||||
activeProcess.BeginOutputReadLine();
|
||||
activeProcess.BeginErrorReadLine();
|
||||
|
||||
await activeProcess.WaitForExitAsync(cts.Token);
|
||||
activeProcess = null;
|
||||
|
||||
return output.ToString();
|
||||
}
|
||||
|
||||
// ── Main loop ─────────────────────────────────────────────────────────────────
|
||||
for (int i = 0; i < items.Count; i++)
|
||||
{
|
||||
var item = items[i];
|
||||
if (cts.IsCancellationRequested) break;
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("[runner] ══════════════════════════════════════════════");
|
||||
Console.WriteLine($"[runner] Task:\n{item}");
|
||||
Console.WriteLine("[runner] ══════════════════════════════════════════════");
|
||||
Console.WriteLine();
|
||||
|
||||
// Step 1 — run the task prompt
|
||||
await RunCopilot(Enumerable.Empty<string>(), $"/caveman full");
|
||||
await RunCopilot(new[] { "--continue" }, $"read ./Docs/instructions.md. {item}");
|
||||
if (cts.IsCancellationRequested) break;
|
||||
|
||||
// Step 2 — confirm completion in the same chat session
|
||||
Console.WriteLine("\n[runner] Asking for task confirmation...\n");
|
||||
var confirmation = await RunCopilot(
|
||||
new[] { "--continue" },
|
||||
"are you sure tasks is done. reply with yes"
|
||||
);
|
||||
if (cts.IsCancellationRequested) break;
|
||||
|
||||
// Step 3 — check for "yes" in the reply, with retry logic for issue resolution
|
||||
int maxRetries = 3;
|
||||
int retryCount = 0;
|
||||
bool taskConfirmed = confirmation.Contains("yes", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
while (!taskConfirmed && retryCount < maxRetries)
|
||||
{
|
||||
retryCount++;
|
||||
Console.WriteLine($"\n[runner] Attempt {retryCount}/{maxRetries}: Resolving remaining issues and running tests...\n");
|
||||
|
||||
confirmation = await RunCopilot(
|
||||
new[] { "--continue" },
|
||||
"resolve any remaining issues, make sure all tests are running and pass. then confirm with yes if done"
|
||||
);
|
||||
if (cts.IsCancellationRequested) break;
|
||||
|
||||
taskConfirmed = confirmation.Contains("yes", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (!taskConfirmed)
|
||||
{
|
||||
Console.WriteLine($"\n[runner] Task not confirmed as done after {maxRetries} attempts. Stopping.");
|
||||
break;
|
||||
}
|
||||
|
||||
// Step 4 — commit the work
|
||||
Console.WriteLine("\n[runner] Task confirmed. Making git commit...\n");
|
||||
|
||||
await RunCopilot(Enumerable.Empty<string>(), $"/caveman full");
|
||||
await RunCopilot(new[] { "--continue" }, "make git commit");
|
||||
if (cts.IsCancellationRequested) break;
|
||||
|
||||
// Step 5 — remove completed task from Tasks.md
|
||||
var remaining = items.Skip(i + 1).ToList();
|
||||
File.WriteAllText(tasksFile, string.Join("\n\n---\n\n", remaining));
|
||||
Console.WriteLine("[runner] Removed completed task from Tasks.md");
|
||||
}
|
||||
|
||||
Console.WriteLine("\n[runner] Finished.");
|
||||
178
Docs/tasks.md
Normal file
178
Docs/tasks.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Scheduled Folder Scan
|
||||
|
||||
### Task 1.1: Add folder scan scheduler configuration
|
||||
|
||||
**Where is that found**
|
||||
- `src/server/models/config.py` (`SchedulerConfig`)
|
||||
- `data/config.json` (example/default config)
|
||||
- `src/server/web/templates/setup.html` (setup UI)
|
||||
- `src/server/api/auth.py` (config save endpoint, if it validates scheduler fields)
|
||||
|
||||
**Goal. How it should be**
|
||||
Add a new boolean field `folder_scan_enabled` (default `false`) to `SchedulerConfig`. When `true`, the scheduler will execute the folder maintenance routine during its scheduled run. Add the field to the setup page as a checkbox. Ensure existing configs without this field load successfully (Pydantic default handles this).
|
||||
|
||||
**Possible traps and issues**
|
||||
- Backward compatibility: old `data/config.json` files must load without errors. Pydantic defaults solve this, but verify by loading an old config.
|
||||
- The setup page JavaScript must include the new field in the payload sent to `/api/config`.
|
||||
- Do not confuse this with `auto_download_after_rescan` — this is a separate toggle.
|
||||
|
||||
**Docs changes needed**
|
||||
- `docs/CONFIGURATION.md`: Document the new `scheduler.folder_scan_enabled` option.
|
||||
- `docs/ARCHITECTURE.md`: Mention folder scan in the scheduler section.
|
||||
|
||||
**Why this is needed**
|
||||
Users need an opt-in toggle to enable automatic daily folder maintenance (NFO repair, folder renaming, poster checks) without forcing it on everyone.
|
||||
|
||||
---
|
||||
|
||||
### Task 1.2: Create FolderScanService skeleton
|
||||
|
||||
**Where is that found**
|
||||
- New file: `src/server/services/folder_scan_service.py`
|
||||
- `src/server/services/scheduler_service.py` (to call it)
|
||||
|
||||
**Goal. How it should be**
|
||||
Create a new `FolderScanService` class with a single async entry point `async def run_folder_scan(self) -> None`. The method should:
|
||||
1. Log start/completion with structlog.
|
||||
2. Check prerequisites (`settings.anime_directory` exists, `settings.tmdb_api_key` is set).
|
||||
3. Skip gracefully with a warning log if prerequisites are missing.
|
||||
4. Use a module-level semaphore (similar to `_NFO_REPAIR_SEMAPHORE`) to limit concurrent TMDB operations to 3.
|
||||
|
||||
Keep the implementation empty for the sub-tasks (1.3–1.5) to fill in. Just add the skeleton and the semaphore.
|
||||
|
||||
**Possible traps and issues**
|
||||
- Circular imports: `folder_scan_service.py` will import from `initialization_service`, `config.settings`, etc. Keep imports inside methods or at the bottom if circular issues arise.
|
||||
- The service should follow the singleton pattern like `SchedulerService` and `DownloadService` if it holds state, or be stateless. For simplicity, make it a plain class instantiated per call or a module-level function set.
|
||||
- Exception handling: any unhandled exception in the scheduled task should be caught and logged so it doesn't crash the scheduler.
|
||||
|
||||
**Docs changes needed**
|
||||
- `docs/ARCHITECTURE.md`: Add `folder_scan_service.py` to the services list.
|
||||
|
||||
**Why this is needed**
|
||||
Encapsulates the new daily maintenance logic in its own module, keeping `scheduler_service.py` clean and allowing the folder scan to be tested independently.
|
||||
|
||||
---
|
||||
|
||||
### Task 1.3: Integrate NFO repair into folder scan
|
||||
|
||||
**Where is that found**
|
||||
- `src/server/services/folder_scan_service.py`
|
||||
- `src/server/services/initialization_service.py` (`perform_nfo_repair_scan`)
|
||||
|
||||
**Goal. How it should be**
|
||||
Inside `FolderScanService.run_folder_scan()`, call `perform_nfo_repair_scan(background_loader=None)` as the first step. Reuse the existing function exactly — do not copy its logic. Log a message before and after the call.
|
||||
|
||||
**Possible traps and issues**
|
||||
- `perform_nfo_repair_scan` spawns `asyncio.create_task` for each repair. When called from the scheduler, these background tasks will still run after `run_folder_scan` returns. This is fine, but log that repairs are queued.
|
||||
- The function already handles missing `tmdb_api_key` and `anime_directory`, so the caller doesn't need to double-check, but the skeleton from Task 1.2 already checks prerequisites.
|
||||
- `perform_nfo_repair_scan` imports `nfo_needs_repair` and `NfoRepairService` inside the function, so no heavy import-time dependencies.
|
||||
|
||||
**Docs changes needed**
|
||||
- `docs/NFO_GUIDE.md`: Update the "Automatic NFO Repair" section to state that repair now runs as part of the scheduled folder scan instead of every startup.
|
||||
|
||||
**Why this is needed**
|
||||
Reuses the existing, tested NFO repair logic. Moves NFO repair from startup blocking to scheduled background maintenance.
|
||||
|
||||
---
|
||||
|
||||
### Task 1.4: Validate and rename series folders
|
||||
|
||||
**Where is that found**
|
||||
- `src/server/services/folder_scan_service.py`
|
||||
- `src/core/services/nfo_repair_service.py` (for `parse_nfo_tags` or similar NFO parsing)
|
||||
- `src/server/database/models.py` / `src/server/database/system_settings_service.py` (if folder paths are stored in DB)
|
||||
|
||||
**Goal. How it should be**
|
||||
After NFO repair, iterate over every subfolder in `settings.anime_directory` that contains a `tvshow.nfo`. For each folder:
|
||||
1. Parse the NFO to extract `<title>` and `<year>` text values.
|
||||
2. Compute the expected folder name: `f"{title} ({year})"`.
|
||||
3. Sanitize the expected name for filesystem safety (remove/replace illegal characters like `/`, `\`, `:`, etc.).
|
||||
4. Compare with the current folder name (`series_dir.name`).
|
||||
5. If different, rename the folder using `series_dir.rename(expected_path)`.
|
||||
6. If the series path is stored in the database (check `anime_service` or DB models), update the database record to point to the new path.
|
||||
|
||||
Skip folders where title or year is missing/empty. Log every rename action.
|
||||
|
||||
**Possible traps and issues**
|
||||
- **Database path consistency**: If `Series` or `Episode` models store absolute or relative paths, renaming the folder on disk without updating the DB will break downloads, NFO updates, and the web UI. Must verify whether paths are stored in the DB and update them.
|
||||
- **Active downloads**: A series currently being downloaded should not be renamed. Check the download queue or lock status before renaming. If no lock mechanism exists, this is a major trap — document it.
|
||||
- **Filesystem permissions**: The app may not have write permission to the anime directory. Catch `PermissionError` and `OSError` and log gracefully.
|
||||
- **Special characters**: Titles like `"A / B"` or `"Show: Subtitle"` contain characters illegal in folder names. Define a sanitization function (e.g., replace `/` with `-`, remove trailing dots on Windows, etc.).
|
||||
- **Duplicate names**: Two different series could sanitize to the same name. Check if target path already exists before renaming.
|
||||
- **Path length limits**: Very long titles might exceed OS path limits.
|
||||
|
||||
**Docs changes needed**
|
||||
- `docs/NFO_GUIDE.md`: Add a section "Folder Naming Convention" explaining the `<title> (<year>)` format.
|
||||
- `docs/CONFIGURATION.md`: Mention that enabling folder scan will rename folders.
|
||||
|
||||
**Why this is needed**
|
||||
Enforces a consistent, predictable folder naming scheme across the library, making it easier for media center apps (Kodi, Jellyfin, Plex) to match metadata.
|
||||
|
||||
---
|
||||
|
||||
### Task 1.5: Check and download missing poster.jpg
|
||||
|
||||
**Where is that found**
|
||||
- `src/server/services/folder_scan_service.py`
|
||||
- `src/core/utils/image_downloader.py` (`ImageDownloader`)
|
||||
- `src/core/services/nfo_service.py` or `src/core/services/nfo_repair_service.py` (to get poster URL from NFO or TMDB)
|
||||
|
||||
**Goal. How it should be**
|
||||
After folder renaming, iterate over series folders again (or combine with Task 1.4 loop). For each folder:
|
||||
1. Check if `poster.jpg` exists and has a size ≥ `ImageDownloader.min_file_size` (1 KB by default).
|
||||
2. If missing or too small:
|
||||
a. Parse `tvshow.nfo` for `<thumb aspect="poster">` or `<thumb>` URL.
|
||||
b. If no URL in NFO, skip (do not query TMDB again to keep tasks small; the NFO should already have it after repair).
|
||||
c. Use `ImageDownloader` (with context manager) to download the image to `series_dir / "poster.jpg"`.
|
||||
d. Validate the downloaded image with `ImageDownloader._validate_image` (or similar existing validation).
|
||||
3. Use the existing `_NFO_REPAIR_SEMAPHORE` or a new `POSTER_DOWNLOAD_SEMAPHORE` to limit concurrent downloads to 3.
|
||||
|
||||
**Possible traps and issues**
|
||||
- **TMDB rate limiting**: Even downloading images hits TMDB CDN. The semaphore limits concurrency.
|
||||
- **Invalid images**: A download might produce a 0-byte or corrupted file. `ImageDownloader` already validates with PIL; reuse that.
|
||||
- **NFO without thumb URL**: If the NFO was created before thumb tags were added, there may be no URL. In that case, skip and log. A future task could query TMDB directly.
|
||||
- **Write permissions**: Same as Task 1.4.
|
||||
- **Async session sharing**: `ImageDownloader` manages its own `aiohttp` session. Use `async with ImageDownloader() as downloader:` to ensure cleanup.
|
||||
|
||||
**Docs changes needed**
|
||||
- `docs/NFO_GUIDE.md`: Add "Poster Check" subsection under folder scan.
|
||||
- `docs/CONFIGURATION.md`: Mention that `nfo.download_poster` setting also affects scheduled poster checks.
|
||||
|
||||
**Why this is needed**
|
||||
Ensures every series has artwork, which is required by most media center front-ends for a polished library view.
|
||||
|
||||
---
|
||||
|
||||
## 2. Remove startup NFO repair
|
||||
|
||||
### Task 2.1: Remove perform_nfo_repair_scan from startup lifespan
|
||||
|
||||
**Where is that found**
|
||||
- `src/server/fastapi_app.py` (lifespan startup block, lines ~245 and ~319)
|
||||
- `src/server/services/initialization_service.py` (keep the function, just remove the call site)
|
||||
- `tests/integration/test_nfo_repair_startup.py`
|
||||
- `tests/unit/test_initialization_service.py` (tests that call `perform_nfo_repair_scan` directly can stay, but integration tests verifying startup wiring must change)
|
||||
|
||||
**Goal. How it should be**
|
||||
1. In `src/server/fastapi_app.py`, remove the import of `perform_nfo_repair_scan` from the `initialization_service` import block.
|
||||
2. Remove the line `await perform_nfo_repair_scan(background_loader)` from the lifespan startup sequence.
|
||||
3. Update `tests/integration/test_nfo_repair_startup.py`:
|
||||
- Remove or modify `test_perform_nfo_repair_scan_imported_in_lifespan` and `test_perform_nfo_repair_scan_called_after_media_scan` since the startup wiring is gone.
|
||||
- Replace with a test that verifies `perform_nfo_repair_scan` is NOT called during startup (or simply delete the file if it has no other purpose).
|
||||
4. `tests/unit/test_initialization_service.py` tests for `perform_nfo_repair_scan` can remain because they test the function itself, not the startup wiring.
|
||||
|
||||
**Possible traps and issues**
|
||||
- **Test failures**: `test_nfo_repair_startup.py` will fail immediately after the code change. It must be updated in the same PR.
|
||||
- **Documentation drift**: `docs/NFO_GUIDE.md`, `docs/CHANGELOG.md`, and `docs/ARCHITECTURE.md` all describe the startup NFO repair behavior. If docs are not updated, users will expect repair on every start.
|
||||
- **Background loader parameter**: The `background_loader` variable was created partly for `perform_nfo_repair_scan`. After removal, check if `background_loader` is still needed for other startup steps (yes — `perform_media_scan_if_needed` uses it). Do not remove `background_loader` entirely.
|
||||
- **Import cleanup**: Ensure no unused imports remain in `fastapi_app.py` after removal.
|
||||
|
||||
**Docs changes needed**
|
||||
- `docs/NFO_GUIDE.md`: Update section 11 "Automatic NFO Repair" to remove startup references and state it runs via scheduler.
|
||||
- `docs/CHANGELOG.md`: Add an entry under "Changed" or "Removed" noting that startup NFO repair is replaced by scheduled folder scan.
|
||||
- `docs/ARCHITECTURE.md`: Update the startup sequence description.
|
||||
|
||||
**Why this is needed**
|
||||
Running `perform_nfo_repair_scan` on every startup slows down server restarts, especially for large libraries. Moving it to a scheduled task keeps startup fast while still ensuring regular maintenance.
|
||||
21
Makefile
Normal file
21
Makefile
Normal file
@@ -0,0 +1,21 @@
|
||||
.PHONY: up down clean browser-clean setup
|
||||
|
||||
up:
|
||||
python run_server.py
|
||||
|
||||
down:
|
||||
pkill -f "uvicorn src.server.fastapi_app:app" || pkill -f "python.*run_server.py" || true
|
||||
|
||||
clean:
|
||||
rm -rf data/*.db data/*.db-shm data/*.db-wal data/config.json
|
||||
|
||||
browser-clean:
|
||||
rm -rf "$$HOME/.cache/microsoft-edge"/* || true
|
||||
rm -rf "$$HOME/.cache/mozilla/firefox"/* || true
|
||||
find "$$HOME/.mozilla/firefox" -name "cache2" -type d -exec rm -rf {} \; 2>/dev/null || true
|
||||
|
||||
setup:
|
||||
curl -X POST http://127.0.0.1:8000/setup \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: 299ae8f630a31bda814263c551361448" \
|
||||
-d '{"path": "/home/lukas/Volume/serien/", "password": "Hallo123!"}'
|
||||
202
README.md
Normal file
202
README.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# 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
|
||||
@@ -1,215 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"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$tRZCyFnr/d87x/i/19p7Lw$BoD8EF67N97SRs7kIX8SREbotRwvFntS.WCH9ZwTxHY",
|
||||
"anime_directory": "/home/lukas/Volume/serien/"
|
||||
},
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"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$JWTsXWstZYyxNiYEQAihFA$K9QPNr2J9biZEX/7SFKU94dnynvyCICrGjKtZcEu6t8"
|
||||
},
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"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$1fo/x1gLYax1bs15L.X8/w$T2GKqjDG7LT9tTZIwX/P2T/uKKuM9IhOD9jmhFUw4A0"
|
||||
},
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"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$nbNWSkkJIeTce48xxrh3bg$QXT6A63JqmSLimtTeI04HzC4eKfQS26xFW7UL9Ry5co",
|
||||
"anime_directory": "/home/lukas/Volume/serien/"
|
||||
},
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"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$j5HSWuu9V.rdm9Pa2zunNA$gjQqL753WLBMZtHVOhziVn.vW3Bkq8mGtCzSkbBjSHo",
|
||||
"anime_directory": "/home/lukas/Volume/serien/"
|
||||
},
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
# 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
|
||||
|
||||
**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: **\*\***\_**\*\***
|
||||
@@ -1,421 +0,0 @@
|
||||
# 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"`
|
||||
|
||||
### Notes
|
||||
|
||||
- **Backward Compatibility**: API endpoints accepting `anime_id` will check `key` first, then fall back to `folder` lookup
|
||||
- **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
|
||||
- No `get_by_folder()` method exists - folder is never used for lookups
|
||||
|
||||
### DownloadQueueItem Fields
|
||||
|
||||
| Field | Type | Purpose |
|
||||
| -------------- | ----------- | ----------------------------------------- |
|
||||
| `id` | String (PK) | UUID for the queue item |
|
||||
| `serie_id` | String | Series key for identification |
|
||||
| `serie_folder` | String | Filesystem folder path |
|
||||
| `serie_name` | String | Display name for the series |
|
||||
| `season` | Integer | Season number |
|
||||
| `episode` | Integer | Episode number |
|
||||
| `status` | Enum | pending, downloading, completed, failed |
|
||||
| `priority` | Enum | low, normal, high |
|
||||
| `progress` | Float | Download progress percentage (0.0-100.0) |
|
||||
| `error` | String | Error message if failed |
|
||||
| `retry_count` | Integer | Number of retry attempts |
|
||||
| `added_at` | DateTime | When item was added to queue |
|
||||
| `started_at` | DateTime | When download started (nullable) |
|
||||
| `completed_at` | DateTime | When download completed/failed (nullable) |
|
||||
|
||||
## Data Storage
|
||||
|
||||
### Storage Architecture
|
||||
|
||||
The application uses **SQLite database** as the primary storage for all application data.
|
||||
|
||||
| Data Type | Storage Location | Service |
|
||||
| -------------- | ------------------ | --------------------------------------- |
|
||||
| Anime Series | `data/aniworld.db` | `AnimeSeriesService` |
|
||||
| Episodes | `data/aniworld.db` | `AnimeSeriesService` |
|
||||
| Download Queue | `data/aniworld.db` | `DownloadService` via `QueueRepository` |
|
||||
| User Sessions | `data/aniworld.db` | `AuthService` |
|
||||
| Configuration | `data/config.json` | `ConfigService` |
|
||||
|
||||
### Download Queue Storage
|
||||
|
||||
The download queue is stored in SQLite via `QueueRepository`, which wraps `DownloadQueueService`:
|
||||
|
||||
```python
|
||||
# QueueRepository provides async operations for queue items
|
||||
repository = QueueRepository(session_factory)
|
||||
|
||||
# Save item to database
|
||||
saved_item = await repository.save_item(download_item)
|
||||
|
||||
# Get pending items (ordered by priority and add time)
|
||||
pending = await repository.get_pending_items()
|
||||
|
||||
# Update item status
|
||||
await repository.update_status(item_id, DownloadStatus.COMPLETED)
|
||||
|
||||
# Update download progress
|
||||
await repository.update_progress(item_id, progress=45.5, downloaded=450, total=1000, speed=2.5)
|
||||
```
|
||||
|
||||
**Queue Persistence Features:**
|
||||
|
||||
- Queue state survives server restarts
|
||||
- Items in `downloading` status are reset to `pending` on startup
|
||||
- Failed items within retry limit are automatically re-queued
|
||||
- Completed and failed history is preserved (with limits)
|
||||
- Real-time progress updates are persisted to database
|
||||
|
||||
### Anime Series Database Storage
|
||||
|
||||
```python
|
||||
# Add series to database
|
||||
await AnimeSeriesService.create(db_session, series_data)
|
||||
|
||||
# Query series by key
|
||||
series = await AnimeSeriesService.get_by_key(db_session, "attack-on-titan")
|
||||
|
||||
# Update series
|
||||
await AnimeSeriesService.update(db_session, series_id, update_data)
|
||||
```
|
||||
|
||||
### Legacy File Storage (Deprecated)
|
||||
|
||||
The legacy file-based storage is **deprecated** and will be removed in v3.0.0:
|
||||
|
||||
- `Serie.save_to_file()` - Deprecated, use `AnimeSeriesService.create()`
|
||||
- `Serie.load_from_file()` - Deprecated, use `AnimeSeriesService.get_by_key()`
|
||||
- `SerieList.add()` - Deprecated, use `SerieList.add_to_db()`
|
||||
|
||||
Deprecation warnings are raised when using these methods.
|
||||
|
||||
## 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);
|
||||
});
|
||||
```
|
||||
53
features.md
53
features.md
@@ -1,53 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,131 +0,0 @@
|
||||
#!/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
104
fix_tests.py
@@ -1,104 +0,0 @@
|
||||
#!/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()
|
||||
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "aniworld-web",
|
||||
"version": "1.4.15",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -14,4 +14,15 @@ pytest==7.4.3
|
||||
pytest-asyncio==0.21.1
|
||||
httpx==0.25.2
|
||||
sqlalchemy>=2.0.35
|
||||
aiosqlite>=0.19.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
|
||||
chardet>=5.2.0
|
||||
fake-useragent>=1.4.0
|
||||
yt-dlp>=2024.1.0
|
||||
urllib3>=2.0.0
|
||||
@@ -2,7 +2,8 @@
|
||||
"""
|
||||
Startup script for the Aniworld FastAPI application.
|
||||
|
||||
This script starts the application with proper logging configuration.
|
||||
This script starts the application with proper logging configuration
|
||||
and graceful shutdown support via Ctrl+C (SIGINT) or SIGTERM.
|
||||
"""
|
||||
import uvicorn
|
||||
|
||||
@@ -15,6 +16,11 @@ 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",
|
||||
@@ -24,4 +30,5 @@ 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
421
scripts/setup.py
@@ -1,421 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
225
scripts/start.sh
225
scripts/start.sh
@@ -1,225 +0,0 @@
|
||||
#!/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]
|
||||
#
|
||||
# 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}"
|
||||
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."
|
||||
}
|
||||
|
||||
# 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
|
||||
;;
|
||||
*)
|
||||
ENVIRONMENT="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
validate_environment
|
||||
check_conda_env
|
||||
create_directories
|
||||
create_env_file
|
||||
install_dependencies
|
||||
init_database
|
||||
start_application
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
316
src/cli/Main.py
316
src/cli/Main.py
@@ -1,316 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,491 +0,0 @@
|
||||
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 I’m the Strongest I’m 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 I’m the Strongest I’m Not Even an Adventurer Yet! (2024)\data for I Parry Everything What Do You Mean I’m the Strongest I’m 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\I’ll 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\I’ll Become a Villainess Who Goes Down in History (2024)\data for I’ll 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 God’s 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 God’s Go-To Girl (2025)\data for The Gorilla God’s 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 Saint’s 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 Saint’s Magic Power is Omnipotent (2021)\data for The Saint’s 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\World’s 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\World’s End Harem (2022)\data for World’s 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
|
||||
205
src/cli/nfo_cli.py
Normal file
205
src/cli/nfo_cli.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""CLI command for NFO management.
|
||||
|
||||
Note: NFO service has been removed. This CLI is no longer functional.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def scan_and_create_nfo():
|
||||
"""Scan all series and create missing NFO files."""
|
||||
logger.info("%s", "=" * 70)
|
||||
logger.info("NFO Auto-Creation Tool")
|
||||
logger.info("%s", "=" * 70)
|
||||
|
||||
if not settings.tmdb_api_key:
|
||||
logger.error("TMDB_API_KEY not configured")
|
||||
logger.error("Set TMDB_API_KEY in .env file or environment")
|
||||
logger.error("Get API key from: https://www.themoviedb.org/settings/api")
|
||||
return 1
|
||||
|
||||
if not settings.anime_directory:
|
||||
logger.error("ANIME_DIRECTORY not configured")
|
||||
return 1
|
||||
|
||||
logger.info("Anime Directory: %s", settings.anime_directory)
|
||||
logger.info("Auto-create NFO: %s", settings.nfo_auto_create)
|
||||
logger.info("Update on scan: %s", settings.nfo_update_on_scan)
|
||||
logger.info("Download poster: %s", settings.nfo_download_poster)
|
||||
logger.info("Download logo: %s", settings.nfo_download_logo)
|
||||
logger.info("Download fanart: %s", settings.nfo_download_fanart)
|
||||
|
||||
if not settings.nfo_auto_create:
|
||||
logger.warning("NFO_AUTO_CREATE is set to False")
|
||||
logger.warning("Enable it in .env to auto-create NFO files")
|
||||
logger.info("Continuing anyway to demonstrate functionality...")
|
||||
# Override for demonstration
|
||||
settings.nfo_auto_create = True
|
||||
|
||||
logger.info("Initializing series manager...")
|
||||
manager = SeriesManagerService.from_settings()
|
||||
|
||||
# Get series list first
|
||||
serie_list = manager.get_serie_list()
|
||||
all_series = serie_list.get_all()
|
||||
|
||||
logger.info("Found %d series in directory", len(all_series))
|
||||
|
||||
if not all_series:
|
||||
logger.warning("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:
|
||||
logger.info("Series without NFO: %d", len(series_without_nfo))
|
||||
for serie in series_without_nfo[:5]: # Show first 5
|
||||
logger.debug("Missing NFO: %s (%s)", serie.name, serie.folder)
|
||||
if len(series_without_nfo) > 5:
|
||||
logger.info("... and %d more", len(series_without_nfo) - 5)
|
||||
else:
|
||||
logger.info("All series already have NFO files")
|
||||
|
||||
if not settings.nfo_update_on_scan:
|
||||
logger.info("Nothing to do. Enable NFO_UPDATE_ON_SCAN to update existing NFOs.")
|
||||
return 0
|
||||
|
||||
logger.info("Processing NFO files...")
|
||||
logger.info("This may take a while depending on the number of series")
|
||||
|
||||
try:
|
||||
await manager.scan_and_process_nfo()
|
||||
logger.info("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()]
|
||||
|
||||
logger.info("Final statistics", extra={
|
||||
"total_series": len(all_series),
|
||||
"with_nfo": len(series_with_nfo),
|
||||
"with_poster": len(series_with_poster),
|
||||
"with_logo": len(series_with_logo),
|
||||
"with_fanart": len(series_with_fanart),
|
||||
})
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to process NFO files")
|
||||
return 1
|
||||
finally:
|
||||
await manager.close()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
async def check_nfo_status():
|
||||
"""Check NFO status for all series."""
|
||||
logger.info("%s", "=" * 70)
|
||||
logger.info("NFO Status Check")
|
||||
logger.info("%s", "=" * 70)
|
||||
|
||||
if not settings.anime_directory:
|
||||
logger.error("ANIME_DIRECTORY not configured")
|
||||
return 1
|
||||
|
||||
logger.info("Anime Directory: %s", settings.anime_directory)
|
||||
|
||||
# Create series list (no NFO service needed for status check)
|
||||
from src.server.database.SerieList import SerieList
|
||||
serie_list = SerieList(settings.anime_directory)
|
||||
all_series = serie_list.get_all()
|
||||
|
||||
if not all_series:
|
||||
logger.warning("No series found")
|
||||
return 0
|
||||
|
||||
logger.info("Total series: %d", 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)
|
||||
|
||||
logger.info(
|
||||
"Series NFO coverage",
|
||||
extra={
|
||||
"with_nfo": len(with_nfo),
|
||||
"without_nfo": len(without_nfo),
|
||||
"total": len(all_series),
|
||||
},
|
||||
)
|
||||
|
||||
if without_nfo:
|
||||
logger.info("Series missing NFO: %d", len(without_nfo))
|
||||
for serie in without_nfo[:10]:
|
||||
logger.debug("Missing NFO: %s (%s)", serie.name, serie.folder)
|
||||
if len(without_nfo) > 10:
|
||||
logger.info("... and %d more", len(without_nfo) - 10)
|
||||
|
||||
# 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())
|
||||
|
||||
logger.info(
|
||||
"Media file coverage",
|
||||
extra={
|
||||
"posters": with_poster,
|
||||
"logos": with_logo,
|
||||
"fanart": with_fanart,
|
||||
"total": len(all_series),
|
||||
},
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
"""Main CLI entry point."""
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
logger.info("NFO Management Tool")
|
||||
logger.info("\nUsage:")
|
||||
logger.info(" python -m src.cli.nfo_cli scan # Scan and create missing NFO files")
|
||||
logger.info(" python -m src.cli.nfo_cli status # Check NFO status for all series")
|
||||
logger.info("\nConfiguration:")
|
||||
logger.info(" Set TMDB_API_KEY in .env file")
|
||||
logger.info(" Set NFO_AUTO_CREATE=true to enable auto-creation")
|
||||
logger.info(" 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())
|
||||
else:
|
||||
logger.error("Unknown command: %s", command)
|
||||
logger.info("Use 'scan' or 'status'")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
@@ -72,6 +73,82 @@ class Settings(BaseSettings):
|
||||
default=3,
|
||||
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"
|
||||
)
|
||||
nfo_folder_ignore_patterns: str = Field(
|
||||
default="The Last of Us|Loki|Chernobyl|Star Trek Discovery|Marvel|Matrix|Fast & Furious|Jurassic|James Bond|Mission: Impossible|Bourne|Hunger Games|Die Hard|John Wick|Pacific Rim|Guardians of the Galaxy|Avengers|Batman|Superman|Wonder Woman|Spider-Man|X-Men|Fantastic Four|Terminator|Predator|Rambo|Rocky|Expendables|Tomb Raider|Jumanji|Jurassic Park|Pirates of the Caribbean|Harry Potter|Lord of the Rings|Hobbit|Game of Thrones|Westworld|Stranger Things|Breaking Bad|Better Call Saul|Sherlock|Downton Abbey|The Crown|Bridgerton|Sex Education|Normal People|Emily in Paris|The Witcher|Servant|Lucifer|Dark|Shadow and Bone|Grimm|Fairytale",
|
||||
validation_alias="NFO_FOLDER_IGNORE_PATTERNS",
|
||||
description="Regex patterns for folder names to skip during scan (pipe-separated)"
|
||||
)
|
||||
|
||||
@property
|
||||
def folder_ignore_patterns(self) -> list[str]:
|
||||
"""Parse ignore patterns from comma-separated string into list.
|
||||
|
||||
Returns:
|
||||
List of regex patterns to skip during folder scanning.
|
||||
"""
|
||||
if not self.nfo_folder_ignore_patterns:
|
||||
return []
|
||||
return [
|
||||
pattern.strip()
|
||||
for pattern in self.nfo_folder_ignore_patterns.split("|")
|
||||
if pattern.strip()
|
||||
]
|
||||
|
||||
def should_ignore_folder(self, folder_name: str) -> bool:
|
||||
"""Check if folder should be ignored based on ignore patterns.
|
||||
|
||||
Args:
|
||||
folder_name: Name of folder to check.
|
||||
|
||||
Returns:
|
||||
True if folder matches any ignore pattern, False otherwise.
|
||||
"""
|
||||
for pattern in self.folder_ignore_patterns:
|
||||
if re.search(pattern, folder_name, re.IGNORECASE):
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def allowed_origins(self) -> list[str]:
|
||||
@@ -92,5 +169,23 @@ class Settings(BaseSettings):
|
||||
]
|
||||
return [origin.strip() for origin in raw.split(",") if origin.strip()]
|
||||
|
||||
@property
|
||||
def scan_key_overrides(self) -> dict[str, str]:
|
||||
"""Return scan key overrides from config.json.
|
||||
|
||||
Maps folder names to provider keys for cases where auto-generated
|
||||
keys from folder names are incorrect.
|
||||
|
||||
Returns:
|
||||
Dict mapping folder names to provider keys.
|
||||
"""
|
||||
from src.server.services.config_service import ConfigService
|
||||
try:
|
||||
config_service = ConfigService()
|
||||
config = config_service.load_config()
|
||||
return config.scan_key_overrides or {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -1,848 +0,0 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
The scanner supports two modes of operation:
|
||||
1. File-based mode (legacy): Saves scan results to data files
|
||||
2. Database mode (preferred): Saves scan results to SQLite database
|
||||
|
||||
Database mode is preferred for new code. File-based mode is kept for
|
||||
backward compatibility with CLI usage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import traceback
|
||||
import uuid
|
||||
import warnings
|
||||
from typing import TYPE_CHECKING, 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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.server.database.models import AnimeSeries
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
error_logger = logging.getLogger("error")
|
||||
no_key_found_logger = logging.getLogger("series.nokey")
|
||||
|
||||
|
||||
class SerieScanner:
|
||||
"""
|
||||
Scans directories for anime series and identifies missing episodes.
|
||||
|
||||
Supports progress callbacks for real-time scanning updates.
|
||||
|
||||
The scanner supports two modes:
|
||||
1. File-based (legacy): Set db_session=None, saves to data files
|
||||
2. Database mode: Provide db_session, saves to SQLite database
|
||||
|
||||
Example:
|
||||
# File-based mode (legacy)
|
||||
scanner = SerieScanner("/path/to/anime", loader)
|
||||
scanner.scan()
|
||||
|
||||
# Database mode (preferred)
|
||||
async with get_db_session() as db:
|
||||
scanner = SerieScanner("/path/to/anime", loader, db_session=db)
|
||||
await scanner.scan_async()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
basePath: str,
|
||||
loader: Loader,
|
||||
callback_manager: Optional[CallbackManager] = None,
|
||||
db_session: Optional["AsyncSession"] = None
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the SerieScanner.
|
||||
|
||||
Args:
|
||||
basePath: Base directory containing anime series
|
||||
loader: Loader instance for fetching series information
|
||||
callback_manager: Optional callback manager for progress updates
|
||||
db_session: Optional database session for database mode.
|
||||
If provided, scan_async() should be used instead of scan().
|
||||
|
||||
Raises:
|
||||
ValueError: If basePath is invalid or doesn't exist
|
||||
"""
|
||||
# Validate basePath to prevent directory traversal attacks
|
||||
if not basePath or not basePath.strip():
|
||||
raise ValueError("Base path cannot be empty")
|
||||
|
||||
# Resolve to absolute path and validate it exists
|
||||
abs_path = os.path.abspath(basePath)
|
||||
if not os.path.exists(abs_path):
|
||||
raise ValueError(f"Base path does not exist: {abs_path}")
|
||||
if not os.path.isdir(abs_path):
|
||||
raise ValueError(f"Base path is not a directory: {abs_path}")
|
||||
|
||||
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._db_session: Optional["AsyncSession"] = db_session
|
||||
|
||||
logger.info("Initialized SerieScanner with base path: %s", abs_path)
|
||||
|
||||
@property
|
||||
def callback_manager(self) -> CallbackManager:
|
||||
"""Get the callback manager instance."""
|
||||
return self._callback_manager
|
||||
|
||||
def reinit(self) -> None:
|
||||
"""Reinitialize the series dictionary (keyed by serie.key)."""
|
||||
self.keyDict: dict[str, Serie] = {}
|
||||
|
||||
def get_total_to_scan(self) -> int:
|
||||
"""Get the total number of folders to scan.
|
||||
|
||||
Returns:
|
||||
Total count of folders with MP4 files
|
||||
"""
|
||||
result = self.__find_mp4_files()
|
||||
return sum(1 for _ in result)
|
||||
|
||||
def scan(
|
||||
self,
|
||||
callback: Optional[Callable[[str, int], None]] = None
|
||||
) -> None:
|
||||
"""
|
||||
Scan directories for anime series and missing episodes (file-based).
|
||||
|
||||
This method saves results to data files. For database storage,
|
||||
use scan_async() instead.
|
||||
|
||||
.. deprecated:: 2.0.0
|
||||
Use :meth:`scan_async` for database-backed storage.
|
||||
File-based storage will be removed in a future version.
|
||||
|
||||
Args:
|
||||
callback: Optional legacy callback function (folder, count)
|
||||
|
||||
Raises:
|
||||
Exception: If scan fails critically
|
||||
"""
|
||||
warnings.warn(
|
||||
"File-based scan() is deprecated. Use scan_async() for "
|
||||
"database storage.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
# Generate unique operation ID
|
||||
self._current_operation_id = str(uuid.uuid4())
|
||||
|
||||
logger.info("Starting scan for missing episodes")
|
||||
|
||||
# Notify scan starting
|
||||
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:
|
||||
# Get total items to process
|
||||
total_to_scan = self.get_total_to_scan()
|
||||
logger.info("Total folders to scan: %d", total_to_scan)
|
||||
|
||||
# The scanner enumerates folders with mp4 files, loads existing
|
||||
# metadata, calculates the missing episodes via the provider, and
|
||||
# persists the refreshed metadata while emitting progress events.
|
||||
result = self.__find_mp4_files()
|
||||
counter = 0
|
||||
|
||||
for folder, mp4_files in result:
|
||||
try:
|
||||
counter += 1
|
||||
|
||||
# Calculate progress
|
||||
if total_to_scan > 0:
|
||||
percentage = (counter / total_to_scan) * 100
|
||||
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._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()
|
||||
):
|
||||
# Delegate the provider to compare local files with
|
||||
# remote metadata, yielding missing episodes per
|
||||
# season. Results are saved back to disk so that both
|
||||
# CLI and API consumers see consistent state.
|
||||
missing_episodes, _site = (
|
||||
self.__get_missing_episodes_and_season(
|
||||
serie.key, mp4_files
|
||||
)
|
||||
)
|
||||
serie.episodeDict = missing_episodes
|
||||
serie.folder = folder
|
||||
data_path = os.path.join(
|
||||
self.directory, folder, 'data'
|
||||
)
|
||||
serie.save_to_file(data_path)
|
||||
|
||||
# Store by key (primary identifier), not folder
|
||||
if serie.key in self.keyDict:
|
||||
logger.error(
|
||||
"Duplicate series found with key '%s' "
|
||||
"(folder: '%s')",
|
||||
serie.key,
|
||||
folder
|
||||
)
|
||||
else:
|
||||
self.keyDict[serie.key] = serie
|
||||
logger.debug(
|
||||
"Stored series with key '%s' (folder: '%s')",
|
||||
serie.key,
|
||||
folder
|
||||
)
|
||||
no_key_found_logger.info(
|
||||
"Saved Serie: '%s'", str(serie)
|
||||
)
|
||||
|
||||
except NoKeyFoundException as nkfe:
|
||||
# Log error and notify via callback
|
||||
error_msg = f"Error processing folder '{folder}': {nkfe}"
|
||||
logger.error(error_msg)
|
||||
|
||||
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
|
||||
error_msg = (
|
||||
f"Folder: '{folder}' - "
|
||||
f"Unexpected error: {e}"
|
||||
)
|
||||
error_logger.error(
|
||||
"%s\n%s",
|
||||
error_msg,
|
||||
traceback.format_exc()
|
||||
)
|
||||
|
||||
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._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(
|
||||
"Scan completed. Processed %d folders, found %d series",
|
||||
counter,
|
||||
len(self.keyDict)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Critical error - notify and re-raise
|
||||
error_msg = f"Critical scan error: {e}"
|
||||
logger.error("%s\n%s", error_msg, traceback.format_exc())
|
||||
|
||||
self._callback_manager.notify_error(
|
||||
ErrorContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id=self._current_operation_id,
|
||||
error=e,
|
||||
message=error_msg,
|
||||
recoverable=False
|
||||
)
|
||||
)
|
||||
|
||||
self._callback_manager.notify_completion(
|
||||
CompletionContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id=self._current_operation_id,
|
||||
success=False,
|
||||
message=error_msg
|
||||
)
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
async def scan_async(
|
||||
self,
|
||||
db: "AsyncSession",
|
||||
callback: Optional[Callable[[str, int], None]] = None
|
||||
) -> None:
|
||||
"""
|
||||
Scan directories for anime series and save to database.
|
||||
|
||||
This is the preferred method for scanning when using database
|
||||
storage. Results are saved to the database instead of files.
|
||||
|
||||
Args:
|
||||
db: Database session for async operations
|
||||
callback: Optional legacy callback function (folder, count)
|
||||
|
||||
Raises:
|
||||
Exception: If scan fails critically
|
||||
|
||||
Example:
|
||||
async with get_db_session() as db:
|
||||
scanner = SerieScanner("/path/to/anime", loader)
|
||||
await scanner.scan_async(db)
|
||||
"""
|
||||
# Generate unique operation ID
|
||||
self._current_operation_id = str(uuid.uuid4())
|
||||
|
||||
logger.info("Starting async scan for missing episodes (database mode)")
|
||||
|
||||
# Notify scan starting
|
||||
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 (database mode)"
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
# Get total items to process
|
||||
total_to_scan = self.get_total_to_scan()
|
||||
logger.info("Total folders to scan: %d", total_to_scan)
|
||||
|
||||
result = self.__find_mp4_files()
|
||||
counter = 0
|
||||
saved_to_db = 0
|
||||
|
||||
for folder, mp4_files in result:
|
||||
try:
|
||||
counter += 1
|
||||
|
||||
# Calculate progress
|
||||
if total_to_scan > 0:
|
||||
percentage = (counter / total_to_scan) * 100
|
||||
else:
|
||||
percentage = 0.0
|
||||
|
||||
# Notify progress
|
||||
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()
|
||||
):
|
||||
# Get missing episodes from provider
|
||||
missing_episodes, _site = (
|
||||
self.__get_missing_episodes_and_season(
|
||||
serie.key, mp4_files
|
||||
)
|
||||
)
|
||||
serie.episodeDict = missing_episodes
|
||||
serie.folder = folder
|
||||
|
||||
# Save to database instead of file
|
||||
await self._save_serie_to_db(serie, db)
|
||||
saved_to_db += 1
|
||||
|
||||
# Store by key in memory cache
|
||||
if serie.key in self.keyDict:
|
||||
logger.error(
|
||||
"Duplicate series found with key '%s' "
|
||||
"(folder: '%s')",
|
||||
serie.key,
|
||||
folder
|
||||
)
|
||||
else:
|
||||
self.keyDict[serie.key] = serie
|
||||
logger.debug(
|
||||
"Stored series with key '%s' (folder: '%s')",
|
||||
serie.key,
|
||||
folder
|
||||
)
|
||||
|
||||
except NoKeyFoundException as nkfe:
|
||||
error_msg = f"Error processing folder '{folder}': {nkfe}"
|
||||
logger.error(error_msg)
|
||||
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:
|
||||
error_msg = (
|
||||
f"Folder: '{folder}' - Unexpected error: {e}"
|
||||
)
|
||||
error_logger.error(
|
||||
"%s\n%s",
|
||||
error_msg,
|
||||
traceback.format_exc()
|
||||
)
|
||||
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._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),
|
||||
"saved_to_db": saved_to_db
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Async scan completed. Processed %d folders, "
|
||||
"found %d series, saved %d to database",
|
||||
counter,
|
||||
len(self.keyDict),
|
||||
saved_to_db
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Critical async scan error: {e}"
|
||||
logger.error("%s\n%s", error_msg, traceback.format_exc())
|
||||
|
||||
self._callback_manager.notify_error(
|
||||
ErrorContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id=self._current_operation_id,
|
||||
error=e,
|
||||
message=error_msg,
|
||||
recoverable=False
|
||||
)
|
||||
)
|
||||
|
||||
self._callback_manager.notify_completion(
|
||||
CompletionContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id=self._current_operation_id,
|
||||
success=False,
|
||||
message=error_msg
|
||||
)
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
async def _save_serie_to_db(
|
||||
self,
|
||||
serie: Serie,
|
||||
db: "AsyncSession"
|
||||
) -> Optional["AnimeSeries"]:
|
||||
"""
|
||||
Save or update a series in the database.
|
||||
|
||||
Creates a new record if the series doesn't exist, or updates
|
||||
the episodes if they have changed.
|
||||
|
||||
Args:
|
||||
serie: Serie instance to save
|
||||
db: Database session for async operations
|
||||
|
||||
Returns:
|
||||
Created or updated AnimeSeries instance, or None if unchanged
|
||||
"""
|
||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||
|
||||
# Check if series already exists
|
||||
existing = await AnimeSeriesService.get_by_key(db, serie.key)
|
||||
|
||||
if existing:
|
||||
# Build existing episode dict from episodes for comparison
|
||||
existing_episodes = await EpisodeService.get_by_series(
|
||||
db, existing.id
|
||||
)
|
||||
existing_dict: dict[int, list[int]] = {}
|
||||
for ep in existing_episodes:
|
||||
if ep.season not in existing_dict:
|
||||
existing_dict[ep.season] = []
|
||||
existing_dict[ep.season].append(ep.episode_number)
|
||||
for season in existing_dict:
|
||||
existing_dict[season].sort()
|
||||
|
||||
# Update episodes if changed
|
||||
if existing_dict != serie.episodeDict:
|
||||
# Add new episodes
|
||||
new_dict = serie.episodeDict or {}
|
||||
for season, episode_numbers in new_dict.items():
|
||||
existing_eps = set(existing_dict.get(season, []))
|
||||
for ep_num in episode_numbers:
|
||||
if ep_num not in existing_eps:
|
||||
await EpisodeService.create(
|
||||
db=db,
|
||||
series_id=existing.id,
|
||||
season=season,
|
||||
episode_number=ep_num,
|
||||
)
|
||||
|
||||
# Update folder if changed
|
||||
if existing.folder != serie.folder:
|
||||
await AnimeSeriesService.update(
|
||||
db,
|
||||
existing.id,
|
||||
folder=serie.folder
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Updated series in database: %s (key=%s)",
|
||||
serie.name,
|
||||
serie.key
|
||||
)
|
||||
return existing
|
||||
else:
|
||||
logger.debug(
|
||||
"Series unchanged in database: %s (key=%s)",
|
||||
serie.name,
|
||||
serie.key
|
||||
)
|
||||
return None
|
||||
else:
|
||||
# Create new series
|
||||
anime_series = await AnimeSeriesService.create(
|
||||
db=db,
|
||||
key=serie.key,
|
||||
name=serie.name,
|
||||
site=serie.site,
|
||||
folder=serie.folder,
|
||||
)
|
||||
|
||||
# Create Episode records
|
||||
if serie.episodeDict:
|
||||
for season, episode_numbers in serie.episodeDict.items():
|
||||
for ep_num in episode_numbers:
|
||||
await EpisodeService.create(
|
||||
db=db,
|
||||
series_id=anime_series.id,
|
||||
season=season,
|
||||
episode_number=ep_num,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Created series in database: %s (key=%s)",
|
||||
serie.name,
|
||||
serie.key
|
||||
)
|
||||
return anime_series
|
||||
|
||||
async def _update_serie_in_db(
|
||||
self,
|
||||
serie: Serie,
|
||||
db: "AsyncSession"
|
||||
) -> Optional["AnimeSeries"]:
|
||||
"""
|
||||
Update an existing series in the database.
|
||||
|
||||
Args:
|
||||
serie: Serie instance to update
|
||||
db: Database session for async operations
|
||||
|
||||
Returns:
|
||||
Updated AnimeSeries instance, or None if not found
|
||||
"""
|
||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||
|
||||
existing = await AnimeSeriesService.get_by_key(db, serie.key)
|
||||
if not existing:
|
||||
logger.warning(
|
||||
"Cannot update non-existent series: %s (key=%s)",
|
||||
serie.name,
|
||||
serie.key
|
||||
)
|
||||
return None
|
||||
|
||||
# Update basic fields
|
||||
await AnimeSeriesService.update(
|
||||
db,
|
||||
existing.id,
|
||||
name=serie.name,
|
||||
site=serie.site,
|
||||
folder=serie.folder,
|
||||
)
|
||||
|
||||
# Update episodes - add any new ones
|
||||
if serie.episodeDict:
|
||||
existing_episodes = await EpisodeService.get_by_series(
|
||||
db, existing.id
|
||||
)
|
||||
existing_dict: dict[int, set[int]] = {}
|
||||
for ep in existing_episodes:
|
||||
if ep.season not in existing_dict:
|
||||
existing_dict[ep.season] = set()
|
||||
existing_dict[ep.season].add(ep.episode_number)
|
||||
|
||||
for season, episode_numbers in serie.episodeDict.items():
|
||||
existing_eps = existing_dict.get(season, set())
|
||||
for ep_num in episode_numbers:
|
||||
if ep_num not in existing_eps:
|
||||
await EpisodeService.create(
|
||||
db=db,
|
||||
series_id=existing.id,
|
||||
season=season,
|
||||
episode_number=ep_num,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Updated series in database: %s (key=%s)",
|
||||
serie.name,
|
||||
serie.key
|
||||
)
|
||||
return existing
|
||||
|
||||
def __find_mp4_files(self) -> Iterator[tuple[str, list[str]]]:
|
||||
"""Find all .mp4 files in the directory structure."""
|
||||
logger.info("Scanning for .mp4 files")
|
||||
for anime_name in os.listdir(self.directory):
|
||||
anime_path = os.path.join(self.directory, anime_name)
|
||||
if os.path.isdir(anime_path):
|
||||
mp4_files: list[str] = []
|
||||
has_files = False
|
||||
for root, _, files in os.walk(anime_path):
|
||||
for file in files:
|
||||
if file.endswith(".mp4"):
|
||||
mp4_files.append(os.path.join(root, file))
|
||||
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.
|
||||
|
||||
Args:
|
||||
folder_name: Filesystem folder name
|
||||
(used only to locate data files)
|
||||
|
||||
Returns:
|
||||
Serie object with valid key if found, None otherwise
|
||||
|
||||
Note:
|
||||
The returned Serie will have its 'key' as the primary identifier.
|
||||
The 'folder' field is metadata only.
|
||||
"""
|
||||
folder_path = os.path.join(self.directory, folder_name)
|
||||
key = None
|
||||
key_file = os.path.join(folder_path, 'key')
|
||||
serie_file = os.path.join(folder_path, 'data')
|
||||
|
||||
if os.path.exists(key_file):
|
||||
with open(key_file, 'r', encoding='utf-8') as file:
|
||||
key = file.read().strip()
|
||||
logger.info(
|
||||
"Key found for folder '%s': %s",
|
||||
folder_name,
|
||||
key
|
||||
)
|
||||
return Serie(key, "", "aniworld.to", folder_name, dict())
|
||||
|
||||
if os.path.exists(serie_file):
|
||||
with open(serie_file, "rb") as file:
|
||||
logger.info(
|
||||
"load serie_file from '%s': %s",
|
||||
folder_name,
|
||||
serie_file
|
||||
)
|
||||
return Serie.load_from_file(serie_file)
|
||||
|
||||
return None
|
||||
|
||||
def __get_episode_and_season(self, filename: str) -> tuple[int, int]:
|
||||
"""Extract season and episode numbers from filename.
|
||||
|
||||
Args:
|
||||
filename: Filename to parse
|
||||
|
||||
Returns:
|
||||
Tuple of (season, episode) as integers
|
||||
|
||||
Raises:
|
||||
MatchNotFoundError: If pattern not found
|
||||
"""
|
||||
pattern = r'S(\d+)E(\d+)'
|
||||
match = re.search(pattern, filename)
|
||||
if match:
|
||||
season = match.group(1)
|
||||
episode = match.group(2)
|
||||
logger.debug(
|
||||
"Extracted season %s, episode %s from '%s'",
|
||||
season,
|
||||
episode,
|
||||
filename
|
||||
)
|
||||
return int(season), int(episode)
|
||||
else:
|
||||
logger.error(
|
||||
"Failed to find season/episode pattern in '%s'",
|
||||
filename
|
||||
)
|
||||
raise MatchNotFoundError(
|
||||
"Season and episode pattern not found in the filename."
|
||||
)
|
||||
|
||||
def __get_episodes_and_seasons(
|
||||
self,
|
||||
mp4_files: Iterable[str]
|
||||
) -> dict[int, list[int]]:
|
||||
"""Get episodes grouped by season from mp4 files.
|
||||
|
||||
Args:
|
||||
mp4_files: List of MP4 filenames
|
||||
|
||||
Returns:
|
||||
Dictionary mapping season to list of episode numbers
|
||||
"""
|
||||
episodes_dict: dict[int, list[int]] = {}
|
||||
|
||||
for file in mp4_files:
|
||||
season, episode = self.__get_episode_and_season(file)
|
||||
|
||||
if season in episodes_dict:
|
||||
episodes_dict[season].append(episode)
|
||||
else:
|
||||
episodes_dict[season] = [episode]
|
||||
return episodes_dict
|
||||
|
||||
def __get_missing_episodes_and_season(
|
||||
self,
|
||||
key: str,
|
||||
mp4_files: Iterable[str]
|
||||
) -> tuple[dict[int, list[int]], str]:
|
||||
"""Get missing episodes for a serie.
|
||||
|
||||
Args:
|
||||
key: Series key
|
||||
mp4_files: List of MP4 filenames
|
||||
|
||||
Returns:
|
||||
Tuple of (episodes_dict, site_name)
|
||||
"""
|
||||
# key season , value count of episodes
|
||||
expected_dict = self.loader.get_season_episode_count(key)
|
||||
filedict = self.__get_episodes_and_seasons(mp4_files)
|
||||
episodes_dict: dict[int, list[int]] = {}
|
||||
for season, expected_count in expected_dict.items():
|
||||
existing_episodes = filedict.get(season, [])
|
||||
missing_episodes = [
|
||||
ep for ep in range(1, expected_count + 1)
|
||||
if ep not in existing_episodes
|
||||
and self.loader.is_language(season, ep, key)
|
||||
]
|
||||
|
||||
if missing_episodes:
|
||||
episodes_dict[season] = missing_episodes
|
||||
|
||||
return episodes_dict, "aniworld.to"
|
||||
@@ -1,423 +0,0 @@
|
||||
"""Utilities for loading and managing stored anime series metadata.
|
||||
|
||||
This module provides the SerieList class for managing collections of anime
|
||||
series metadata. It supports both file-based and database-backed storage.
|
||||
|
||||
The class can operate in two modes:
|
||||
1. File-based mode (legacy): Reads/writes data files from disk
|
||||
2. Database mode: Reads/writes to SQLite database via AnimeSeriesService
|
||||
|
||||
Database mode is preferred for new code. File-based mode is kept for
|
||||
backward compatibility with CLI usage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import warnings
|
||||
from json import JSONDecodeError
|
||||
from typing import TYPE_CHECKING, Dict, Iterable, List, Optional
|
||||
|
||||
from src.core.entities.series import Serie
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.server.database.models import AnimeSeries
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SerieList:
|
||||
"""
|
||||
Represents the collection of cached series stored on disk or database.
|
||||
|
||||
Series are identified by their unique 'key' (provider identifier).
|
||||
The 'folder' is metadata only and not used for lookups.
|
||||
|
||||
The class supports two modes of operation:
|
||||
|
||||
1. File-based mode (legacy):
|
||||
Initialize without db_session to use file-based storage.
|
||||
Series are loaded from 'data' files in the anime directory.
|
||||
|
||||
2. Database mode (preferred):
|
||||
Pass db_session to use database-backed storage via AnimeSeriesService.
|
||||
Series are loaded from the AnimeSeries table.
|
||||
|
||||
Example:
|
||||
# File-based mode (legacy)
|
||||
serie_list = SerieList("/path/to/anime")
|
||||
|
||||
# Database mode (preferred)
|
||||
async with get_db_session() as db:
|
||||
serie_list = SerieList("/path/to/anime", db_session=db)
|
||||
await serie_list.load_series_from_db()
|
||||
|
||||
Attributes:
|
||||
directory: Path to the anime directory
|
||||
keyDict: Internal dictionary mapping serie.key to Serie objects
|
||||
_db_session: Optional database session for database mode
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_path: str,
|
||||
db_session: Optional["AsyncSession"] = None,
|
||||
skip_load: bool = False
|
||||
) -> None:
|
||||
"""Initialize the SerieList.
|
||||
|
||||
Args:
|
||||
base_path: Path to the anime directory
|
||||
db_session: Optional database session for database mode.
|
||||
If provided, use load_series_from_db() instead of
|
||||
the automatic file-based loading.
|
||||
skip_load: If True, skip automatic loading of series.
|
||||
Useful when using database mode to allow async loading.
|
||||
"""
|
||||
self.directory: str = base_path
|
||||
# Internal storage using serie.key as the dictionary key
|
||||
self.keyDict: Dict[str, Serie] = {}
|
||||
self._db_session: Optional["AsyncSession"] = db_session
|
||||
|
||||
# Only auto-load from files if no db_session and not skipping
|
||||
if not skip_load and db_session is None:
|
||||
self.load_series()
|
||||
|
||||
def add(self, serie: Serie) -> None:
|
||||
"""
|
||||
Persist a new series if it is not already present (file-based mode).
|
||||
|
||||
Uses serie.key for identification. The serie.folder is used for
|
||||
filesystem operations only.
|
||||
|
||||
.. deprecated:: 2.0.0
|
||||
Use :meth:`add_to_db` for database-backed storage.
|
||||
File-based storage will be removed in a future version.
|
||||
|
||||
Args:
|
||||
serie: The Serie instance to add
|
||||
|
||||
Note:
|
||||
This method creates data files on disk. For database storage,
|
||||
use add_to_db() instead.
|
||||
"""
|
||||
if self.contains(serie.key):
|
||||
return
|
||||
|
||||
warnings.warn(
|
||||
"File-based storage via add() is deprecated. "
|
||||
"Use add_to_db() for database storage.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
async def add_to_db(
|
||||
self,
|
||||
serie: Serie,
|
||||
db: "AsyncSession"
|
||||
) -> Optional["AnimeSeries"]:
|
||||
"""
|
||||
Add a series to the database.
|
||||
|
||||
Uses serie.key for identification. Creates a new AnimeSeries
|
||||
record in the database if it doesn't already exist.
|
||||
|
||||
Args:
|
||||
serie: The Serie instance to add
|
||||
db: Database session for async operations
|
||||
|
||||
Returns:
|
||||
Created AnimeSeries instance, or None if already exists
|
||||
|
||||
Example:
|
||||
async with get_db_session() as db:
|
||||
result = await serie_list.add_to_db(serie, db)
|
||||
if result:
|
||||
print(f"Added series: {result.name}")
|
||||
"""
|
||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||
|
||||
# Check if series already exists in DB
|
||||
existing = await AnimeSeriesService.get_by_key(db, serie.key)
|
||||
if existing:
|
||||
logger.debug(
|
||||
"Series already exists in database: %s (key=%s)",
|
||||
serie.name,
|
||||
serie.key
|
||||
)
|
||||
return None
|
||||
|
||||
# Create new series in database
|
||||
anime_series = await AnimeSeriesService.create(
|
||||
db=db,
|
||||
key=serie.key,
|
||||
name=serie.name,
|
||||
site=serie.site,
|
||||
folder=serie.folder,
|
||||
)
|
||||
|
||||
# Create Episode records for each episode in episodeDict
|
||||
if serie.episodeDict:
|
||||
for season, episode_numbers in serie.episodeDict.items():
|
||||
for episode_number in episode_numbers:
|
||||
await EpisodeService.create(
|
||||
db=db,
|
||||
series_id=anime_series.id,
|
||||
season=season,
|
||||
episode_number=episode_number,
|
||||
)
|
||||
|
||||
# Also add to in-memory collection
|
||||
self.keyDict[serie.key] = serie
|
||||
|
||||
logger.info(
|
||||
"Added series to database: %s (key=%s)",
|
||||
serie.name,
|
||||
serie.key
|
||||
)
|
||||
|
||||
return anime_series
|
||||
|
||||
def contains(self, key: str) -> bool:
|
||||
"""
|
||||
Return True when a series identified by ``key`` already exists.
|
||||
|
||||
Args:
|
||||
key: The unique provider identifier for the series
|
||||
|
||||
Returns:
|
||||
True if the series exists in the collection
|
||||
"""
|
||||
return key in self.keyDict
|
||||
|
||||
def load_series(self) -> None:
|
||||
"""Populate the in-memory map with metadata discovered on disk."""
|
||||
|
||||
logging.info("Scanning anime folders in %s", self.directory)
|
||||
try:
|
||||
entries: Iterable[str] = os.listdir(self.directory)
|
||||
except OSError as error:
|
||||
logging.error(
|
||||
"Unable to scan directory %s: %s",
|
||||
self.directory,
|
||||
error,
|
||||
)
|
||||
return
|
||||
|
||||
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)
|
||||
self._load_data(anime_folder, anime_path)
|
||||
continue
|
||||
|
||||
logging.warning(
|
||||
"Skipping folder %s because no metadata file was found",
|
||||
anime_folder,
|
||||
)
|
||||
|
||||
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
|
||||
"""
|
||||
try:
|
||||
serie = Serie.load_from_file(data_path)
|
||||
# Store by key, not folder
|
||||
self.keyDict[serie.key] = serie
|
||||
logging.debug(
|
||||
"Successfully loaded metadata for %s (key: %s)",
|
||||
anime_folder,
|
||||
serie.key
|
||||
)
|
||||
except (OSError, JSONDecodeError, KeyError, ValueError) as error:
|
||||
logging.error(
|
||||
"Failed to load metadata for folder %s from %s: %s",
|
||||
anime_folder,
|
||||
data_path,
|
||||
error,
|
||||
)
|
||||
|
||||
async def load_series_from_db(self, db: "AsyncSession") -> int:
|
||||
"""
|
||||
Load all series from the database into the in-memory collection.
|
||||
|
||||
This is the preferred method for populating the series list
|
||||
when using database-backed storage.
|
||||
|
||||
Args:
|
||||
db: Database session for async operations
|
||||
|
||||
Returns:
|
||||
Number of series loaded from the database
|
||||
|
||||
Example:
|
||||
async with get_db_session() as db:
|
||||
serie_list = SerieList("/path/to/anime", skip_load=True)
|
||||
count = await serie_list.load_series_from_db(db)
|
||||
print(f"Loaded {count} series from database")
|
||||
"""
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
|
||||
# Clear existing in-memory data
|
||||
self.keyDict.clear()
|
||||
|
||||
# Load all series from database (with episodes for episodeDict)
|
||||
anime_series_list = await AnimeSeriesService.get_all(
|
||||
db, with_episodes=True
|
||||
)
|
||||
|
||||
for anime_series in anime_series_list:
|
||||
serie = self._convert_from_db(anime_series)
|
||||
self.keyDict[serie.key] = serie
|
||||
|
||||
logger.info(
|
||||
"Loaded %d series from database",
|
||||
len(self.keyDict)
|
||||
)
|
||||
|
||||
return len(self.keyDict)
|
||||
|
||||
@staticmethod
|
||||
def _convert_from_db(anime_series: "AnimeSeries") -> Serie:
|
||||
"""
|
||||
Convert an AnimeSeries database model to a Serie entity.
|
||||
|
||||
Args:
|
||||
anime_series: AnimeSeries model from database
|
||||
(must have episodes relationship loaded)
|
||||
|
||||
Returns:
|
||||
Serie entity instance
|
||||
"""
|
||||
# Build episode_dict from episodes relationship
|
||||
episode_dict: dict[int, list[int]] = {}
|
||||
if anime_series.episodes:
|
||||
for episode in anime_series.episodes:
|
||||
season = episode.season
|
||||
if season not in episode_dict:
|
||||
episode_dict[season] = []
|
||||
episode_dict[season].append(episode.episode_number)
|
||||
# Sort episode numbers within each season
|
||||
for season in episode_dict:
|
||||
episode_dict[season].sort()
|
||||
|
||||
return Serie(
|
||||
key=anime_series.key,
|
||||
name=anime_series.name,
|
||||
site=anime_series.site,
|
||||
folder=anime_series.folder,
|
||||
episodeDict=episode_dict
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _convert_to_db_dict(serie: Serie) -> dict:
|
||||
"""
|
||||
Convert a Serie entity to a dictionary for database creation.
|
||||
|
||||
Args:
|
||||
serie: Serie entity instance
|
||||
|
||||
Returns:
|
||||
Dictionary suitable for AnimeSeriesService.create()
|
||||
"""
|
||||
return {
|
||||
"key": serie.key,
|
||||
"name": serie.name,
|
||||
"site": serie.site,
|
||||
"folder": serie.folder,
|
||||
}
|
||||
|
||||
async def contains_in_db(self, key: str, db: "AsyncSession") -> bool:
|
||||
"""
|
||||
Check if a series with the given key exists in the database.
|
||||
|
||||
Args:
|
||||
key: The unique provider identifier for the series
|
||||
db: Database session for async operations
|
||||
|
||||
Returns:
|
||||
True if the series exists in the database
|
||||
"""
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
|
||||
existing = await AnimeSeriesService.get_by_key(db, key)
|
||||
return existing is not None
|
||||
|
||||
def GetMissingEpisode(self) -> List[Serie]:
|
||||
"""Return all series that still contain missing episodes."""
|
||||
return [
|
||||
serie
|
||||
for serie in self.keyDict.values()
|
||||
if serie.episodeDict
|
||||
]
|
||||
|
||||
def get_missing_episodes(self) -> List[Serie]:
|
||||
"""PEP8-friendly alias for :meth:`GetMissingEpisode`."""
|
||||
return self.GetMissingEpisode()
|
||||
|
||||
def GetList(self) -> List[Serie]:
|
||||
"""Return all series instances stored in the list."""
|
||||
return list(self.keyDict.values())
|
||||
|
||||
def get_all(self) -> List[Serie]:
|
||||
"""PEP8-friendly alias for :meth:`GetList`."""
|
||||
return self.GetList()
|
||||
|
||||
def get_by_key(self, key: str) -> Optional[Serie]:
|
||||
"""
|
||||
Get a series by its unique provider key.
|
||||
|
||||
This is the primary method for series lookup.
|
||||
|
||||
Args:
|
||||
key: The unique provider identifier (e.g., "attack-on-titan")
|
||||
|
||||
Returns:
|
||||
The Serie instance if found, None otherwise
|
||||
"""
|
||||
return self.keyDict.get(key)
|
||||
|
||||
def get_by_folder(self, folder: str) -> Optional[Serie]:
|
||||
"""
|
||||
Get a series by its folder name.
|
||||
|
||||
.. deprecated:: 2.0.0
|
||||
Use :meth:`get_by_key` instead. Folder-based lookups will be
|
||||
removed in version 3.0.0. The `folder` field is metadata only
|
||||
and should not be used for identification.
|
||||
|
||||
This method is provided for backward compatibility only.
|
||||
Prefer using get_by_key() for new code.
|
||||
|
||||
Args:
|
||||
folder: The filesystem folder name (e.g., "Attack on Titan (2013)")
|
||||
|
||||
Returns:
|
||||
The Serie instance if found, None otherwise
|
||||
"""
|
||||
warnings.warn(
|
||||
"get_by_folder() is deprecated and will be removed in v3.0.0. "
|
||||
"Use get_by_key() instead. The 'folder' field is metadata only.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
for serie in self.keyDict.values():
|
||||
if serie.folder == folder:
|
||||
return serie
|
||||
return None
|
||||
@@ -1,200 +0,0 @@
|
||||
import json
|
||||
import warnings
|
||||
|
||||
|
||||
class Serie:
|
||||
"""
|
||||
Represents an anime series with metadata and episode information.
|
||||
|
||||
The `key` property is the unique identifier for the series
|
||||
(provider-assigned, URL-safe).
|
||||
The `folder` property is the filesystem folder name
|
||||
(metadata only, not used for lookups).
|
||||
|
||||
Args:
|
||||
key: Unique series identifier from provider
|
||||
(e.g., "attack-on-titan"). Cannot be empty.
|
||||
name: Display name of the series
|
||||
site: Provider site URL
|
||||
folder: Filesystem folder name (metadata only,
|
||||
e.g., "Attack on Titan (2013)")
|
||||
episodeDict: Dictionary mapping season numbers to
|
||||
lists of episode numbers
|
||||
|
||||
Raises:
|
||||
ValueError: If key is None or empty string
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key: str,
|
||||
name: str,
|
||||
site: str,
|
||||
folder: str,
|
||||
episodeDict: dict[int, list[int]]
|
||||
):
|
||||
if not key or not key.strip():
|
||||
raise ValueError("Serie key cannot be None or empty")
|
||||
|
||||
self._key = key.strip()
|
||||
self._name = name
|
||||
self._site = site
|
||||
self._folder = folder
|
||||
self._episodeDict = episodeDict
|
||||
|
||||
def __str__(self):
|
||||
"""String representation of Serie object"""
|
||||
return (
|
||||
f"Serie(key='{self.key}', name='{self.name}', "
|
||||
f"site='{self.site}', folder='{self.folder}', "
|
||||
f"episodeDict={self.episodeDict})"
|
||||
)
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
"""
|
||||
Unique series identifier (primary identifier for all lookups).
|
||||
|
||||
This is the provider-assigned, URL-safe identifier used
|
||||
throughout the application for series identification,
|
||||
lookups, and operations.
|
||||
|
||||
Returns:
|
||||
str: The unique series key
|
||||
"""
|
||||
return self._key
|
||||
|
||||
@key.setter
|
||||
def key(self, value: str):
|
||||
"""
|
||||
Set the unique series identifier.
|
||||
|
||||
Args:
|
||||
value: New key value
|
||||
|
||||
Raises:
|
||||
ValueError: If value is None or empty string
|
||||
"""
|
||||
if not value or not value.strip():
|
||||
raise ValueError("Serie key cannot be None or empty")
|
||||
self._key = value.strip()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@name.setter
|
||||
def name(self, value: str):
|
||||
self._name = value
|
||||
|
||||
@property
|
||||
def site(self) -> str:
|
||||
return self._site
|
||||
|
||||
@site.setter
|
||||
def site(self, value: str):
|
||||
self._site = value
|
||||
|
||||
@property
|
||||
def folder(self) -> str:
|
||||
"""
|
||||
Filesystem folder name (metadata only, not used for lookups).
|
||||
|
||||
This property contains the local directory name where the series
|
||||
files are stored. It should NOT be used as an identifier for
|
||||
series lookups - use `key` instead.
|
||||
|
||||
Returns:
|
||||
str: The filesystem folder name
|
||||
"""
|
||||
return self._folder
|
||||
|
||||
@folder.setter
|
||||
def folder(self, value: str):
|
||||
"""
|
||||
Set the filesystem folder name.
|
||||
|
||||
Args:
|
||||
value: Folder name for the series
|
||||
"""
|
||||
self._folder = value
|
||||
|
||||
@property
|
||||
def episodeDict(self) -> dict[int, list[int]]:
|
||||
return self._episodeDict
|
||||
|
||||
@episodeDict.setter
|
||||
def episodeDict(self, value: dict[int, list[int]]):
|
||||
self._episodeDict = value
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert Serie object to dictionary for JSON serialization."""
|
||||
return {
|
||||
"key": self.key,
|
||||
"name": self.name,
|
||||
"site": self.site,
|
||||
"folder": self.folder,
|
||||
"episodeDict": {
|
||||
str(k): list(v) for k, v in self.episodeDict.items()
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict):
|
||||
"""Create a Serie object from dictionary."""
|
||||
# Convert keys to int
|
||||
episode_dict = {
|
||||
int(k): v for k, v in data["episodeDict"].items()
|
||||
}
|
||||
return Serie(
|
||||
data["key"],
|
||||
data["name"],
|
||||
data["site"],
|
||||
data["folder"],
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
with open(filename, "r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
return cls.from_dict(data)
|
||||
@@ -1,149 +0,0 @@
|
||||
"""
|
||||
Error handling and recovery strategies for core providers.
|
||||
|
||||
This module provides custom exceptions and decorators for handling
|
||||
errors in provider operations with automatic retry mechanisms.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
from typing import Any, Callable, TypeVar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Type variable for decorator
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
class RetryableError(Exception):
|
||||
"""Exception that indicates an operation can be safely retried."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class NonRetryableError(Exception):
|
||||
"""Exception that indicates an operation should not be retried."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class NetworkError(Exception):
|
||||
"""Exception for network-related errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class DownloadError(Exception):
|
||||
"""Exception for download-related errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RecoveryStrategies:
|
||||
"""Strategies for handling errors and recovering from failures."""
|
||||
|
||||
@staticmethod
|
||||
def handle_network_failure(
|
||||
func: Callable, *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
"""Handle network failures with basic retry logic."""
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except (NetworkError, ConnectionError):
|
||||
if attempt == max_retries - 1:
|
||||
raise
|
||||
logger.warning(
|
||||
f"Network error on attempt {attempt + 1}, retrying..."
|
||||
)
|
||||
continue
|
||||
|
||||
@staticmethod
|
||||
def handle_download_failure(
|
||||
func: Callable, *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
"""Handle download failures with retry logic."""
|
||||
max_retries = 2
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except DownloadError:
|
||||
if attempt == max_retries - 1:
|
||||
raise
|
||||
logger.warning(
|
||||
f"Download error on attempt {attempt + 1}, retrying..."
|
||||
)
|
||||
continue
|
||||
|
||||
|
||||
class FileCorruptionDetector:
|
||||
"""Detector for corrupted files."""
|
||||
|
||||
@staticmethod
|
||||
def is_valid_video_file(filepath: str) -> bool:
|
||||
"""Check if a video file is valid and not corrupted."""
|
||||
try:
|
||||
import os
|
||||
if not os.path.exists(filepath):
|
||||
return False
|
||||
|
||||
file_size = os.path.getsize(filepath)
|
||||
# Video files should be at least 1MB
|
||||
return file_size > 1024 * 1024
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking file validity: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def with_error_recovery(
|
||||
max_retries: int = 3, context: str = ""
|
||||
) -> Callable[[F], F]:
|
||||
"""
|
||||
Decorator for adding error recovery to functions.
|
||||
|
||||
Args:
|
||||
max_retries: Maximum number of retry attempts
|
||||
context: Context string for logging
|
||||
|
||||
Returns:
|
||||
Decorated function with retry logic
|
||||
"""
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
last_error = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except NonRetryableError:
|
||||
raise
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning(
|
||||
f"Error in {context} (attempt {attempt + 1}/"
|
||||
f"{max_retries}): {e}, retrying..."
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"Error in {context} failed after {max_retries} "
|
||||
f"attempts: {e}"
|
||||
)
|
||||
|
||||
if last_error:
|
||||
raise last_error
|
||||
|
||||
raise RuntimeError(
|
||||
f"Unexpected error in {context} after {max_retries} attempts"
|
||||
)
|
||||
|
||||
return wrapper # type: ignore
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
# Create module-level instances for use in provider code
|
||||
recovery_strategies = RecoveryStrategies()
|
||||
file_corruption_detector = FileCorruptionDetector()
|
||||
@@ -1,597 +0,0 @@
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from fake_useragent import UserAgent
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
from yt_dlp import YoutubeDL
|
||||
|
||||
from ..interfaces.providers import Providers
|
||||
from .base_provider import Loader
|
||||
|
||||
# Imported shared provider configuration
|
||||
from .provider_config import (
|
||||
ANIWORLD_HEADERS,
|
||||
DEFAULT_DOWNLOAD_TIMEOUT,
|
||||
DEFAULT_PROVIDERS,
|
||||
INVALID_PATH_CHARS,
|
||||
LULUVDO_USER_AGENT,
|
||||
ProviderType,
|
||||
)
|
||||
|
||||
# Configure persistent loggers but don't add duplicate handlers when module
|
||||
# is imported multiple times (common in test environments).
|
||||
# Use absolute paths for log files to prevent security issues
|
||||
|
||||
# Determine project root (assuming this file is in src/core/providers/)
|
||||
_module_dir = Path(__file__).parent
|
||||
_project_root = _module_dir.parent.parent.parent
|
||||
_logs_dir = _project_root / "logs"
|
||||
|
||||
# Ensure logs directory exists
|
||||
_logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
download_error_logger = logging.getLogger("DownloadErrors")
|
||||
if not download_error_logger.handlers:
|
||||
log_path = _logs_dir / "download_errors.log"
|
||||
download_error_handler = logging.FileHandler(str(log_path))
|
||||
download_error_handler.setLevel(logging.ERROR)
|
||||
download_error_logger.addHandler(download_error_handler)
|
||||
|
||||
noKeyFound_logger = logging.getLogger()
|
||||
|
||||
|
||||
class AniworldLoader(Loader):
|
||||
def __init__(self) -> None:
|
||||
self.SUPPORTED_PROVIDERS = DEFAULT_PROVIDERS
|
||||
# Copy default AniWorld headers so modifications remain local
|
||||
self.AniworldHeaders = dict(ANIWORLD_HEADERS)
|
||||
self.INVALID_PATH_CHARS = INVALID_PATH_CHARS
|
||||
self.RANDOM_USER_AGENT = UserAgent().random
|
||||
self.LULUVDO_USER_AGENT = LULUVDO_USER_AGENT
|
||||
self.PROVIDER_HEADERS = {
|
||||
ProviderType.VIDMOLY.value: ['Referer: "https://vidmoly.to"'],
|
||||
ProviderType.DOODSTREAM.value: ['Referer: "https://dood.li/"'],
|
||||
ProviderType.VOE.value: [f"User-Agent: {self.RANDOM_USER_AGENT}"],
|
||||
ProviderType.LULUVDO.value: [
|
||||
f"User-Agent: {self.LULUVDO_USER_AGENT}",
|
||||
"Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
'Origin: "https://luluvdo.com"',
|
||||
'Referer: "https://luluvdo.com/"',
|
||||
],
|
||||
}
|
||||
self.ANIWORLD_TO = "https://aniworld.to"
|
||||
self.session = requests.Session()
|
||||
|
||||
# Configure retries with backoff
|
||||
retries = Retry(
|
||||
total=5, # Number of retries
|
||||
backoff_factor=1, # Delay multiplier (1s, 2s, 4s, ...)
|
||||
status_forcelist=[500, 502, 503, 504],
|
||||
allowed_methods=["GET"]
|
||||
)
|
||||
|
||||
adapter = HTTPAdapter(max_retries=retries)
|
||||
self.session.mount("https://", adapter)
|
||||
# Default HTTP request timeout used for requests.Session calls.
|
||||
# Allows overriding via DOWNLOAD_TIMEOUT env var at runtime.
|
||||
self.DEFAULT_REQUEST_TIMEOUT = int(
|
||||
os.getenv("DOWNLOAD_TIMEOUT") or DEFAULT_DOWNLOAD_TIMEOUT
|
||||
)
|
||||
|
||||
self._KeyHTMLDict = {}
|
||||
self._EpisodeHTMLDict = {}
|
||||
self.Providers = Providers()
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the cached HTML data."""
|
||||
logging.debug("Clearing HTML cache")
|
||||
self._KeyHTMLDict = {}
|
||||
self._EpisodeHTMLDict = {}
|
||||
logging.debug("HTML cache cleared successfully")
|
||||
|
||||
def remove_from_cache(self):
|
||||
"""Remove episode HTML from cache."""
|
||||
logging.debug("Removing episode HTML from cache")
|
||||
self._EpisodeHTMLDict = {}
|
||||
logging.debug("Episode HTML cache cleared")
|
||||
|
||||
def search(self, word: str) -> list:
|
||||
"""Search for anime series.
|
||||
|
||||
Args:
|
||||
word: Search term
|
||||
|
||||
Returns:
|
||||
List of found series
|
||||
"""
|
||||
logging.info(f"Searching for anime with keyword: '{word}'")
|
||||
search_url = (
|
||||
f"{self.ANIWORLD_TO}/ajax/seriesSearch?keyword={quote(word)}"
|
||||
)
|
||||
logging.debug(f"Search URL: {search_url}")
|
||||
anime_list = self.fetch_anime_list(search_url)
|
||||
logging.info(f"Found {len(anime_list)} anime series for keyword '{word}'")
|
||||
|
||||
return anime_list
|
||||
|
||||
def fetch_anime_list(self, url: str) -> list:
|
||||
logging.debug(f"Fetching anime list from URL: {url}")
|
||||
response = self.session.get(url, timeout=self.DEFAULT_REQUEST_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
logging.debug(f"Response status code: {response.status_code}")
|
||||
|
||||
clean_text = response.text.strip()
|
||||
|
||||
try:
|
||||
decoded_data = json.loads(html.unescape(clean_text))
|
||||
logging.debug(f"Successfully decoded JSON data on first attempt")
|
||||
return decoded_data if isinstance(decoded_data, list) else []
|
||||
except json.JSONDecodeError:
|
||||
logging.warning("Initial JSON decode failed, attempting cleanup")
|
||||
try:
|
||||
# Remove BOM and problematic characters
|
||||
clean_text = clean_text.encode('utf-8').decode('utf-8-sig')
|
||||
# Remove problematic characters
|
||||
clean_text = re.sub(r'[\x00-\x1F\x7F-\x9F]', '', clean_text)
|
||||
# Parse the new text
|
||||
decoded_data = json.loads(clean_text)
|
||||
logging.debug("Successfully decoded JSON after cleanup")
|
||||
return decoded_data if isinstance(decoded_data, list) else []
|
||||
except (requests.RequestException, json.JSONDecodeError) as exc:
|
||||
logging.error(f"Failed to decode anime list from {url}: {exc}")
|
||||
raise ValueError("Could not get valid anime: ") from exc
|
||||
|
||||
def _get_language_key(self, language: str) -> int:
|
||||
"""Convert language name to language code.
|
||||
|
||||
Language Codes:
|
||||
1: German Dub
|
||||
2: English Sub
|
||||
3: German Sub
|
||||
"""
|
||||
language_code = 0
|
||||
if language == "German Dub":
|
||||
language_code = 1
|
||||
if language == "English Sub":
|
||||
language_code = 2
|
||||
if language == "German Sub":
|
||||
language_code = 3
|
||||
logging.debug(f"Converted language '{language}' to code {language_code}")
|
||||
return language_code
|
||||
|
||||
def is_language(
|
||||
self,
|
||||
season: int,
|
||||
episode: int,
|
||||
key: str,
|
||||
language: str = "German Dub"
|
||||
) -> bool:
|
||||
"""Check if episode is available in specified language."""
|
||||
logging.debug(f"Checking if S{season:02}E{episode:03} ({key}) is available in {language}")
|
||||
language_code = self._get_language_key(language)
|
||||
|
||||
episode_soup = BeautifulSoup(
|
||||
self._get_episode_html(season, episode, key).content,
|
||||
'html.parser'
|
||||
)
|
||||
change_language_box_div = episode_soup.find(
|
||||
'div', class_='changeLanguageBox')
|
||||
languages = []
|
||||
|
||||
if change_language_box_div:
|
||||
img_tags = change_language_box_div.find_all('img')
|
||||
for img in img_tags:
|
||||
lang_key = img.get('data-lang-key')
|
||||
if lang_key and lang_key.isdigit():
|
||||
languages.append(int(lang_key))
|
||||
|
||||
is_available = language_code in languages
|
||||
logging.debug(f"Available languages for S{season:02}E{episode:03}: {languages}, requested: {language_code}, available: {is_available}")
|
||||
return is_available
|
||||
|
||||
def download(
|
||||
self,
|
||||
base_directory: str,
|
||||
serie_folder: str,
|
||||
season: int,
|
||||
episode: int,
|
||||
key: str,
|
||||
language: str = "German Dub",
|
||||
progress_callback=None
|
||||
) -> bool:
|
||||
"""Download episode to specified directory.
|
||||
|
||||
Args:
|
||||
base_directory: Base download directory path
|
||||
serie_folder: Filesystem folder name (metadata only, used for
|
||||
file path construction)
|
||||
season: Season number
|
||||
episode: Episode number
|
||||
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
|
||||
"""
|
||||
logging.info(
|
||||
f"Starting download for S{season:02}E{episode:03} "
|
||||
f"({key}) in {language}"
|
||||
)
|
||||
sanitized_anime_title = ''.join(
|
||||
char for char in self.get_title(key)
|
||||
if char not in self.INVALID_PATH_CHARS
|
||||
)
|
||||
logging.debug(f"Sanitized anime title: {sanitized_anime_title}")
|
||||
|
||||
if season == 0:
|
||||
output_file = (
|
||||
f"{sanitized_anime_title} - "
|
||||
f"Movie {episode:02} - "
|
||||
f"({language}).mp4"
|
||||
)
|
||||
else:
|
||||
output_file = (
|
||||
f"{sanitized_anime_title} - "
|
||||
f"S{season:02}E{episode:03} - "
|
||||
f"({language}).mp4"
|
||||
)
|
||||
|
||||
folder_path = os.path.join(
|
||||
os.path.join(base_directory, serie_folder),
|
||||
f"Season {season}"
|
||||
)
|
||||
output_path = os.path.join(folder_path, output_file)
|
||||
logging.debug(f"Output path: {output_path}")
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
|
||||
temp_dir = "./Temp/"
|
||||
os.makedirs(os.path.dirname(temp_dir), exist_ok=True)
|
||||
temp_path = os.path.join(temp_dir, output_file)
|
||||
logging.debug(f"Temporary path: {temp_path}")
|
||||
|
||||
for provider in self.SUPPORTED_PROVIDERS:
|
||||
logging.debug(f"Attempting download with provider: {provider}")
|
||||
link, header = self._get_direct_link_from_provider(
|
||||
season, episode, key, language
|
||||
)
|
||||
logging.debug("Direct link obtained from provider")
|
||||
ydl_opts = {
|
||||
'fragment_retries': float('inf'),
|
||||
'outtmpl': temp_path,
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'progress_with_newline': False,
|
||||
'nocheckcertificate': True,
|
||||
}
|
||||
|
||||
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")
|
||||
logging.debug(f"Download link: {link[:100]}...")
|
||||
logging.debug(f"YDL options: {ydl_opts}")
|
||||
|
||||
with YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(link, download=True)
|
||||
logging.debug(
|
||||
f"Download info: "
|
||||
f"title={info.get('title')}, "
|
||||
f"filesize={info.get('filesize')}"
|
||||
)
|
||||
|
||||
if os.path.exists(temp_path):
|
||||
logging.debug("Moving file from temp to final destination")
|
||||
shutil.copy(temp_path, output_path)
|
||||
os.remove(temp_path)
|
||||
logging.info(
|
||||
f"Download completed successfully: {output_file}"
|
||||
)
|
||||
self.clear_cache()
|
||||
return True
|
||||
else:
|
||||
logging.error(
|
||||
f"Download failed: temp file not found at {temp_path}"
|
||||
)
|
||||
self.clear_cache()
|
||||
return False
|
||||
except BrokenPipeError as e:
|
||||
logging.error(
|
||||
f"Broken pipe error with provider {provider}: {e}. "
|
||||
f"This usually means the stream connection was closed."
|
||||
)
|
||||
# Try next provider if available
|
||||
continue
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"YoutubeDL download failed with provider {provider}: "
|
||||
f"{type(e).__name__}: {e}"
|
||||
)
|
||||
# Try next provider if available
|
||||
continue
|
||||
break
|
||||
|
||||
# If we get here, all providers failed
|
||||
logging.error("All download providers failed")
|
||||
self.clear_cache()
|
||||
return False
|
||||
|
||||
def get_site_key(self) -> str:
|
||||
"""Get the site key for this provider."""
|
||||
return "aniworld.to"
|
||||
|
||||
def get_title(self, key: str) -> str:
|
||||
"""Get anime title from series key."""
|
||||
logging.debug(f"Getting title for key: {key}")
|
||||
soup = BeautifulSoup(
|
||||
self._get_key_html(key).content,
|
||||
'html.parser'
|
||||
)
|
||||
title_div = soup.find('div', class_='series-title')
|
||||
|
||||
if title_div:
|
||||
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_key_html(self, key: str):
|
||||
"""Get cached HTML for series key.
|
||||
|
||||
Args:
|
||||
key: Series identifier (will be URL-encoded for safety)
|
||||
|
||||
Returns:
|
||||
Cached or fetched HTML response
|
||||
"""
|
||||
if key in self._KeyHTMLDict:
|
||||
logging.debug(f"Using cached HTML for key: {key}")
|
||||
return self._KeyHTMLDict[key]
|
||||
|
||||
# Sanitize key parameter for URL
|
||||
safe_key = quote(key, safe='')
|
||||
url = f"{self.ANIWORLD_TO}/anime/stream/{safe_key}"
|
||||
logging.debug(f"Fetching HTML for key: {key} from {url}")
|
||||
self._KeyHTMLDict[key] = self.session.get(
|
||||
url,
|
||||
timeout=self.DEFAULT_REQUEST_TIMEOUT
|
||||
)
|
||||
logging.debug(f"Cached HTML for key: {key}")
|
||||
return self._KeyHTMLDict[key]
|
||||
|
||||
def _get_episode_html(self, season: int, episode: int, key: str):
|
||||
"""Get cached HTML for episode.
|
||||
|
||||
Args:
|
||||
season: Season number (validated to be positive)
|
||||
episode: Episode number (validated to be positive)
|
||||
key: Series identifier (will be URL-encoded for safety)
|
||||
|
||||
Returns:
|
||||
Cached or fetched HTML response
|
||||
|
||||
Raises:
|
||||
ValueError: If season or episode are invalid
|
||||
"""
|
||||
# Validate season and episode numbers
|
||||
if season < 1 or season > 999:
|
||||
logging.error(f"Invalid season number: {season}")
|
||||
raise ValueError(f"Invalid season number: {season}")
|
||||
if episode < 1 or episode > 9999:
|
||||
logging.error(f"Invalid episode number: {episode}")
|
||||
raise ValueError(f"Invalid episode number: {episode}")
|
||||
|
||||
if key in self._EpisodeHTMLDict:
|
||||
logging.debug(f"Using cached HTML for S{season:02}E{episode:03} ({key})")
|
||||
return self._EpisodeHTMLDict[(key, season, episode)]
|
||||
|
||||
# Sanitize key parameter for URL
|
||||
safe_key = quote(key, safe='')
|
||||
link = (
|
||||
f"{self.ANIWORLD_TO}/anime/stream/{safe_key}/"
|
||||
f"staffel-{season}/episode-{episode}"
|
||||
)
|
||||
logging.debug(f"Fetching episode HTML from: {link}")
|
||||
html = self.session.get(link, timeout=self.DEFAULT_REQUEST_TIMEOUT)
|
||||
self._EpisodeHTMLDict[(key, season, episode)] = html
|
||||
logging.debug(f"Cached episode HTML for S{season:02}E{episode:03} ({key})")
|
||||
return self._EpisodeHTMLDict[(key, season, episode)]
|
||||
|
||||
def _get_provider_from_html(
|
||||
self,
|
||||
season: int,
|
||||
episode: int,
|
||||
key: str
|
||||
) -> dict:
|
||||
"""Parse HTML content to extract streaming providers.
|
||||
|
||||
Returns a dictionary with provider names as keys
|
||||
and language key-to-redirect URL mappings as values.
|
||||
|
||||
Example:
|
||||
{
|
||||
'VOE': {1: 'https://aniworld.to/redirect/1766412',
|
||||
2: 'https://aniworld.to/redirect/1766405'},
|
||||
}
|
||||
"""
|
||||
logging.debug(f"Extracting providers from HTML for S{season:02}E{episode:03} ({key})")
|
||||
soup = BeautifulSoup(
|
||||
self._get_episode_html(season, episode, key).content,
|
||||
'html.parser'
|
||||
)
|
||||
providers: dict[str, dict[int, str]] = {}
|
||||
|
||||
episode_links = soup.find_all(
|
||||
'li', class_=lambda x: x and x.startswith('episodeLink')
|
||||
)
|
||||
|
||||
if not episode_links:
|
||||
logging.warning(f"No episode links found for S{season:02}E{episode:03} ({key})")
|
||||
return providers
|
||||
|
||||
for link in episode_links:
|
||||
provider_name_tag = link.find('h4')
|
||||
provider_name = (
|
||||
provider_name_tag.text.strip()
|
||||
if provider_name_tag else None
|
||||
)
|
||||
|
||||
redirect_link_tag = link.find('a', class_='watchEpisode')
|
||||
redirect_link = (
|
||||
redirect_link_tag['href']
|
||||
if redirect_link_tag else None
|
||||
)
|
||||
|
||||
lang_key = link.get('data-lang-key')
|
||||
lang_key = (
|
||||
int(lang_key)
|
||||
if lang_key and lang_key.isdigit() else None
|
||||
)
|
||||
|
||||
if provider_name and redirect_link and lang_key:
|
||||
if provider_name not in providers:
|
||||
providers[provider_name] = {}
|
||||
providers[provider_name][lang_key] = (
|
||||
f"{self.ANIWORLD_TO}{redirect_link}"
|
||||
)
|
||||
logging.debug(f"Found provider: {provider_name}, lang_key: {lang_key}")
|
||||
|
||||
logging.debug(f"Total providers found: {len(providers)}")
|
||||
return providers
|
||||
|
||||
def _get_redirect_link(
|
||||
self,
|
||||
season: int,
|
||||
episode: int,
|
||||
key: str,
|
||||
language: str = "German Dub"
|
||||
):
|
||||
"""Get redirect link for episode in specified language."""
|
||||
logging.debug(f"Getting redirect link for S{season:02}E{episode:03} ({key}) in {language}")
|
||||
language_code = self._get_language_key(language)
|
||||
if self.is_language(season, episode, key, language):
|
||||
for (provider_name, lang_dict) in (
|
||||
self._get_provider_from_html(
|
||||
season, episode, key
|
||||
).items()
|
||||
):
|
||||
if language_code in lang_dict:
|
||||
logging.debug(f"Found redirect link with provider: {provider_name}")
|
||||
return (lang_dict[language_code], provider_name)
|
||||
logging.warning(f"No redirect link found for S{season:02}E{episode:03} ({key}) in {language}")
|
||||
return None
|
||||
|
||||
def _get_embeded_link(
|
||||
self,
|
||||
season: int,
|
||||
episode: int,
|
||||
key: str,
|
||||
language: str = "German Dub"
|
||||
):
|
||||
"""Get embedded link from redirect link."""
|
||||
logging.debug(f"Getting embedded link for S{season:02}E{episode:03} ({key}) in {language}")
|
||||
redirect_link, provider_name = (
|
||||
self._get_redirect_link(season, episode, key, language)
|
||||
)
|
||||
logging.debug(f"Redirect link: {redirect_link}, provider: {provider_name}")
|
||||
|
||||
embeded_link = self.session.get(
|
||||
redirect_link,
|
||||
timeout=self.DEFAULT_REQUEST_TIMEOUT,
|
||||
headers={'User-Agent': self.RANDOM_USER_AGENT}
|
||||
).url
|
||||
logging.debug(f"Embedded link: {embeded_link}")
|
||||
return embeded_link
|
||||
|
||||
def _get_direct_link_from_provider(
|
||||
self,
|
||||
season: int,
|
||||
episode: int,
|
||||
key: str,
|
||||
language: str = "German Dub"
|
||||
):
|
||||
"""Get direct download link from streaming provider."""
|
||||
logging.debug(f"Getting direct link from provider for S{season:02}E{episode:03} ({key}) in {language}")
|
||||
embeded_link = self._get_embeded_link(
|
||||
season, episode, key, language
|
||||
)
|
||||
if embeded_link is None:
|
||||
logging.error(f"No embedded link found for S{season:02}E{episode:03} ({key})")
|
||||
return None
|
||||
|
||||
logging.debug(f"Using VOE provider to extract direct link")
|
||||
return self.Providers.GetProvider(
|
||||
"VOE"
|
||||
).get_link(embeded_link, self.DEFAULT_REQUEST_TIMEOUT)
|
||||
|
||||
def get_season_episode_count(self, slug: str) -> dict:
|
||||
"""Get episode count for each season.
|
||||
|
||||
Args:
|
||||
slug: Series identifier (will be URL-encoded for safety)
|
||||
|
||||
Returns:
|
||||
Dictionary mapping season numbers to episode counts
|
||||
"""
|
||||
logging.info(f"Getting season and episode count for slug: {slug}")
|
||||
# Sanitize slug parameter for URL
|
||||
safe_slug = quote(slug, safe='')
|
||||
base_url = f"{self.ANIWORLD_TO}/anime/stream/{safe_slug}/"
|
||||
logging.debug(f"Base URL: {base_url}")
|
||||
response = requests.get(base_url, timeout=self.DEFAULT_REQUEST_TIMEOUT)
|
||||
soup = BeautifulSoup(response.content, 'html.parser')
|
||||
|
||||
season_meta = soup.find('meta', itemprop='numberOfSeasons')
|
||||
number_of_seasons = int(season_meta['content']) if season_meta else 0
|
||||
logging.info(f"Found {number_of_seasons} seasons for '{slug}'")
|
||||
|
||||
episode_counts = {}
|
||||
|
||||
for season in range(1, number_of_seasons + 1):
|
||||
season_url = f"{base_url}staffel-{season}"
|
||||
logging.debug(f"Fetching episodes for season {season} from: {season_url}")
|
||||
response = requests.get(
|
||||
season_url,
|
||||
timeout=self.DEFAULT_REQUEST_TIMEOUT,
|
||||
)
|
||||
soup = BeautifulSoup(response.content, 'html.parser')
|
||||
|
||||
episode_links = soup.find_all('a', href=True)
|
||||
unique_links = set(
|
||||
link['href']
|
||||
for link in episode_links
|
||||
if f"staffel-{season}/episode-" in link['href']
|
||||
)
|
||||
|
||||
episode_counts[season] = len(unique_links)
|
||||
logging.debug(f"Season {season} has {episode_counts[season]} episodes")
|
||||
|
||||
logging.info(f"Episode count retrieval complete for '{slug}': {episode_counts}")
|
||||
return episode_counts
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,88 +0,0 @@
|
||||
"""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
|
||||
@@ -1,59 +0,0 @@
|
||||
"""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))
|
||||
@@ -1,95 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,59 +0,0 @@
|
||||
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))
|
||||
@@ -1,40 +0,0 @@
|
||||
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))
|
||||
@@ -1,45 +0,0 @@
|
||||
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))
|
||||
@@ -1,2 +0,0 @@
|
||||
def get_direct_link_from_streamtape(embeded_streamtape_link: str) -> str:
|
||||
pass
|
||||
@@ -1,35 +0,0 @@
|
||||
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))
|
||||
@@ -1,30 +0,0 @@
|
||||
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.
@@ -36,10 +36,10 @@ class ConfigEncryption:
|
||||
def _ensure_key_exists(self) -> None:
|
||||
"""Ensure encryption key exists or create one."""
|
||||
if not self.key_file.exists():
|
||||
logger.info(f"Creating new encryption key at {self.key_file}")
|
||||
logger.info("Creating new encryption key at %s", self.key_file)
|
||||
self._generate_new_key()
|
||||
else:
|
||||
logger.info(f"Using existing encryption key from {self.key_file}")
|
||||
logger.info("Using existing encryption key from %s", self.key_file)
|
||||
|
||||
def _generate_new_key(self) -> None:
|
||||
"""Generate and store a new encryption key."""
|
||||
@@ -56,7 +56,7 @@ class ConfigEncryption:
|
||||
logger.info("Generated new encryption key")
|
||||
|
||||
except IOError as e:
|
||||
logger.error(f"Failed to generate encryption key: {e}")
|
||||
logger.error("Failed to generate encryption key: %s", e)
|
||||
raise
|
||||
|
||||
def _load_key(self) -> bytes:
|
||||
@@ -77,7 +77,7 @@ class ConfigEncryption:
|
||||
key = self.key_file.read_bytes()
|
||||
return key
|
||||
except IOError as e:
|
||||
logger.error(f"Failed to load encryption key: {e}")
|
||||
logger.error("Failed to load encryption key: %s", e)
|
||||
raise
|
||||
|
||||
def _get_cipher(self) -> Fernet:
|
||||
@@ -117,7 +117,7 @@ class ConfigEncryption:
|
||||
return encrypted_str
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to encrypt value: {e}")
|
||||
logger.error("Failed to encrypt value: %s", e)
|
||||
raise
|
||||
|
||||
def decrypt_value(self, encrypted_value: str) -> str:
|
||||
@@ -149,7 +149,7 @@ class ConfigEncryption:
|
||||
return decrypted_str
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to decrypt value: {e}")
|
||||
logger.error("Failed to decrypt value: %s", e)
|
||||
raise
|
||||
|
||||
def encrypt_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
@@ -191,9 +191,9 @@ class ConfigEncryption:
|
||||
'encrypted': True,
|
||||
'value': self.encrypt_value(value)
|
||||
}
|
||||
logger.debug(f"Encrypted config field: {key}")
|
||||
logger.debug("Encrypted config field: %s", key)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to encrypt {key}: {e}")
|
||||
logger.warning("Failed to encrypt %s: %s", key, e)
|
||||
encrypted_config[key] = value
|
||||
else:
|
||||
encrypted_config[key] = value
|
||||
@@ -222,9 +222,9 @@ class ConfigEncryption:
|
||||
decrypted_config[key] = self.decrypt_value(
|
||||
value['value']
|
||||
)
|
||||
logger.debug(f"Decrypted config field: {key}")
|
||||
logger.debug("Decrypted config field: %s", key)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to decrypt {key}: {e}")
|
||||
logger.error("Failed to decrypt %s: %s", key, e)
|
||||
decrypted_config[key] = None
|
||||
else:
|
||||
decrypted_config[key] = value
|
||||
@@ -248,7 +248,7 @@ class ConfigEncryption:
|
||||
if self.key_file.exists():
|
||||
backup_path = self.key_file.with_suffix('.key.bak')
|
||||
self.key_file.rename(backup_path)
|
||||
logger.info(f"Backed up old key to {backup_path}")
|
||||
logger.info("Backed up old key to %s", backup_path)
|
||||
|
||||
# Generate new key
|
||||
if new_key_file:
|
||||
|
||||
@@ -276,13 +276,13 @@ class DatabaseIntegrityChecker:
|
||||
removed += 1
|
||||
|
||||
self.session.commit()
|
||||
logger.info(f"Removed {removed} orphaned records")
|
||||
logger.info("Removed %s orphaned records", removed)
|
||||
|
||||
return removed
|
||||
|
||||
except Exception as e:
|
||||
self.session.rollback()
|
||||
logger.error(f"Error removing orphaned records: {e}")
|
||||
logger.error("Error removing orphaned records: %s", e)
|
||||
raise
|
||||
|
||||
|
||||
|
||||
@@ -39,13 +39,15 @@ class FileIntegrityManager:
|
||||
self.checksums = json.load(f)
|
||||
count = len(self.checksums)
|
||||
logger.info(
|
||||
f"Loaded {count} checksums from {self.checksum_file}"
|
||||
"Loaded %d checksums from %s",
|
||||
count,
|
||||
self.checksum_file,
|
||||
)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logger.error(f"Failed to load checksums: {e}")
|
||||
logger.error("Failed to load checksums: %s", e)
|
||||
self.checksums = {}
|
||||
else:
|
||||
logger.info(f"Checksum file does not exist: {self.checksum_file}")
|
||||
logger.info("Checksum file does not exist: %s", self.checksum_file)
|
||||
self.checksums = {}
|
||||
|
||||
def _save_checksums(self) -> None:
|
||||
@@ -56,10 +58,12 @@ class FileIntegrityManager:
|
||||
json.dump(self.checksums, f, indent=2)
|
||||
count = len(self.checksums)
|
||||
logger.debug(
|
||||
f"Saved {count} checksums to {self.checksum_file}"
|
||||
"Saved %d checksums to %s",
|
||||
count,
|
||||
self.checksum_file,
|
||||
)
|
||||
except IOError as e:
|
||||
logger.error(f"Failed to save checksums: {e}")
|
||||
logger.error("Failed to save checksums: %s", e)
|
||||
|
||||
def calculate_checksum(
|
||||
self, file_path: Path, algorithm: str = "sha256"
|
||||
@@ -94,12 +98,15 @@ class FileIntegrityManager:
|
||||
checksum = hash_obj.hexdigest()
|
||||
filename = file_path.name
|
||||
logger.debug(
|
||||
f"Calculated {algorithm} checksum for {filename}: {checksum}"
|
||||
"Calculated %s checksum for %s: %s",
|
||||
algorithm,
|
||||
filename,
|
||||
checksum,
|
||||
)
|
||||
return checksum
|
||||
|
||||
except IOError as e:
|
||||
logger.error(f"Failed to read file {file_path}: {e}")
|
||||
logger.error("Failed to read file %s: %s", file_path, e)
|
||||
raise
|
||||
|
||||
def store_checksum(
|
||||
@@ -126,7 +133,7 @@ class FileIntegrityManager:
|
||||
self.checksums[key] = checksum
|
||||
self._save_checksums()
|
||||
|
||||
logger.info(f"Stored checksum for {file_path.name}")
|
||||
logger.info("Stored checksum for %s", file_path.name)
|
||||
return checksum
|
||||
|
||||
def verify_checksum(
|
||||
@@ -197,10 +204,10 @@ class FileIntegrityManager:
|
||||
if key in self.checksums:
|
||||
del self.checksums[key]
|
||||
self._save_checksums()
|
||||
logger.info(f"Removed checksum for {file_path.name}")
|
||||
logger.info("Removed checksum for %s", file_path.name)
|
||||
return True
|
||||
else:
|
||||
logger.debug(f"No checksum found to remove for {file_path.name}")
|
||||
logger.debug("No checksum found to remove for %s", file_path.name)
|
||||
return False
|
||||
|
||||
def has_checksum(self, file_path: Path) -> bool:
|
||||
|
||||
870
src/server/SerieScanner.py
Normal file
870
src/server/SerieScanner.py
Normal file
@@ -0,0 +1,870 @@
|
||||
"""
|
||||
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).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import traceback
|
||||
import uuid
|
||||
from typing import Callable, Iterable, Iterator, Optional
|
||||
|
||||
from events import Events
|
||||
|
||||
from src.config.settings import settings
|
||||
from src.server.database.models import AnimeSeries
|
||||
from src.server.exceptions.exceptions.Exceptions import MatchNotFoundError
|
||||
from src.server.providers.base_provider import Loader
|
||||
from src.server.database.connection import get_sync_session
|
||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
error_logger = logging.getLogger("error")
|
||||
no_key_found_logger = logging.getLogger("series.nokey")
|
||||
|
||||
|
||||
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:
|
||||
# Synchronous context (CLI):
|
||||
scanner = SerieScanner("/path/to/anime", loader)
|
||||
scanner.scan() # asyncio.run() used internally when no event loop
|
||||
|
||||
# Asynchronous context (server/scheduler):
|
||||
# scan() detects running event loop and uses create_task()
|
||||
# internally, so no special handling needed by caller.
|
||||
# Results are in scanner.keyDict
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
basePath: str,
|
||||
loader: Loader,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the SerieScanner.
|
||||
|
||||
Args:
|
||||
basePath: Base directory containing anime series
|
||||
loader: Loader instance for fetching series information
|
||||
|
||||
Raises:
|
||||
ValueError: If basePath is invalid or doesn't exist
|
||||
"""
|
||||
# Validate basePath to prevent directory traversal attacks
|
||||
if not basePath or not basePath.strip():
|
||||
raise ValueError("Base path cannot be empty")
|
||||
|
||||
# Resolve to absolute path and validate it exists
|
||||
abs_path = os.path.abspath(basePath)
|
||||
if not os.path.exists(abs_path):
|
||||
raise ValueError(f"Base path does not exist: {abs_path}")
|
||||
if not os.path.isdir(abs_path):
|
||||
raise ValueError(f"Base path is not a directory: {abs_path}")
|
||||
|
||||
self.directory: str = abs_path
|
||||
self.keyDict: dict[str, AnimeSeries] = {}
|
||||
self.loader: Loader = loader
|
||||
self._current_operation_id: Optional[str] = None
|
||||
self.events = Events()
|
||||
|
||||
self.events.on_progress = []
|
||||
self.events.on_error = []
|
||||
self.events.on_warning = []
|
||||
self.events.on_completion = []
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
event_handler: Event handler attribute (e.g., self.events.on_progress)
|
||||
data: Data dictionary to pass to the event handler
|
||||
"""
|
||||
if event_handler:
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
)
|
||||
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_warning(self, handler):
|
||||
"""
|
||||
Subscribe a handler to an event.
|
||||
Args:
|
||||
handler: Callable to handle the event
|
||||
"""
|
||||
if handler not in self.events.on_warning:
|
||||
self.events.on_warning.append(handler)
|
||||
|
||||
def unsubscribe_on_warning(self, handler):
|
||||
"""
|
||||
Unsubscribe a handler from an event.
|
||||
Args:
|
||||
handler: Callable to remove
|
||||
"""
|
||||
if handler in self.events.on_warning:
|
||||
self.events.on_warning.remove(handler)
|
||||
|
||||
def subscribe_on_completion(self, handler):
|
||||
"""
|
||||
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)
|
||||
|
||||
def reinit(self) -> None:
|
||||
"""Reinitialize the series dictionary (keyed by anime.key)."""
|
||||
self.keyDict: dict[str, AnimeSeries] = {}
|
||||
|
||||
async def _persist_serie_to_db(self, anime: AnimeSeries) -> None:
|
||||
"""Persist anime to database (create or update).
|
||||
|
||||
Args:
|
||||
anime: AnimeSeries model to persist
|
||||
"""
|
||||
try:
|
||||
from src.server.database.connection import get_async_session_factory
|
||||
|
||||
db = get_async_session_factory()
|
||||
try:
|
||||
existing = await AnimeSeriesService.get_by_key(db, anime.key)
|
||||
if existing:
|
||||
await AnimeSeriesService.update(
|
||||
db, existing.id,
|
||||
name=anime.name,
|
||||
folder=anime.folder,
|
||||
year=anime.year
|
||||
)
|
||||
await self._sync_episodes_to_db(db, existing.id, anime.episodeDict)
|
||||
else:
|
||||
db_anime = await AnimeSeriesService.create(
|
||||
db=db,
|
||||
key=anime.key,
|
||||
name=anime.name,
|
||||
site=anime.site,
|
||||
folder=anime.folder,
|
||||
year=anime.year
|
||||
)
|
||||
for ep in anime.episodes:
|
||||
await EpisodeService.create(
|
||||
db=db,
|
||||
series_id=db_anime.id,
|
||||
season=ep.season,
|
||||
episode_number=ep.episode_number
|
||||
)
|
||||
await db.commit()
|
||||
logger.debug(
|
||||
"Persisted anime '%s' (key=%s) to database",
|
||||
anime.name, anime.key
|
||||
)
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
"Failed to persist anime '%s' to DB: %s",
|
||||
anime.key, e, exc_info=True
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
await db.close()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Could not persist anime '%s' to DB (DB unavailable?): %s",
|
||||
anime.key, e
|
||||
)
|
||||
|
||||
async def _sync_episodes_to_db(
|
||||
self, db, series_id: int, episode_dict: dict[int, list[int]]
|
||||
) -> None:
|
||||
"""Sync episodes to database, preserving downloaded flags.
|
||||
|
||||
Adds missing episodes, removes episodes no longer missing,
|
||||
and preserves is_downloaded=True episodes.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
series_id: Database ID of the series
|
||||
episode_dict: Dict mapping season -> list of episode numbers
|
||||
"""
|
||||
existing_episodes = await EpisodeService.get_by_series(db, series_id)
|
||||
existing_map = {
|
||||
(ep.season, ep.episode_number): ep for ep in existing_episodes
|
||||
}
|
||||
new_keys = set()
|
||||
for season, eps in episode_dict.items():
|
||||
for ep_num in eps:
|
||||
new_keys.add((season, ep_num))
|
||||
for (season, ep_num), ep in existing_map.items():
|
||||
if (season, ep_num) not in new_keys:
|
||||
if ep.is_downloaded:
|
||||
logger.debug(
|
||||
"Preserving downloaded episode S%02dE%02d for series_id=%d",
|
||||
season, ep_num, series_id
|
||||
)
|
||||
else:
|
||||
await EpisodeService.delete_by_series(
|
||||
db, series_id, season, ep_num
|
||||
)
|
||||
for season, eps in episode_dict.items():
|
||||
for ep_num in eps:
|
||||
if (season, ep_num) not in existing_map:
|
||||
await EpisodeService.create(
|
||||
db=db,
|
||||
series_id=series_id,
|
||||
season=season,
|
||||
episode_number=ep_num
|
||||
)
|
||||
|
||||
def get_total_to_scan(self) -> int:
|
||||
"""Get the total number of folders to scan.
|
||||
|
||||
Returns:
|
||||
Total count of folders with MP4 files
|
||||
"""
|
||||
result = self.__find_mp4_files()
|
||||
return sum(1 for _ in result)
|
||||
|
||||
def scan(self) -> 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.
|
||||
|
||||
Raises:
|
||||
Exception: If scan fails critically
|
||||
"""
|
||||
# Generate unique operation ID
|
||||
self._current_operation_id = str(uuid.uuid4())
|
||||
|
||||
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"
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
# Get total items to process
|
||||
total_to_scan = self.get_total_to_scan()
|
||||
logger.info("Total folders to scan: %d", total_to_scan)
|
||||
|
||||
# The scanner enumerates folders with mp4 files, loads existing
|
||||
# metadata, calculates the missing episodes via the provider, and
|
||||
# persists the refreshed metadata while emitting progress events.
|
||||
result = self.__find_mp4_files()
|
||||
counter = 0
|
||||
|
||||
for folder, mp4_files in result:
|
||||
try:
|
||||
counter += 1
|
||||
|
||||
# Calculate progress
|
||||
if total_to_scan > 0:
|
||||
percentage = (counter / total_to_scan) * 100
|
||||
else:
|
||||
percentage = 0.0
|
||||
|
||||
# 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"
|
||||
}
|
||||
)
|
||||
|
||||
serie = self.__read_data_from_file(folder)
|
||||
if serie is None or not serie.key or not serie.key.strip():
|
||||
logger.warning(
|
||||
"No series found in DB for folder '%s', skipping",
|
||||
folder,
|
||||
)
|
||||
continue
|
||||
if (
|
||||
serie is not None
|
||||
and serie.key
|
||||
and serie.key.strip()
|
||||
):
|
||||
# Delegate the provider to compare local files with
|
||||
# remote metadata, yielding missing episodes per
|
||||
# season. Results are saved back to disk so that both
|
||||
# CLI and API consumers see consistent state.
|
||||
missing_episodes, _site = (
|
||||
self.__get_missing_episodes_and_season(
|
||||
serie.key, mp4_files
|
||||
)
|
||||
)
|
||||
serie.episodeDict = missing_episodes
|
||||
serie.folder = folder
|
||||
|
||||
# Persist to database (async)
|
||||
try:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No running loop — safe to use asyncio.run()
|
||||
asyncio.run(self._persist_serie_to_db(serie))
|
||||
else:
|
||||
# Already in async context — schedule as task
|
||||
asyncio.create_task(self._persist_serie_to_db(serie))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"DB persistence failed for '%s', "
|
||||
"continuing without DB: %s",
|
||||
serie.key, e
|
||||
)
|
||||
|
||||
# Store by key (primary identifier), not folder
|
||||
if serie.key in self.keyDict:
|
||||
existing = self.keyDict[serie.key]
|
||||
logger.warning(
|
||||
"Duplicate series found with key '%s': "
|
||||
"folder '%s' maps to same key as existing folder '%s'. "
|
||||
"Skipping duplicate folder.",
|
||||
serie.key,
|
||||
folder,
|
||||
existing.folder
|
||||
)
|
||||
self._safe_call_event(
|
||||
self.events.on_warning,
|
||||
{
|
||||
"operation_id": self._current_operation_id,
|
||||
"warning": "duplicate_key",
|
||||
"message": f"Duplicate series skipped: '{folder}' maps to key '{serie.key}' already used by '{existing.folder}'",
|
||||
"metadata": {
|
||||
"key": serie.key,
|
||||
"duplicate_folder": folder,
|
||||
"existing_folder": existing.folder,
|
||||
}
|
||||
}
|
||||
)
|
||||
else:
|
||||
self.keyDict[serie.key] = serie
|
||||
logger.debug(
|
||||
"Stored series with key '%s' (folder: '%s')",
|
||||
serie.key,
|
||||
folder
|
||||
)
|
||||
no_key_found_logger.info(
|
||||
"Saved Serie: '%s'", str(serie)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Log error and notify via callback
|
||||
error_msg = (
|
||||
f"Folder: '{folder}' - "
|
||||
f"Unexpected error: {e}"
|
||||
)
|
||||
error_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": 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": {
|
||||
"total_folders": counter,
|
||||
"series_found": len(self.keyDict)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Scan completed. Processed %d folders, found %d series",
|
||||
counter,
|
||||
len(self.keyDict)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Critical error - notify and re-raise
|
||||
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._safe_call_event(
|
||||
self.events.on_completion,
|
||||
{
|
||||
"operation_id": self._current_operation_id,
|
||||
"success": False,
|
||||
"message": error_msg
|
||||
}
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
def __find_mp4_files(self) -> Iterator[tuple[str, list[str]]]:
|
||||
"""Find all .mp4 files in the directory structure."""
|
||||
logger.info("Scanning for .mp4 files")
|
||||
for anime_name in os.listdir(self.directory):
|
||||
anime_path = os.path.join(self.directory, anime_name)
|
||||
if os.path.isdir(anime_path):
|
||||
if settings.should_ignore_folder(anime_name):
|
||||
logger.debug("Skipping ignored folder: %s", anime_name)
|
||||
continue
|
||||
mp4_files: list[str] = []
|
||||
has_files = False
|
||||
for root, _, files in os.walk(anime_path):
|
||||
for file in files:
|
||||
if file.endswith(".mp4"):
|
||||
mp4_files.append(os.path.join(root, file))
|
||||
has_files = True
|
||||
yield anime_name, mp4_files if has_files else []
|
||||
|
||||
def __read_data_from_file(self, folder_name: str) -> Optional[AnimeSeries]:
|
||||
"""Load or discover an AnimeSeries for the given folder.
|
||||
|
||||
Strategy:
|
||||
1. Query DB by folder name
|
||||
2. If not found in DB, return None (no file fallback)
|
||||
|
||||
Args:
|
||||
folder_name: Filesystem folder name
|
||||
|
||||
Returns:
|
||||
AnimeSeries object if found in DB, None otherwise
|
||||
"""
|
||||
# Step 1: Try DB lookup by folder name
|
||||
try:
|
||||
session = get_sync_session()
|
||||
try:
|
||||
anime_series = AnimeSeriesService.get_by_folder_sync(session, folder_name)
|
||||
return anime_series
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"DB lookup failed for folder '%s': %s",
|
||||
folder_name,
|
||||
exc
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def __get_episode_and_season(self, filename: str) -> tuple[int, int]:
|
||||
"""Extract season and episode numbers from filename.
|
||||
|
||||
Args:
|
||||
filename: Filename to parse
|
||||
|
||||
Returns:
|
||||
Tuple of (season, episode) as integers
|
||||
|
||||
Raises:
|
||||
MatchNotFoundError: If pattern not found
|
||||
"""
|
||||
pattern = r'S(\d+)E(\d+)'
|
||||
match = re.search(pattern, filename)
|
||||
if match:
|
||||
season = match.group(1)
|
||||
episode = match.group(2)
|
||||
logger.debug(
|
||||
"Extracted season %s, episode %s from '%s'",
|
||||
season,
|
||||
episode,
|
||||
filename
|
||||
)
|
||||
return int(season), int(episode)
|
||||
else:
|
||||
logger.error(
|
||||
"Failed to find season/episode pattern in '%s'",
|
||||
filename
|
||||
)
|
||||
raise MatchNotFoundError(
|
||||
"Season and episode pattern not found in the filename."
|
||||
)
|
||||
|
||||
def __get_episodes_and_seasons(
|
||||
self,
|
||||
mp4_files: Iterable[str]
|
||||
) -> dict[int, list[int]]:
|
||||
"""Get episodes grouped by season from mp4 files.
|
||||
|
||||
Args:
|
||||
mp4_files: List of MP4 filenames
|
||||
|
||||
Returns:
|
||||
Dictionary mapping season to list of episode numbers
|
||||
"""
|
||||
episodes_dict: dict[int, list[int]] = {}
|
||||
|
||||
for file in mp4_files:
|
||||
season, episode = self.__get_episode_and_season(file)
|
||||
|
||||
if season in episodes_dict:
|
||||
episodes_dict[season].append(episode)
|
||||
else:
|
||||
episodes_dict[season] = [episode]
|
||||
return episodes_dict
|
||||
|
||||
def __get_missing_episodes_and_season(
|
||||
self,
|
||||
key: str,
|
||||
mp4_files: Iterable[str]
|
||||
) -> tuple[dict[int, list[int]], str]:
|
||||
"""Get missing episodes for a serie.
|
||||
|
||||
Args:
|
||||
key: Series key
|
||||
mp4_files: List of MP4 filenames
|
||||
|
||||
Returns:
|
||||
Tuple of (episodes_dict, site_name)
|
||||
"""
|
||||
# key season , value count of episodes
|
||||
expected_dict = self.loader.get_season_episode_count(key)
|
||||
filedict = self.__get_episodes_and_seasons(mp4_files)
|
||||
episodes_dict: dict[int, list[int]] = {}
|
||||
for season, expected_count in expected_dict.items():
|
||||
existing_episodes = filedict.get(season, [])
|
||||
missing_episodes = [
|
||||
ep for ep in range(1, expected_count + 1)
|
||||
if ep not in existing_episodes
|
||||
and self.loader.is_language(season, ep, key)
|
||||
]
|
||||
|
||||
if missing_episodes:
|
||||
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 AnimeSeries in keyDict
|
||||
if key in self.keyDict:
|
||||
# Update existing anime - rebuild episodeDict from episodes
|
||||
existing = self.keyDict[key]
|
||||
existing_ep_dict = existing.episodeDict
|
||||
# Merge missing episodes
|
||||
for season, eps in missing_episodes.items():
|
||||
if season not in existing_ep_dict:
|
||||
existing_ep_dict[season] = []
|
||||
existing_ep_dict[season].extend(eps)
|
||||
logger.debug(
|
||||
"Updated existing series %s with %d missing episodes",
|
||||
key,
|
||||
sum(len(eps) for eps in missing_episodes.values())
|
||||
)
|
||||
else:
|
||||
# Extract year from folder name if present, otherwise leave as None
|
||||
year = self._extract_year_from_folder_name(folder)
|
||||
|
||||
# Create new AnimeSeries entry (minimal, fields populated later)
|
||||
from src.server.database.models import AnimeSeries
|
||||
anime_series = AnimeSeries(
|
||||
key=key,
|
||||
name=folder, # Use folder as fallback name since we don't have actual name
|
||||
site=site,
|
||||
folder=folder,
|
||||
year=year
|
||||
)
|
||||
# Set episodeDict cache directly since AnimeSeries doesn't persist missing episodes
|
||||
# (they get synced to DB via _persist_serie_to_db later)
|
||||
anime_series._episode_dict_cache = missing_episodes.copy()
|
||||
self.keyDict[key] = anime_series
|
||||
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 {}
|
||||
|
||||
@@ -4,24 +4,25 @@ 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 warnings
|
||||
from typing import Any, Dict, List, Optional
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from events import Events
|
||||
|
||||
try:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
except ImportError: # pragma: no cover - optional dependency
|
||||
AsyncSession = object # type: ignore
|
||||
|
||||
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.config.settings import settings
|
||||
from src.server.database.SerieList import SerieList
|
||||
from src.server.database.models import AnimeSeries
|
||||
from src.server.providers.provider_factory import Loaders
|
||||
from src.server.SerieScanner import SerieScanner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -125,6 +126,10 @@ class SeriesApp:
|
||||
- Managing series lists
|
||||
|
||||
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.
|
||||
@@ -136,42 +141,40 @@ class SeriesApp:
|
||||
def __init__(
|
||||
self,
|
||||
directory_to_search: str,
|
||||
db_session: Optional[AsyncSession] = None,
|
||||
):
|
||||
"""
|
||||
Initialize SeriesApp.
|
||||
|
||||
Args:
|
||||
directory_to_search: Base directory for anime series
|
||||
db_session: Optional database session for database-backed
|
||||
storage. When provided, SerieList and SerieScanner will
|
||||
use the database instead of file-based storage.
|
||||
"""
|
||||
|
||||
self.directory_to_search = directory_to_search
|
||||
self._db_session = db_session
|
||||
|
||||
# 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, db_session=db_session
|
||||
)
|
||||
self.list = SerieList(
|
||||
self.directory_to_search, db_session=db_session
|
||||
)
|
||||
# Synchronous init used during constructor to avoid awaiting
|
||||
# in __init__
|
||||
self._init_list_sync()
|
||||
|
||||
logger.info(
|
||||
"SeriesApp initialized for directory: %s (db_session: %s)",
|
||||
directory_to_search,
|
||||
"provided" if db_session else "none"
|
||||
self.loader,
|
||||
)
|
||||
# Series will be loaded from database by the service layer during application setup
|
||||
self.list = SerieList(self.directory_to_search)
|
||||
self.series_list: List[Any] = []
|
||||
# Initialize empty list - series loaded later via load_series_from_list()
|
||||
# No need to call _init_list_sync() anymore
|
||||
|
||||
# NFO service removed - metadata handling moved to server layer
|
||||
self.nfo_service = None
|
||||
|
||||
logger.info(
|
||||
"SeriesApp initialized for directory: %s",
|
||||
directory_to_search,
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -203,69 +206,24 @@ class SeriesApp:
|
||||
def scan_status(self, value):
|
||||
"""Set scan_status event handler."""
|
||||
self._events.scan_status = value
|
||||
|
||||
@property
|
||||
def db_session(self) -> Optional[AsyncSession]:
|
||||
|
||||
def load_series_from_list(self, series: list) -> None:
|
||||
"""
|
||||
Get the database session.
|
||||
Load series into the in-memory list.
|
||||
|
||||
Returns:
|
||||
AsyncSession or None: The database session if configured
|
||||
"""
|
||||
return self._db_session
|
||||
|
||||
def set_db_session(self, session: Optional[AsyncSession]) -> None:
|
||||
"""
|
||||
Update the database session.
|
||||
|
||||
Also updates the db_session on SerieList and SerieScanner.
|
||||
This method is called by the service layer after loading
|
||||
series from the database.
|
||||
|
||||
Args:
|
||||
session: The new database session or None
|
||||
series: List of Serie objects to load
|
||||
"""
|
||||
self._db_session = session
|
||||
self.list._db_session = session
|
||||
self.serie_scanner._db_session = session
|
||||
logger.debug(
|
||||
"Database session updated: %s",
|
||||
"provided" if session else "none"
|
||||
)
|
||||
|
||||
async def init_from_db_async(self) -> None:
|
||||
"""
|
||||
Initialize series list from database (async).
|
||||
|
||||
This should be called when using database storage instead of
|
||||
the synchronous file-based initialization.
|
||||
"""
|
||||
if self._db_session:
|
||||
await self.list.load_series_from_db(self._db_session)
|
||||
self.series_list = self.list.GetMissingEpisode()
|
||||
logger.debug(
|
||||
"Loaded %d series with missing episodes from database",
|
||||
len(self.series_list)
|
||||
)
|
||||
else:
|
||||
warnings.warn(
|
||||
"init_from_db_async called without db_session configured",
|
||||
UserWarning
|
||||
)
|
||||
|
||||
def _init_list_sync(self) -> None:
|
||||
"""Synchronous initialization helper for constructor."""
|
||||
self.list.keyDict.clear()
|
||||
for serie in series:
|
||||
self.list.keyDict[serie.key] = serie
|
||||
self.series_list = self.list.GetMissingEpisode()
|
||||
logger.debug(
|
||||
"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",
|
||||
"Loaded %d series with %d having missing episodes",
|
||||
len(series),
|
||||
len(self.series_list)
|
||||
)
|
||||
|
||||
@@ -283,7 +241,12 @@ class SeriesApp:
|
||||
RuntimeError: If search fails
|
||||
"""
|
||||
logger.info("Searching for: %s", words)
|
||||
results = await asyncio.to_thread(self.loader.search, words)
|
||||
loop = asyncio.get_running_loop()
|
||||
results = await loop.run_in_executor(
|
||||
self.executor,
|
||||
self.loader.search,
|
||||
words
|
||||
)
|
||||
logger.info("Found %d results", len(results))
|
||||
return results
|
||||
|
||||
@@ -318,6 +281,7 @@ 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,
|
||||
@@ -339,11 +303,45 @@ class SeriesApp:
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
def download_callback(progress_info):
|
||||
logger.debug(
|
||||
"wrapped_callback called with: %s", progress_info
|
||||
# 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
|
||||
|
||||
try:
|
||||
def download_progress_handler(progress_info):
|
||||
"""Handle download progress events from loader."""
|
||||
# Throttle progress logging to avoid spam
|
||||
status = progress_info.get("status", "")
|
||||
if status in ("downloading", "finished"):
|
||||
logger.debug(
|
||||
"download_progress_handler called with: %s", progress_info
|
||||
)
|
||||
|
||||
downloaded = progress_info.get('downloaded_bytes', 0)
|
||||
total_bytes = (
|
||||
@@ -372,17 +370,28 @@ class SeriesApp:
|
||||
item_id=item_id,
|
||||
)
|
||||
)
|
||||
# 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
|
||||
)
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
if download_success:
|
||||
logger.info(
|
||||
@@ -430,7 +439,30 @@ class SeriesApp:
|
||||
|
||||
return download_success
|
||||
|
||||
except Exception as e:
|
||||
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
|
||||
logger.error(
|
||||
"Download error: %s (key: %s) S%02dE%02d - %s",
|
||||
serie_folder,
|
||||
@@ -457,23 +489,40 @@ class SeriesApp:
|
||||
|
||||
return False
|
||||
|
||||
async def rescan(self) -> int:
|
||||
async def rescan(self) -> list:
|
||||
"""
|
||||
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:
|
||||
Number of series with missing episodes after rescan.
|
||||
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).
|
||||
"""
|
||||
logger.info("Starting directory rescan")
|
||||
|
||||
total_to_scan = 0
|
||||
|
||||
try:
|
||||
# Get total items to scan
|
||||
total_to_scan = await asyncio.to_thread(
|
||||
logger.info("Getting total items to scan...")
|
||||
loop = asyncio.get_running_loop()
|
||||
total_to_scan = await loop.run_in_executor(
|
||||
self.executor,
|
||||
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,
|
||||
@@ -486,37 +535,60 @@ class SeriesApp:
|
||||
)
|
||||
|
||||
# Reinitialize scanner
|
||||
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
|
||||
await loop.run_in_executor(
|
||||
self.executor,
|
||||
self.serie_scanner.reinit
|
||||
)
|
||||
|
||||
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=current,
|
||||
total=total_to_scan,
|
||||
current=progress_data.get('current', 0),
|
||||
total=progress_data.get('total', total_to_scan),
|
||||
folder=folder,
|
||||
status="progress",
|
||||
progress=progress,
|
||||
message=f"Scanning: {folder}",
|
||||
progress=(
|
||||
progress_data.get('percentage', 0.0) / 100.0
|
||||
),
|
||||
message=message,
|
||||
)
|
||||
)
|
||||
|
||||
# Perform scan
|
||||
await asyncio.to_thread(self.serie_scanner.scan, scan_callback)
|
||||
# Subscribe to scanner's progress events
|
||||
self.serie_scanner.subscribe_on_progress(scan_progress_handler)
|
||||
|
||||
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())
|
||||
|
||||
# Reinitialize list
|
||||
self.list = SerieList(self.directory_to_search)
|
||||
await self._init_list()
|
||||
# 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()
|
||||
|
||||
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,
|
||||
@@ -531,7 +603,7 @@ class SeriesApp:
|
||||
)
|
||||
)
|
||||
|
||||
return len(self.series_list)
|
||||
return scanned_series
|
||||
|
||||
except InterruptedError:
|
||||
logger.warning("Scan cancelled by user")
|
||||
@@ -540,7 +612,7 @@ class SeriesApp:
|
||||
self._events.scan_status(
|
||||
ScanStatusEventArgs(
|
||||
current=0,
|
||||
total=total_to_scan if 'total_to_scan' in locals() else 0,
|
||||
total=total_to_scan,
|
||||
folder="",
|
||||
status="cancelled",
|
||||
message="Scan cancelled by user",
|
||||
@@ -555,7 +627,7 @@ class SeriesApp:
|
||||
self._events.scan_status(
|
||||
ScanStatusEventArgs(
|
||||
current=0,
|
||||
total=total_to_scan if 'total_to_scan' in locals() else 0,
|
||||
total=total_to_scan,
|
||||
folder="",
|
||||
status="failed",
|
||||
error=e,
|
||||
@@ -581,7 +653,7 @@ class SeriesApp:
|
||||
"""
|
||||
await self._init_list()
|
||||
|
||||
def _get_serie_by_key(self, key: str) -> Optional[Serie]:
|
||||
def _get_serie_by_key(self, key: str) -> Optional[AnimeSeries]:
|
||||
"""
|
||||
Get a series by its unique provider key.
|
||||
|
||||
@@ -592,10 +664,154 @@ class SeriesApp:
|
||||
"attack-on-titan")
|
||||
|
||||
Returns:
|
||||
The Serie instance if found, None otherwise
|
||||
The AnimeSeries instance if found, None otherwise
|
||||
|
||||
Note:
|
||||
This method uses the SerieList.get_by_key() method which
|
||||
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[AnimeSeries]:
|
||||
"""
|
||||
Get all series from data files in the anime directory.
|
||||
|
||||
Scans the directory_to_search for all 'data' files and loads
|
||||
the AnimeSeries metadata from each file. This method is synchronous
|
||||
and can be wrapped with asyncio.to_thread if needed for async
|
||||
contexts.
|
||||
|
||||
Returns:
|
||||
List of AnimeSeries 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 anime in all_series:
|
||||
print(f"Found: {anime.name} (key={anime.key})")
|
||||
"""
|
||||
logger.info(
|
||||
"Scanning for data files in directory: %s",
|
||||
self.directory_to_search
|
||||
)
|
||||
|
||||
all_series: List[AnimeSeries] = []
|
||||
|
||||
try:
|
||||
if not os.path.isdir(self.directory_to_search):
|
||||
logger.warning(
|
||||
"Directory does not exist: %s",
|
||||
self.directory_to_search
|
||||
)
|
||||
return []
|
||||
except (OSError, ValueError) as e:
|
||||
logger.error(
|
||||
"Failed to scan directory for data files: %s",
|
||||
str(e),
|
||||
exc_info=True
|
||||
)
|
||||
return []
|
||||
|
||||
try:
|
||||
for folder_name in os.listdir(self.directory_to_search):
|
||||
folder_path = os.path.join(
|
||||
self.directory_to_search, folder_name
|
||||
)
|
||||
if not os.path.isdir(folder_path):
|
||||
continue
|
||||
|
||||
data_file = os.path.join(folder_path, "data")
|
||||
if not os.path.isfile(data_file):
|
||||
continue
|
||||
|
||||
series_data = _load_data_file(data_file)
|
||||
if series_data is None:
|
||||
continue
|
||||
|
||||
key = series_data.get("key")
|
||||
if not key:
|
||||
logger.warning(
|
||||
"Data file missing key, skipping: %s",
|
||||
data_file
|
||||
)
|
||||
continue
|
||||
|
||||
anime = AnimeSeries(
|
||||
key=key,
|
||||
name=series_data.get("name") or folder_name,
|
||||
site=series_data.get("site", "https://aniworld.to"),
|
||||
folder=series_data.get("folder", folder_name),
|
||||
year=series_data.get("year"),
|
||||
)
|
||||
|
||||
episode_dict = series_data.get("episodeDict", {})
|
||||
if episode_dict:
|
||||
anime._episode_dict_cache = {
|
||||
int(season): episodes
|
||||
for season, episodes in episode_dict.items()
|
||||
}
|
||||
|
||||
all_series.append(anime)
|
||||
except (OSError, ValueError) as e:
|
||||
logger.error(
|
||||
"Failed to scan directory for data files: %s",
|
||||
str(e),
|
||||
exc_info=True
|
||||
)
|
||||
return []
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def _load_data_file(data_file_path: str) -> Optional[dict]:
|
||||
"""Load and parse a legacy 'data' file (JSON).
|
||||
|
||||
Args:
|
||||
data_file_path: Path to the data file
|
||||
|
||||
Returns:
|
||||
Parsed data dict or None if parsing fails
|
||||
"""
|
||||
import json
|
||||
|
||||
try:
|
||||
with open(data_file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("Data file is not a dictionary: %s", data_file_path)
|
||||
return None
|
||||
|
||||
return data
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(
|
||||
"Failed to parse legacy data file (JSON error): %s - %s",
|
||||
data_file_path, str(e)
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to read legacy data file: %s - %s",
|
||||
data_file_path, str(e)
|
||||
)
|
||||
return None
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
import warnings
|
||||
from typing import Any, List, Optional
|
||||
|
||||
@@ -6,21 +7,60 @@ 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.config.settings import settings
|
||||
from src.server.database.models import AnimeSeries
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
from src.server.exceptions import (
|
||||
BadRequestError,
|
||||
NotFoundError,
|
||||
ServerError,
|
||||
ValidationError,
|
||||
)
|
||||
from src.server.models.anime import AnimeMetadataUpdate
|
||||
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_database_session,
|
||||
get_optional_database_session,
|
||||
get_series_app,
|
||||
require_auth,
|
||||
)
|
||||
from src.server.utils.filesystem import sanitize_folder_name
|
||||
from src.server.utils.key_utils import generate_key_from_folder, is_valid_key
|
||||
from src.server.utils.validators import validate_filter_value, validate_search_query
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/anime", tags=["anime"])
|
||||
|
||||
|
||||
def _compute_folder_name(name: str, year: Optional[int]) -> str:
|
||||
"""Compute sanitized folder name from display name and year.
|
||||
|
||||
If year is provided, strips any existing year in (YYYY) format to avoid
|
||||
duplicates, then appends the new year. If year is None, preserves the
|
||||
original name (with any existing year).
|
||||
|
||||
Args:
|
||||
name: Display name of the series
|
||||
year: Release year from provider, or None
|
||||
|
||||
Returns:
|
||||
Sanitized folder name in format "Name (YYYY)" or just "Name"
|
||||
"""
|
||||
if year:
|
||||
# Strip any existing year in (YYYY) format before adding new year
|
||||
clean_name = re.sub(r'\s*\(\d{4}\)\s*$', '', name).strip()
|
||||
folder_name_with_year = f"{clean_name} ({year})"
|
||||
else:
|
||||
# No new year provided, preserve original name (with any existing year)
|
||||
folder_name_with_year = name
|
||||
|
||||
return sanitize_folder_name(folder_name_with_year)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_anime_status(
|
||||
_auth: dict = Depends(require_auth),
|
||||
@@ -55,12 +95,42 @@ async def get_anime_status(
|
||||
"series_count": series_count
|
||||
}
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get status: {str(exc)}",
|
||||
raise ServerError(
|
||||
message=f"Failed to get status: {str(exc)}"
|
||||
) from exc
|
||||
|
||||
|
||||
class DuplicateFolderGroup(BaseModel):
|
||||
"""Placeholder - duplicates functionality removed."""
|
||||
key: str = Field(..., description="Series key (unique identifier)")
|
||||
folders: List[str] = Field(..., description="List of duplicate folder names")
|
||||
folder_count: int = Field(..., description="Number of duplicate folders")
|
||||
|
||||
|
||||
class DuplicateFoldersResponse(BaseModel):
|
||||
"""Placeholder - duplicates functionality removed."""
|
||||
total_groups: int = Field(..., description="Total number of duplicate groups")
|
||||
duplicate_groups: List[DuplicateFolderGroup] = Field(
|
||||
..., description="List of duplicate folder groups"
|
||||
)
|
||||
message: str = Field(..., description="Human-readable summary")
|
||||
|
||||
|
||||
@router.get("/duplicate-folders", response_model=DuplicateFoldersResponse)
|
||||
async def get_duplicate_folders(
|
||||
_auth: dict = Depends(require_auth),
|
||||
) -> DuplicateFoldersResponse:
|
||||
"""List all pre-existing duplicate folder groups.
|
||||
|
||||
Note: Duplicate folder scanning has been removed. Returns empty response.
|
||||
"""
|
||||
return DuplicateFoldersResponse(
|
||||
total_groups=0,
|
||||
duplicate_groups=[],
|
||||
message="Duplicate folder scanning has been removed.",
|
||||
)
|
||||
|
||||
|
||||
class AnimeSummary(BaseModel):
|
||||
"""Summary of an anime series with missing episodes.
|
||||
|
||||
@@ -76,7 +146,13 @@ 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(
|
||||
...,
|
||||
@@ -98,10 +174,34 @@ 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."""
|
||||
@@ -112,7 +212,13 @@ class AnimeSummary(BaseModel):
|
||||
"site": "aniworld.to",
|
||||
"folder": "beheneko the elf girls cat (2025)",
|
||||
"missing_episodes": {"1": [1, 2, 3, 4]},
|
||||
"link": "https://aniworld.to/anime/stream/beheneko"
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,22 +280,27 @@ async def list_anime(
|
||||
sort_by: Optional[str] = None,
|
||||
filter: Optional[str] = None,
|
||||
_auth: dict = Depends(require_auth),
|
||||
series_app: Any = Depends(get_series_app),
|
||||
anime_service: AnimeService = Depends(get_anime_service),
|
||||
) -> List[AnimeSummary]:
|
||||
"""List library series that still have missing episodes.
|
||||
"""List all library series with their missing episodes status.
|
||||
|
||||
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 (validated for security)
|
||||
filter: Optional filter parameter. Allowed values:
|
||||
- "missing_episodes": Show only series that have any missing episodes
|
||||
- "no_episodes": Show only series that have no downloaded episodes
|
||||
_auth: Ensures the caller is authenticated (value unused)
|
||||
series_app: Core SeriesApp instance provided via dependency.
|
||||
anime_service: AnimeService instance provided via dependency
|
||||
|
||||
Returns:
|
||||
List[AnimeSummary]: Summary entries with `key` as primary identifier.
|
||||
@@ -199,6 +310,7 @@ 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.
|
||||
@@ -208,35 +320,30 @@ async def list_anime(
|
||||
try:
|
||||
page_num = int(page)
|
||||
if page_num < 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Page number must be positive"
|
||||
raise ValidationError(
|
||||
message="Page number must be positive"
|
||||
)
|
||||
page = page_num
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Page must be a valid number"
|
||||
raise ValidationError(
|
||||
message="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 HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Per page must be positive"
|
||||
raise ValidationError(
|
||||
message="Per page must be positive"
|
||||
)
|
||||
if per_page_num > 1000:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Per page cannot exceed 1000"
|
||||
raise ValidationError(
|
||||
message="Per page cannot exceed 1000"
|
||||
)
|
||||
per_page = per_page_num
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Per page must be a valid number"
|
||||
raise ValidationError(
|
||||
message="Per page must be a valid number"
|
||||
)
|
||||
|
||||
# Validate sort_by parameter to prevent ORM injection
|
||||
@@ -245,51 +352,46 @@ 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 HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid sort_by parameter. Allowed: {allowed}"
|
||||
raise ValidationError(
|
||||
message=f"Invalid sort_by parameter. Allowed: {allowed}"
|
||||
)
|
||||
|
||||
# Validate filter parameter
|
||||
if filter:
|
||||
# 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:
|
||||
allowed_filters = ["missing_episodes", "no_episodes"]
|
||||
validate_filter_value(filter, allowed_filters)
|
||||
except ValueError as e:
|
||||
raise ValidationError(message=str(e))
|
||||
|
||||
try:
|
||||
# Get missing episodes from series app
|
||||
if not hasattr(series_app, "list"):
|
||||
return []
|
||||
# Use AnimeService to get series with metadata from database
|
||||
series_list = await anime_service.list_series_with_filters(
|
||||
filter_type=filter
|
||||
)
|
||||
|
||||
series = series_app.list.GetMissingEpisode()
|
||||
summaries: List[AnimeSummary] = []
|
||||
for serie in series:
|
||||
# Get all properties from the serie object
|
||||
key = getattr(serie, "key", "")
|
||||
name = getattr(serie, "name", "")
|
||||
site = getattr(serie, "site", "")
|
||||
folder = getattr(serie, "folder", "")
|
||||
episode_dict = getattr(serie, "episodeDict", {}) or {}
|
||||
|
||||
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)
|
||||
|
||||
summaries.append(
|
||||
AnimeSummary(
|
||||
key=key,
|
||||
name=name,
|
||||
site=site,
|
||||
folder=folder,
|
||||
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"),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -310,12 +412,11 @@ async def list_anime(
|
||||
)
|
||||
|
||||
return summaries
|
||||
except HTTPException:
|
||||
except (ValidationError, BadRequestError, NotFoundError, ServerError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve anime list",
|
||||
raise ServerError(
|
||||
message="Failed to retrieve anime list"
|
||||
) from exc
|
||||
|
||||
|
||||
@@ -346,17 +447,40 @@ async def trigger_rescan(
|
||||
"message": "Rescan started successfully",
|
||||
}
|
||||
except AnimeServiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Rescan failed: {str(e)}",
|
||||
raise ServerError(
|
||||
message=f"Rescan failed: {str(e)}"
|
||||
) from e
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to start rescan",
|
||||
raise ServerError(
|
||||
message="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."""
|
||||
|
||||
@@ -364,8 +488,8 @@ class AddSeriesRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
def validate_search_query(query: str) -> str:
|
||||
"""Validate and sanitize search query.
|
||||
def _validate_search_query_extended(query: str) -> str:
|
||||
"""Validate and sanitize search query with additional checks.
|
||||
|
||||
Args:
|
||||
query: The search query string
|
||||
@@ -396,25 +520,16 @@ def validate_search_query(query: str) -> str:
|
||||
detail="Search query too long (max 200 characters)"
|
||||
)
|
||||
|
||||
# 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
|
||||
# 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)
|
||||
)
|
||||
|
||||
|
||||
|
||||
class SearchAnimeRequest(BaseModel):
|
||||
@@ -503,7 +618,7 @@ async def _perform_search(
|
||||
"""
|
||||
try:
|
||||
# Validate and sanitize the query
|
||||
validated_query = validate_search_query(query)
|
||||
validated_query = _validate_search_query_extended(query)
|
||||
|
||||
# Check if series_app is available
|
||||
if not series_app:
|
||||
@@ -580,22 +695,31 @@ async def _perform_search(
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/add")
|
||||
@router.post("/add", status_code=status.HTTP_202_ACCEPTED)
|
||||
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.
|
||||
"""Add a new series to the library with asynchronous data loading.
|
||||
|
||||
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 along with a
|
||||
filesystem-friendly `folder` name derived from the name.
|
||||
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
|
||||
|
||||
Series are saved to the database using AnimeSeriesService when
|
||||
database is available, falling back to in-memory storage otherwise.
|
||||
Data loading happens asynchronously in the background, with real-time
|
||||
status updates via WebSocket.
|
||||
|
||||
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).
|
||||
|
||||
Args:
|
||||
request: Request containing the series link and name.
|
||||
@@ -604,15 +728,23 @@ async def add_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 success message, key, and db_id
|
||||
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
|
||||
|
||||
Raises:
|
||||
HTTPException: If adding the series fails or link is invalid
|
||||
"""
|
||||
try:
|
||||
# Validate inputs
|
||||
# Step A: Validate inputs
|
||||
if not request.link or not request.link.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -645,76 +777,330 @@ async def add_series(
|
||||
detail="Could not extract series key from link",
|
||||
)
|
||||
|
||||
# Create folder from name (filesystem-friendly)
|
||||
folder = request.name.strip()
|
||||
# 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("Fetched year for %s: %s", key, year)
|
||||
except Exception as e:
|
||||
logger.warning("Could not fetch year for %s: %s", key, e)
|
||||
|
||||
# Step B: Compute sanitized folder name with year (deduplicates if year already in name)
|
||||
try:
|
||||
folder = _compute_folder_name(name, year)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid series name for folder: {str(e)}",
|
||||
)
|
||||
|
||||
db_id = None
|
||||
|
||||
# Try to save to database if available
|
||||
# Step C: Create folder on disk if it doesn't exist, and rename if needed
|
||||
# Determine the anime directory path
|
||||
anime_dir = settings.anime_directory if hasattr(settings, 'anime_directory') else None
|
||||
current_folder_on_disk = None
|
||||
|
||||
if anime_dir:
|
||||
import os
|
||||
anime_path = os.path.join(anime_dir, folder)
|
||||
|
||||
# Check if an existing folder (without year) needs renaming
|
||||
# Look for folder that matches name without year
|
||||
if year:
|
||||
potential_old_name = sanitize_folder_name(name)
|
||||
potential_old_path = os.path.join(anime_dir, potential_old_name)
|
||||
if potential_old_path != anime_path and os.path.exists(potential_old_path):
|
||||
current_folder_on_disk = potential_old_name
|
||||
logger.info(
|
||||
"Found existing folder without year for %s: %s, renaming to %s",
|
||||
key,
|
||||
potential_old_name,
|
||||
folder
|
||||
)
|
||||
elif not os.path.exists(anime_path):
|
||||
# No existing folder to rename, create new one
|
||||
os.makedirs(anime_path, exist_ok=True)
|
||||
else:
|
||||
# No year, just ensure folder exists
|
||||
if not os.path.exists(anime_path):
|
||||
os.makedirs(anime_path, exist_ok=True)
|
||||
|
||||
# Step D: 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: {request.name}",
|
||||
"message": f"Series already exists: {name}",
|
||||
"key": key,
|
||||
"folder": existing.folder,
|
||||
"db_id": existing.id
|
||||
"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
|
||||
# Save to database using AnimeSeriesService with loading status
|
||||
anime_series = await AnimeSeriesService.create(
|
||||
db=db,
|
||||
key=key,
|
||||
name=request.name.strip(),
|
||||
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)",
|
||||
request.name,
|
||||
"Added series to database: %s (key=%s, db_id=%d, year=%s, loading=pending)",
|
||||
name,
|
||||
key,
|
||||
db_id
|
||||
db_id,
|
||||
year
|
||||
)
|
||||
|
||||
# Also add to in-memory cache if series_app has the list attribute
|
||||
# Step D: Add to SerieList (in-memory only, no folder creation)
|
||||
if series_app and hasattr(series_app, "list"):
|
||||
serie = Serie(
|
||||
from src.server.database.models import AnimeSeries
|
||||
anime = AnimeSeries(
|
||||
key=key,
|
||||
name=request.name.strip(),
|
||||
name=name,
|
||||
site="aniworld.to",
|
||||
folder=folder,
|
||||
episodeDict={}
|
||||
year=year
|
||||
)
|
||||
# Add to in-memory cache
|
||||
|
||||
# Add to in-memory cache without creating folder on disk
|
||||
if hasattr(series_app.list, 'keyDict'):
|
||||
# Direct update without file saving
|
||||
series_app.list.keyDict[key] = serie
|
||||
elif hasattr(series_app.list, 'add'):
|
||||
# Legacy: use add method (may create file with deprecation warning)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
series_app.list.add(serie)
|
||||
series_app.list.keyDict[key] = anime
|
||||
logger.info(
|
||||
"Added series to in-memory cache: %s (key=%s, folder=%s, year=%s)",
|
||||
name,
|
||||
key,
|
||||
folder,
|
||||
year
|
||||
)
|
||||
|
||||
return {
|
||||
# Step E: Rename existing folder if needed (e.g., folder existed without year)
|
||||
if current_folder_on_disk:
|
||||
try:
|
||||
renamed = await anime_service.rename_folder_if_needed(
|
||||
key=key,
|
||||
current_folder=current_folder_on_disk,
|
||||
target_folder=folder,
|
||||
db=db
|
||||
)
|
||||
if renamed:
|
||||
logger.info(
|
||||
"Successfully renamed folder for %s: %s -> %s",
|
||||
key,
|
||||
current_folder_on_disk,
|
||||
folder
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to rename folder for %s: %s -> %s: %s",
|
||||
key,
|
||||
current_folder_on_disk,
|
||||
folder,
|
||||
e
|
||||
)
|
||||
|
||||
# Step F: 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 G: 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(
|
||||
key=key,
|
||||
folder=folder
|
||||
)
|
||||
total_missing = sum(
|
||||
len(eps) for eps in missing_episodes.values()
|
||||
)
|
||||
logger.info(
|
||||
"Scanned %d missing episodes for %s",
|
||||
total_missing,
|
||||
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:
|
||||
logger.warning(
|
||||
"Failed to scan missing episodes for %s: %s",
|
||||
key,
|
||||
e
|
||||
)
|
||||
|
||||
# Step G: Return immediate response (202 Accepted)
|
||||
response = {
|
||||
"status": "success",
|
||||
"message": f"Successfully added series: {request.name}",
|
||||
"message": f"Series added successfully: {name}. Data will be loaded in background.",
|
||||
"key": key,
|
||||
"folder": folder,
|
||||
"db_id": db_id
|
||||
"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,
|
||||
@@ -809,3 +1195,75 @@ async def get_anime(
|
||||
# Maximum allowed input size for security
|
||||
MAX_INPUT_LENGTH = 100000 # 100KB
|
||||
|
||||
|
||||
@router.put("/{anime_key}")
|
||||
async def update_anime_metadata(
|
||||
anime_key: str,
|
||||
body: AnimeMetadataUpdate,
|
||||
_auth: dict = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_database_session),
|
||||
) -> dict:
|
||||
"""Update anime metadata (key, tmdb_id, tvdb_id).
|
||||
|
||||
Args:
|
||||
anime_key: Current series key to update
|
||||
body: Fields to update (all optional)
|
||||
_auth: Authentication dependency
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Updated series metadata
|
||||
|
||||
Raises:
|
||||
HTTPException 404: Series not found
|
||||
HTTPException 409: Key conflict (new key already exists)
|
||||
HTTPException 422: Validation error
|
||||
"""
|
||||
series = await AnimeSeriesService.get_by_key(db, anime_key)
|
||||
if not series:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Series with key '{anime_key}' not found",
|
||||
)
|
||||
|
||||
updates = {}
|
||||
|
||||
if body.key is not None and body.key != anime_key:
|
||||
existing = await AnimeSeriesService.get_by_key(db, body.key)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"A series with key '{body.key}' already exists",
|
||||
)
|
||||
updates["key"] = body.key
|
||||
|
||||
if body.tmdb_id is not None:
|
||||
updates["tmdb_id"] = body.tmdb_id
|
||||
|
||||
if body.tvdb_id is not None:
|
||||
updates["tvdb_id"] = body.tvdb_id
|
||||
|
||||
if not updates:
|
||||
return {
|
||||
"key": series.key,
|
||||
"tmdb_id": series.tmdb_id,
|
||||
"tvdb_id": series.tvdb_id,
|
||||
"message": "No changes",
|
||||
}
|
||||
|
||||
updated = await AnimeSeriesService.update(db, series.id, **updates)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Updated metadata for '%s': %s",
|
||||
anime_key,
|
||||
updates,
|
||||
)
|
||||
|
||||
return {
|
||||
"key": updated.key,
|
||||
"tmdb_id": updated.tmdb_id,
|
||||
"tvdb_id": updated.tvdb_id,
|
||||
"message": "Metadata updated successfully",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Authentication API endpoints for Aniworld."""
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import status as http_status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
@@ -15,6 +16,9 @@ from src.server.models.auth import (
|
||||
from src.server.models.config import AppConfig
|
||||
from src.server.services.auth_service import AuthError, LockedOutError, auth_service
|
||||
from src.server.services.config_service import get_config_service
|
||||
from src.server.services.progress_service import ProgressType
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# NOTE: import dependencies (optional_auth, security) lazily inside handlers
|
||||
# to avoid importing heavyweight modules (e.g. sqlalchemy) at import time.
|
||||
@@ -29,8 +33,9 @@ optional_bearer = HTTPBearer(auto_error=False)
|
||||
async def setup_auth(req: SetupRequest):
|
||||
"""Initial setup endpoint to configure the master password.
|
||||
|
||||
This endpoint also initializes the configuration with default values
|
||||
and saves the anime directory and master password hash.
|
||||
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.
|
||||
"""
|
||||
if auth_service.is_configured():
|
||||
raise HTTPException(
|
||||
@@ -44,28 +49,176 @@ async def setup_auth(req: SetupRequest):
|
||||
req.master_password
|
||||
)
|
||||
|
||||
# Initialize or update config with master password hash
|
||||
# and anime directory
|
||||
# Initialize or update config with all provided values
|
||||
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
|
||||
|
||||
# Mark that loading has been initiated (used by middleware to prevent
|
||||
# premature redirect to /login after setup)
|
||||
config.other['loading_started'] = True
|
||||
|
||||
# Store anime directory in config's other field if provided
|
||||
anime_directory = None
|
||||
if hasattr(req, 'anime_directory') and req.anime_directory:
|
||||
if req.anime_directory:
|
||||
anime_directory = req.anime_directory.strip()
|
||||
if anime_directory:
|
||||
config.other['anime_directory'] = anime_directory
|
||||
|
||||
# Save the config with the password hash and anime directory
|
||||
# Save the config with all updates
|
||||
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
|
||||
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)
|
||||
|
||||
# Start scheduler if anime_directory is now set
|
||||
try:
|
||||
from src.server.services.scheduler.scheduler_service import (
|
||||
get_scheduler_service,
|
||||
)
|
||||
|
||||
scheduler_svc = get_scheduler_service()
|
||||
logger.info("Starting scheduler after initialization")
|
||||
await scheduler_svc.ensure_started()
|
||||
logger.info("Scheduler started successfully during setup")
|
||||
except Exception as sched_exc:
|
||||
logger.warning(
|
||||
"Failed to start scheduler during setup: %s", sched_exc
|
||||
)
|
||||
# Continue — scheduler failure should not break initialization
|
||||
|
||||
# Send completion event
|
||||
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
|
||||
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 with phase=initial
|
||||
# The loading page will show ONLY series_sync step, then redirect to /setup/unresolved
|
||||
return {"status": "ok", "redirect": "/loading?phase=initial"}
|
||||
# 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:
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from src.server.models.config import AppConfig, ConfigUpdate, ValidationResult
|
||||
from src.server.services.config_service import (
|
||||
ConfigBackupError,
|
||||
@@ -28,16 +31,53 @@ def get_config(auth: Optional[dict] = Depends(require_auth)) -> AppConfig:
|
||||
|
||||
|
||||
@router.put("", response_model=AppConfig)
|
||||
def update_config(
|
||||
async def update_config(
|
||||
update: ConfigUpdate, auth: dict = Depends(require_auth)
|
||||
) -> AppConfig:
|
||||
"""Apply an update to the configuration and persist it.
|
||||
|
||||
Creates automatic backup before applying changes.
|
||||
Creates automatic backup before applying changes. If anime_directory
|
||||
is configured, starts the scheduler service.
|
||||
"""
|
||||
try:
|
||||
config_service = get_config_service()
|
||||
return config_service.update_config(update)
|
||||
updated_config = config_service.update_config(update)
|
||||
|
||||
# Sync anime_directory to settings if it was updated
|
||||
from src.config.settings import settings as app_settings
|
||||
|
||||
anime_dir_changed = False
|
||||
if update.other and update.other.get("anime_directory"):
|
||||
anime_dir = update.other.get("anime_directory")
|
||||
if anime_dir and not app_settings.anime_directory:
|
||||
app_settings.anime_directory = str(anime_dir)
|
||||
anime_dir_changed = True
|
||||
logger.info("Synced anime_directory from config: %s", anime_dir)
|
||||
|
||||
# Start scheduler if anime_directory was just configured
|
||||
if anime_dir_changed:
|
||||
try:
|
||||
from src.server.services.scheduler.scheduler_service import (
|
||||
get_scheduler_service,
|
||||
)
|
||||
|
||||
scheduler_svc = get_scheduler_service()
|
||||
logger.info(
|
||||
"Starting scheduler after anime_directory configuration"
|
||||
)
|
||||
await scheduler_svc.ensure_started()
|
||||
logger.info(
|
||||
"Scheduler started successfully after config update"
|
||||
)
|
||||
except Exception as sched_exc:
|
||||
logger.warning(
|
||||
"Failed to start scheduler after config update: %s",
|
||||
sched_exc,
|
||||
)
|
||||
# Config was already saved, don't fail the request
|
||||
|
||||
return updated_config
|
||||
|
||||
except ConfigValidationError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -239,8 +279,15 @@ async def update_directory(
|
||||
|
||||
config_service.save_config(app_config)
|
||||
|
||||
# Series are now loaded directly from database, no sync needed
|
||||
logger.info(
|
||||
"Directory updated successfully",
|
||||
directory=directory
|
||||
)
|
||||
|
||||
response: Dict[str, Any] = {
|
||||
"message": "Anime directory updated successfully"
|
||||
"message": "Anime directory updated successfully",
|
||||
"synced_series": 0
|
||||
}
|
||||
|
||||
return response
|
||||
@@ -349,3 +396,59 @@ 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)}"
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ 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, HTTPException, Path, status
|
||||
from fastapi import APIRouter, Depends, Path, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from src.server.exceptions import BadRequestError, NotFoundError, ServerError
|
||||
from src.server.models.download import (
|
||||
DownloadRequest,
|
||||
QueueOperationRequest,
|
||||
@@ -52,9 +53,8 @@ async def get_queue_status(
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve queue status: {str(e)}",
|
||||
raise ServerError(
|
||||
message=f"Failed to retrieve queue status: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -91,9 +91,8 @@ async def add_to_queue(
|
||||
try:
|
||||
# Validate request
|
||||
if not request.episodes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="At least one episode must be specified",
|
||||
raise BadRequestError(
|
||||
message="At least one episode must be specified"
|
||||
)
|
||||
|
||||
# Add to queue
|
||||
@@ -122,16 +121,12 @@ async def add_to_queue(
|
||||
)
|
||||
|
||||
except DownloadServiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
except HTTPException:
|
||||
raise BadRequestError(message=str(e))
|
||||
except (BadRequestError, NotFoundError, ServerError):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to add episodes to queue: {str(e)}",
|
||||
raise ServerError(
|
||||
message=f"Failed to add episodes to queue: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -163,9 +158,8 @@ async def clear_completed(
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to clear completed items: {str(e)}",
|
||||
raise ServerError(
|
||||
message=f"Failed to clear completed items: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -197,9 +191,8 @@ async def clear_failed(
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to clear failed items: {str(e)}",
|
||||
raise ServerError(
|
||||
message=f"Failed to clear failed items: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -231,9 +224,8 @@ async def clear_pending(
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to clear pending items: {str(e)}",
|
||||
raise ServerError(
|
||||
message=f"Failed to clear pending items: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -262,22 +254,19 @@ async def remove_from_queue(
|
||||
removed_ids = await download_service.remove_from_queue([item_id])
|
||||
|
||||
if not removed_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Download item {item_id} not found in queue",
|
||||
raise NotFoundError(
|
||||
message=f"Download item {item_id} not found in queue",
|
||||
resource_type="download_item",
|
||||
resource_id=item_id
|
||||
)
|
||||
|
||||
except DownloadServiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
except HTTPException:
|
||||
raise BadRequestError(message=str(e))
|
||||
except (BadRequestError, NotFoundError, ServerError):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to remove item from queue: {str(e)}",
|
||||
raise ServerError(
|
||||
message=f"Failed to remove item from queue: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -307,22 +296,18 @@ async def remove_multiple_from_queue(
|
||||
)
|
||||
|
||||
if not removed_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No matching items found in queue",
|
||||
raise NotFoundError(
|
||||
message="No matching items found in queue",
|
||||
resource_type="download_items"
|
||||
)
|
||||
|
||||
except DownloadServiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
except HTTPException:
|
||||
raise BadRequestError(message=str(e))
|
||||
except (BadRequestError, NotFoundError, ServerError):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to remove items from queue: {str(e)}",
|
||||
raise ServerError(
|
||||
message=f"Failed to remove items from queue: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -354,9 +339,8 @@ async def start_queue(
|
||||
result = await download_service.start_queue_processing()
|
||||
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No pending downloads in queue",
|
||||
raise BadRequestError(
|
||||
message="No pending downloads in queue"
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -365,16 +349,12 @@ async def start_queue(
|
||||
}
|
||||
|
||||
except DownloadServiceError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
except HTTPException:
|
||||
raise BadRequestError(message=str(e))
|
||||
except (BadRequestError, NotFoundError, ServerError):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to start queue processing: {str(e)}",
|
||||
raise ServerError(
|
||||
message=f"Failed to start queue processing: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -408,9 +388,8 @@ async def stop_queue(
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to stop queue processing: {str(e)}",
|
||||
raise ServerError(
|
||||
message=f"Failed to stop queue processing: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -442,9 +421,8 @@ async def pause_queue(
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to pause queue processing: {str(e)}",
|
||||
raise ServerError(
|
||||
message=f"Failed to pause queue processing: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -480,9 +458,8 @@ async def reorder_queue(
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to reorder queue: {str(e)}",
|
||||
raise ServerError(
|
||||
message=f"Failed to reorder queue: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -522,7 +499,6 @@ async def retry_failed(
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retry downloads: {str(e)}",
|
||||
raise ServerError(
|
||||
message=f"Failed to retry downloads: {str(e)}"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import psutil
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -17,12 +17,21 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/health", tags=["health"])
|
||||
|
||||
|
||||
from src.server.utils.version import APP_VERSION
|
||||
|
||||
|
||||
class HealthStatus(BaseModel):
|
||||
"""Basic health status response."""
|
||||
|
||||
status: str
|
||||
timestamp: str
|
||||
version: str = "1.0.0"
|
||||
version: str = APP_VERSION
|
||||
service: str = "aniworld-api"
|
||||
series_app_initialized: bool = False
|
||||
anime_directory_configured: bool = False
|
||||
scheduler_next_run: Optional[str] = None
|
||||
scheduler_last_run: Optional[str] = None
|
||||
checks: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class DatabaseHealth(BaseModel):
|
||||
@@ -57,7 +66,7 @@ class DetailedHealthStatus(BaseModel):
|
||||
|
||||
status: str
|
||||
timestamp: str
|
||||
version: str = "1.0.0"
|
||||
version: str = APP_VERSION
|
||||
dependencies: DependencyHealth
|
||||
startup_time: datetime
|
||||
|
||||
@@ -88,7 +97,7 @@ async def check_database_health(db: AsyncSession) -> DatabaseHealth:
|
||||
message="Database connection successful",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Database health check failed: {e}")
|
||||
logger.error("Database health check failed: %s", e)
|
||||
return DatabaseHealth(
|
||||
status="unhealthy",
|
||||
connection_time_ms=0,
|
||||
@@ -118,7 +127,7 @@ async def check_filesystem_health() -> Dict[str, Any]:
|
||||
"message": "Filesystem check completed",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Filesystem health check failed: {e}")
|
||||
logger.error("Filesystem health check failed: %s", e)
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"message": f"Filesystem check failed: {str(e)}",
|
||||
@@ -161,26 +170,99 @@ def get_system_metrics() -> SystemMetrics:
|
||||
uptime_seconds=uptime_seconds,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"System metrics collection failed: {e}")
|
||||
logger.error("System metrics collection failed: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to collect system metrics: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=HealthStatus)
|
||||
async def basic_health_check() -> HealthStatus:
|
||||
async def basic_health_check(request: Request) -> 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.
|
||||
Includes scheduler next/last run times for monitoring tools.
|
||||
Includes startup health check results.
|
||||
|
||||
Returns:
|
||||
HealthStatus: Simple health status with timestamp.
|
||||
HealthStatus: Simple health status with timestamp and service info.
|
||||
"""
|
||||
from src.config.settings import settings
|
||||
from src.server.utils.dependencies import _series_app
|
||||
|
||||
# Get scheduler status for health monitoring
|
||||
scheduler_status: dict = {}
|
||||
try:
|
||||
from src.server.services.scheduler.scheduler_service import (
|
||||
get_scheduler_service,
|
||||
)
|
||||
scheduler_status = get_scheduler_service().get_status()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get startup checks from app state
|
||||
checks = getattr(request.app.state, "startup_checks", None)
|
||||
|
||||
# Determine overall status based on checks
|
||||
overall_status = "healthy"
|
||||
if checks:
|
||||
for check_name, check_data in checks.items():
|
||||
if check_data.get("status") == "error":
|
||||
overall_status = "unhealthy"
|
||||
break
|
||||
elif check_data.get("status") == "warning":
|
||||
overall_status = "degraded"
|
||||
|
||||
logger.debug("Basic health check requested")
|
||||
return HealthStatus(
|
||||
status="healthy",
|
||||
status=overall_status,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
service="aniworld-api",
|
||||
series_app_initialized=_series_app is not None,
|
||||
anime_directory_configured=bool(settings.anime_directory),
|
||||
scheduler_next_run=scheduler_status.get("next_run"),
|
||||
scheduler_last_run=scheduler_status.get("last_run"),
|
||||
checks=checks,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ready")
|
||||
async def ready_check(request: Request) -> Dict[str, Any]:
|
||||
"""Readiness check endpoint for container orchestrators.
|
||||
|
||||
Returns 503 if critical dependencies are not available.
|
||||
This endpoint is used by Kubernetes, Docker Swarm, etc. to determine
|
||||
if the container should receive traffic.
|
||||
|
||||
Returns:
|
||||
dict: Readiness status with checks details.
|
||||
"""
|
||||
checks = getattr(request.app.state, "startup_checks", {})
|
||||
|
||||
critical_failures = []
|
||||
for check_name, check_data in checks.items():
|
||||
if check_data.get("status") == "error":
|
||||
critical_failures.append(f"{check_name}: {check_data.get('message')}")
|
||||
|
||||
if critical_failures:
|
||||
return {
|
||||
"status": "not_ready",
|
||||
"ready": False,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"critical_failures": critical_failures,
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "ready",
|
||||
"ready": True,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/detailed", response_model=DetailedHealthStatus)
|
||||
async def detailed_health_check(
|
||||
db: AsyncSession = Depends(get_database_session),
|
||||
@@ -223,7 +305,7 @@ async def detailed_health_check(
|
||||
startup_time=startup_time,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Detailed health check failed: {e}")
|
||||
logger.error("Detailed health check failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Health check failed")
|
||||
|
||||
|
||||
|
||||
230
src/server/api/logging.py
Normal file
230
src/server/api/logging.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""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}
|
||||
70
src/server/api/nfo.py
Normal file
70
src/server/api/nfo.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""NFO Management API endpoints.
|
||||
|
||||
Note: NFO service has been removed. All NFO endpoints return 503.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
router = APIRouter(prefix="/api/nfo", tags=["nfo"])
|
||||
|
||||
|
||||
@router.get("/disabled")
|
||||
async def nfo_disabled():
|
||||
"""NFO endpoints disabled - NFO service removed."""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="NFO service has been removed. Use series management endpoints instead."
|
||||
)
|
||||
|
||||
|
||||
@router.post("/batch/create")
|
||||
async def batch_create_nfo():
|
||||
"""NFO endpoints disabled - NFO service removed."""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="NFO service has been removed. Use series management endpoints instead."
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{serie_id}/create")
|
||||
async def create_nfo(serie_id: str):
|
||||
"""NFO endpoints disabled - NFO service removed."""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="NFO service has been removed. Use series management endpoints instead."
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{serie_id}/status")
|
||||
async def get_nfo_status(serie_id: str):
|
||||
"""NFO endpoints disabled - NFO service removed."""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="NFO service has been removed. Use series management endpoints instead."
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{serie_id}/delete")
|
||||
async def delete_nfo(serie_id: str):
|
||||
"""NFO endpoints disabled - NFO service removed."""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="NFO service has been removed. Use series management endpoints instead."
|
||||
)
|
||||
|
||||
|
||||
@router.get("/poster/{serie_id}")
|
||||
async def get_nfo_poster(serie_id: str):
|
||||
"""NFO endpoints disabled - NFO service removed."""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="NFO service has been removed. Use series management endpoints instead."
|
||||
)
|
||||
|
||||
|
||||
@router.get("/fanart/{serie_id}")
|
||||
async def get_nfo_fanart(serie_id: str):
|
||||
"""NFO endpoints disabled - NFO service removed."""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="NFO service has been removed. Use series management endpoints instead."
|
||||
)
|
||||
@@ -4,12 +4,13 @@ This module provides endpoints for managing scheduled tasks such as
|
||||
automatic anime library rescans.
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
from typing import Any, 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.scheduler_service import get_scheduler_service
|
||||
from src.server.utils.dependencies import require_auth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,78 +18,105 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/scheduler", tags=["scheduler"])
|
||||
|
||||
|
||||
@router.get("/config", response_model=SchedulerConfig)
|
||||
def get_scheduler_config(
|
||||
auth: Optional[dict] = Depends(require_auth)
|
||||
) -> SchedulerConfig:
|
||||
"""Get current scheduler configuration.
|
||||
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()
|
||||
|
||||
Args:
|
||||
auth: Authentication token (optional for read operations)
|
||||
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")
|
||||
def get_scheduler_config(
|
||||
auth: Optional[dict] = Depends(require_auth),
|
||||
) -> Dict[str, Any]:
|
||||
"""Get current scheduler configuration along with runtime status.
|
||||
|
||||
Returns:
|
||||
SchedulerConfig: Current scheduler configuration
|
||||
Combined config and status response.
|
||||
|
||||
Raises:
|
||||
HTTPException: If configuration cannot be loaded
|
||||
HTTPException: 500 if configuration cannot be loaded.
|
||||
"""
|
||||
try:
|
||||
config_service = get_config_service()
|
||||
app_config = config_service.load_config()
|
||||
return app_config.scheduler
|
||||
except ConfigServiceError as e:
|
||||
logger.error(f"Failed to load scheduler config: {e}")
|
||||
return _build_response(app_config.scheduler)
|
||||
except ConfigServiceError as exc:
|
||||
logger.error("Failed to load scheduler config: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to load scheduler configuration: {e}",
|
||||
) from e
|
||||
detail=f"Failed to load scheduler configuration: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/config", response_model=SchedulerConfig)
|
||||
@router.post("/config")
|
||||
def update_scheduler_config(
|
||||
scheduler_config: SchedulerConfig,
|
||||
auth: dict = Depends(require_auth),
|
||||
) -> SchedulerConfig:
|
||||
"""Update scheduler configuration.
|
||||
) -> Dict[str, Any]:
|
||||
"""Update scheduler configuration and apply changes immediately.
|
||||
|
||||
Args:
|
||||
scheduler_config: New scheduler configuration
|
||||
auth: Authentication token (required)
|
||||
Accepts the full SchedulerConfig body; any fields not supplied default
|
||||
to their model defaults (backward compatible).
|
||||
|
||||
Returns:
|
||||
SchedulerConfig: Updated scheduler configuration
|
||||
Combined config and status response reflecting the saved config.
|
||||
|
||||
Raises:
|
||||
HTTPException: If configuration update fails
|
||||
HTTPException: 422 on validation errors (handled by FastAPI/Pydantic),
|
||||
500 on save or scheduler failure.
|
||||
"""
|
||||
try:
|
||||
config_service = get_config_service()
|
||||
app_config = config_service.load_config()
|
||||
|
||||
# Update scheduler section
|
||||
app_config.scheduler = scheduler_config
|
||||
|
||||
# Save and return
|
||||
config_service.save_config(app_config)
|
||||
|
||||
logger.info(
|
||||
f"Scheduler config updated by {auth.get('username', 'unknown')}"
|
||||
"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,
|
||||
)
|
||||
|
||||
return scheduler_config
|
||||
except ConfigServiceError as e:
|
||||
logger.error(f"Failed to update scheduler config: {e}")
|
||||
# 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)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to update scheduler configuration: {e}",
|
||||
) from e
|
||||
detail=f"Failed to update scheduler configuration: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
This endpoint triggers an immediate anime library rescan, bypassing
|
||||
the scheduler interval.
|
||||
"""Manually trigger a library rescan (and auto-download if configured).
|
||||
|
||||
Args:
|
||||
auth: Authentication token (required)
|
||||
@@ -100,8 +128,7 @@ async def trigger_rescan(auth: dict = Depends(require_auth)) -> Dict[str, str]:
|
||||
HTTPException: If rescan cannot be triggered
|
||||
"""
|
||||
try:
|
||||
# Import here to avoid circular dependency
|
||||
from src.server.fastapi_app import get_series_app
|
||||
from src.server.utils.dependencies import get_series_app # noqa: PLC0415
|
||||
|
||||
series_app = get_series_app()
|
||||
if not series_app:
|
||||
@@ -110,21 +137,19 @@ async def trigger_rescan(auth: dict = Depends(require_auth)) -> Dict[str, str]:
|
||||
detail="SeriesApp not initialized",
|
||||
)
|
||||
|
||||
# Trigger the rescan
|
||||
logger.info(
|
||||
f"Manual rescan triggered by {auth.get('username', 'unknown')}"
|
||||
"Manual rescan triggered by %s", auth.get("username", "unknown")
|
||||
)
|
||||
|
||||
# Use existing rescan logic from anime API
|
||||
from src.server.api.anime import trigger_rescan as do_rescan
|
||||
from src.server.api.anime import trigger_rescan as do_rescan # noqa: PLC0415
|
||||
|
||||
return await do_rescan()
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to trigger manual rescan")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to trigger rescan: {str(e)}",
|
||||
) from e
|
||||
detail=f"Failed to trigger rescan: {exc}",
|
||||
) from exc
|
||||
|
||||
423
src/server/api/setup_endpoints.py
Normal file
423
src/server/api/setup_endpoints.py
Normal file
@@ -0,0 +1,423 @@
|
||||
"""API endpoints for setup and unresolved folder management.
|
||||
|
||||
Provides endpoints to:
|
||||
- List unresolved folders that couldn't be auto-resolved during setup
|
||||
- Get suggestions/search results for an unresolved folder
|
||||
- Resolve an unresolved folder by providing a provider key
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.server.database.connection import get_db_session
|
||||
from src.server.database.service import AnimeSeriesService, UnresolvedFolderService
|
||||
from src.server.utils.dependencies import (
|
||||
get_database_session,
|
||||
get_series_app,
|
||||
require_auth,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
||||
|
||||
|
||||
class UnresolvedFolderResponse(BaseModel):
|
||||
"""Response model for an unresolved folder."""
|
||||
|
||||
folder_name: str = Field(..., description="Original filesystem folder name")
|
||||
title: str = Field(..., description="Extracted title from folder name")
|
||||
year: Optional[int] = Field(None, description="Extracted release year")
|
||||
search_attempts: int = Field(..., description="Number of search attempts made")
|
||||
search_suggestions: list[dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
description="Cached search results for potential matches"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ResolveFolderRequest(BaseModel):
|
||||
"""Request model for resolving an unresolved folder."""
|
||||
|
||||
provider_key: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description="Provider key to associate with this folder"
|
||||
)
|
||||
|
||||
|
||||
class ResolveFolderResponse(BaseModel):
|
||||
"""Response model for resolving an unresolved folder."""
|
||||
|
||||
status: str = Field(..., description="Operation status")
|
||||
message: str = Field(..., description="Human-readable message")
|
||||
folder_name: str = Field(..., description="Folder name that was resolved")
|
||||
key: str = Field(..., description="Provider key that was used")
|
||||
series_id: int = Field(..., description="Database ID of the created series")
|
||||
|
||||
|
||||
@router.get("/unresolved", response_model=list[UnresolvedFolderResponse])
|
||||
async def list_unresolved_folders(
|
||||
db=Depends(get_database_session),
|
||||
) -> list[UnresolvedFolderResponse]:
|
||||
"""List all unresolved folders that need manual key resolution.
|
||||
|
||||
Returns folders that couldn't be auto-resolved during setup,
|
||||
including cached search suggestions when available.
|
||||
|
||||
Returns:
|
||||
List of UnresolvedFolderResponse objects
|
||||
"""
|
||||
folders = await UnresolvedFolderService.get_all_unresolved(db)
|
||||
|
||||
result = []
|
||||
for folder in folders:
|
||||
suggestions = []
|
||||
if folder.last_search_result:
|
||||
try:
|
||||
suggestions = json.loads(folder.last_search_result)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
"Failed to parse search result for folder: %s",
|
||||
folder.folder_name
|
||||
)
|
||||
|
||||
result.append(UnresolvedFolderResponse(
|
||||
folder_name=folder.folder_name,
|
||||
title=folder.title,
|
||||
year=folder.year,
|
||||
search_attempts=folder.search_attempts,
|
||||
search_suggestions=suggestions,
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/unresolved/{folder_name}", response_model=UnresolvedFolderResponse)
|
||||
async def get_unresolved_folder(
|
||||
folder_name: str,
|
||||
db=Depends(get_database_session),
|
||||
) -> UnresolvedFolderResponse:
|
||||
"""Get details for a specific unresolved folder.
|
||||
|
||||
Args:
|
||||
folder_name: URL-encoded folder name to look up
|
||||
|
||||
Returns:
|
||||
UnresolvedFolderResponse for the specified folder
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if folder not found or already resolved
|
||||
"""
|
||||
folder = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
|
||||
|
||||
if not folder:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Unresolved folder not found: {folder_name}"
|
||||
)
|
||||
|
||||
if folder.is_resolved:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Folder already resolved: {folder_name}"
|
||||
)
|
||||
|
||||
suggestions = []
|
||||
if folder.last_search_result:
|
||||
try:
|
||||
suggestions = json.loads(folder.last_search_result)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return UnresolvedFolderResponse(
|
||||
folder_name=folder.folder_name,
|
||||
title=folder.title,
|
||||
year=folder.year,
|
||||
search_attempts=folder.search_attempts,
|
||||
search_suggestions=suggestions,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/unresolved/{folder_name}/resolve", response_model=ResolveFolderResponse)
|
||||
async def resolve_unresolved_folder(
|
||||
folder_name: str,
|
||||
request: ResolveFolderRequest,
|
||||
db=Depends(get_database_session),
|
||||
) -> ResolveFolderResponse:
|
||||
"""Resolve an unresolved folder by providing the correct provider key.
|
||||
|
||||
This endpoint:
|
||||
1. Validates the provider key format
|
||||
2. Updates the UnresolvedFolder record as resolved
|
||||
3. Creates the AnimeSeries record in the database
|
||||
4. Returns the created series information
|
||||
|
||||
Args:
|
||||
folder_name: URL-encoded folder name to resolve
|
||||
request: ResolveFolderRequest with the provider_key
|
||||
|
||||
Returns:
|
||||
ResolveFolderResponse with created series details
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if folder not found
|
||||
HTTPException: 400 if key is invalid or series already exists
|
||||
"""
|
||||
# Check if folder exists and is unresolved
|
||||
unresolved = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
|
||||
|
||||
if not unresolved:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Unresolved folder not found: {folder_name}"
|
||||
)
|
||||
|
||||
if unresolved.is_resolved:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Folder already resolved: {folder_name}"
|
||||
)
|
||||
|
||||
# Check if a series with this key already exists
|
||||
existing_series = await AnimeSeriesService.get_by_key(db, request.provider_key)
|
||||
if existing_series:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Series with key '{request.provider_key}' already exists"
|
||||
)
|
||||
|
||||
# Mark as resolved
|
||||
await UnresolvedFolderService.resolve(db, folder_name, request.provider_key)
|
||||
|
||||
# Create the AnimeSeries record
|
||||
series = await AnimeSeriesService.create(
|
||||
db=db,
|
||||
key=request.provider_key,
|
||||
name=unresolved.title,
|
||||
site="https://aniworld.to",
|
||||
folder=folder_name,
|
||||
year=unresolved.year,
|
||||
loading_status="pending",
|
||||
episodes_loaded=False,
|
||||
logo_loaded=False,
|
||||
images_loaded=False,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Resolved unresolved folder via API: %s -> key=%s (series_id=%d)",
|
||||
folder_name, request.provider_key, series.id
|
||||
)
|
||||
|
||||
return ResolveFolderResponse(
|
||||
status="success",
|
||||
message=f"Successfully resolved and added series: {unresolved.title}",
|
||||
folder_name=folder_name,
|
||||
key=request.provider_key,
|
||||
series_id=series.id,
|
||||
)
|
||||
|
||||
|
||||
class SearchFolderRequest(BaseModel):
|
||||
"""Request model for searching an unresolved folder with custom query."""
|
||||
query: Optional[str] = Field(None, description="Custom search query override")
|
||||
|
||||
|
||||
@router.post("/unresolved/{folder_name}/search", response_model=UnresolvedFolderResponse)
|
||||
async def search_unresolved_folder(
|
||||
folder_name: str,
|
||||
request: Optional[SearchFolderRequest] = None,
|
||||
db=Depends(get_database_session),
|
||||
) -> UnresolvedFolderResponse:
|
||||
"""Re-search for a specific unresolved folder to get fresh suggestions.
|
||||
|
||||
Performs a new search using the folder's title or a custom query.
|
||||
Caches the results for subsequent display.
|
||||
|
||||
Args:
|
||||
folder_name: URL-encoded folder name to search for
|
||||
request: Optional SearchFolderRequest with custom query override
|
||||
|
||||
Returns:
|
||||
UnresolvedFolderResponse with updated search suggestions
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if folder not found or already resolved
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
folder = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
|
||||
|
||||
if not folder:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Unresolved folder not found: {folder_name}"
|
||||
)
|
||||
|
||||
if folder.is_resolved:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Folder already resolved: {folder_name}"
|
||||
)
|
||||
|
||||
# Use custom query if provided, otherwise fall back to folder title
|
||||
search_query = request.query if request and request.query else folder.title
|
||||
|
||||
# Perform search
|
||||
series_app = get_series_app()
|
||||
try:
|
||||
results = await series_app.search(search_query)
|
||||
search_result_json = json.dumps(results) if results else "[]"
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Search failed for unresolved folder: %s, error: %s",
|
||||
folder_name, str(e)
|
||||
)
|
||||
search_result_json = "[]"
|
||||
results = []
|
||||
|
||||
# Update the folder with new search results
|
||||
await UnresolvedFolderService.update_search_result(db, folder_name, search_result_json)
|
||||
|
||||
return UnresolvedFolderResponse(
|
||||
folder_name=folder.folder_name,
|
||||
title=folder.title,
|
||||
year=folder.year,
|
||||
search_attempts=folder.search_attempts + 1,
|
||||
search_suggestions=results,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/unresolved/{folder_name}")
|
||||
async def delete_unresolved_folder(
|
||||
folder_name: str,
|
||||
db=Depends(get_database_session),
|
||||
) -> dict[str, str]:
|
||||
"""Delete an unresolved folder tracking record.
|
||||
|
||||
Use this when you've manually added the series outside of this flow
|
||||
(e.g., via POST /api/anime/add) to clean up the unresolved tracker.
|
||||
|
||||
Args:
|
||||
folder_name: URL-encoded folder name to delete
|
||||
|
||||
Returns:
|
||||
Dict with status message
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if folder not found
|
||||
"""
|
||||
deleted = await UnresolvedFolderService.delete(db, folder_name)
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Unresolved folder not found: {folder_name}"
|
||||
)
|
||||
|
||||
return {"status": "success", "message": f"Deleted unresolved folder: {folder_name}"}
|
||||
|
||||
|
||||
class DoneResponse(BaseModel):
|
||||
"""Response model for completing unresolved folders."""
|
||||
status: str = Field(..., description="Operation status")
|
||||
message: str = Field(..., description="Human-readable message")
|
||||
count: int = Field(..., description="Number of folders marked as done")
|
||||
|
||||
|
||||
@router.post("/unresolved/done", response_model=DoneResponse)
|
||||
async def complete_unresolved_folders(
|
||||
db=Depends(get_database_session),
|
||||
) -> DoneResponse:
|
||||
"""Mark all unresolved folders as handled and complete the unresolved phase.
|
||||
|
||||
This endpoint:
|
||||
1. Marks the unresolved phase as completed in config
|
||||
2. Returns the count of folders that were handled
|
||||
|
||||
After this, /setup/unresolved will redirect to /loading.
|
||||
|
||||
Returns:
|
||||
DoneResponse with status and count of handled folders
|
||||
"""
|
||||
from src.server.services.config_service import get_config_service
|
||||
|
||||
# Get all unresolved folders
|
||||
folders = await UnresolvedFolderService.get_all_unresolved(db)
|
||||
count = len(folders)
|
||||
|
||||
# Mark unresolved as completed in config
|
||||
config_service = get_config_service()
|
||||
try:
|
||||
config = config_service.load_config()
|
||||
if config.other is None:
|
||||
config.other = {}
|
||||
config.other['unresolved_completed'] = True
|
||||
config_service.save_config(config, create_backup=False)
|
||||
logger.info("Marked unresolved phase as completed")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save unresolved_completed flag: %s", e)
|
||||
|
||||
logger.info(
|
||||
"Completed unresolved phase: %d folders handled",
|
||||
count
|
||||
)
|
||||
|
||||
return DoneResponse(
|
||||
status="success",
|
||||
message=f"Marked {count} folders as handled. Unresolved phase completed.",
|
||||
count=count,
|
||||
)
|
||||
|
||||
|
||||
class NfoScanPhaseResponse(BaseModel):
|
||||
"""Response model for NFO scan phase trigger."""
|
||||
status: str = Field(..., description="Status of the operation")
|
||||
message: str = Field(..., description="Human-readable message")
|
||||
|
||||
|
||||
@router.post("/nfo-scan-phase", response_model=NfoScanPhaseResponse)
|
||||
async def trigger_nfo_scan_phase() -> NfoScanPhaseResponse:
|
||||
"""Trigger the NFO scan phase.
|
||||
|
||||
This endpoint is called by the loading page when accessed with ?phase=nfo.
|
||||
It starts the NFO scan in the background and returns immediately.
|
||||
The loading page then connects via WebSocket to receive progress updates.
|
||||
|
||||
Returns:
|
||||
NfoScanPhaseResponse with status and message
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from src.server.services.initialization_service import perform_nfo_scan_phase
|
||||
from src.server.services.progress_service import get_progress_service
|
||||
|
||||
progress_service = get_progress_service()
|
||||
|
||||
async def run_nfo_scan():
|
||||
"""Run NFO scan phase with progress updates."""
|
||||
try:
|
||||
await perform_nfo_scan_phase(progress_service)
|
||||
logger.info("NFO scan phase completed via API trigger")
|
||||
except Exception as e:
|
||||
logger.error("NFO scan phase failed: %s", e, exc_info=True)
|
||||
if progress_service:
|
||||
await progress_service.fail_progress(
|
||||
progress_id="nfo_scan",
|
||||
error_message=f"NFO scan failed: {str(e)}",
|
||||
metadata={"step_id": "nfo_scan", "phase": "nfo"}
|
||||
)
|
||||
|
||||
# Start NFO scan in background
|
||||
asyncio.create_task(run_nfo_scan())
|
||||
|
||||
return NfoScanPhaseResponse(
|
||||
status="started",
|
||||
message="NFO scan phase started. Check progress via WebSocket."
|
||||
)
|
||||
@@ -13,8 +13,9 @@ in their data payload. The `folder` field is optional for display purposes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from typing import Dict, Optional, Set
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, status
|
||||
@@ -34,6 +35,73 @@ 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(
|
||||
@@ -130,6 +198,19 @@ 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)
|
||||
@@ -149,9 +230,26 @@ 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=client_msg.data.get("room", ""),
|
||||
room=room_name,
|
||||
)
|
||||
|
||||
if room_req.action == "join":
|
||||
@@ -241,7 +339,8 @@ async def websocket_endpoint(
|
||||
error=str(e),
|
||||
)
|
||||
finally:
|
||||
# Cleanup connection
|
||||
# Cleanup connection and rate limit record
|
||||
_cleanup_ws_rate_limits(connection_id)
|
||||
await ws_service.disconnect(connection_id)
|
||||
logger.info("WebSocket connection closed", connection_id=connection_id)
|
||||
|
||||
@@ -263,5 +362,6 @@ async def websocket_status(
|
||||
"status": "operational",
|
||||
"active_connections": connection_count,
|
||||
"supported_message_types": [t.value for t in WebSocketMessageType],
|
||||
"valid_rooms": sorted(VALID_ROOMS),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -39,7 +39,7 @@ def get_settings() -> Union[DevelopmentSettings, ProductionSettings]:
|
||||
Example:
|
||||
>>> settings = get_settings()
|
||||
>>> print(settings.log_level)
|
||||
DEBUG
|
||||
INFO
|
||||
"""
|
||||
if ENVIRONMENT in {"development", "testing"}:
|
||||
return get_development_settings()
|
||||
|
||||
@@ -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: DEBUG)
|
||||
LOG_LEVEL: Logging level (default: INFO)
|
||||
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="DEBUG", env="LOG_LEVEL")
|
||||
"""Logging level (DEBUG for detailed output)."""
|
||||
log_level: str = Field(default="INFO", env="LOG_LEVEL")
|
||||
"""Logging level (INFO for standard output)."""
|
||||
|
||||
log_file: str = Field(default="logs/development.log", env="LOG_FILE")
|
||||
"""Path to development log file."""
|
||||
@@ -215,7 +215,7 @@ class DevelopmentSettings(BaseSettings):
|
||||
@property
|
||||
def debug_enabled(self) -> bool:
|
||||
"""Check if debug mode is enabled."""
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def reload_enabled(self) -> bool:
|
||||
|
||||
@@ -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.DEBUG)
|
||||
server_file_handler.setLevel(logging.INFO)
|
||||
server_file_handler.setFormatter(detailed_format)
|
||||
root_logger.addHandler(server_file_handler)
|
||||
|
||||
@@ -95,10 +95,10 @@ def setup_logging() -> Dict[str, logging.Logger]:
|
||||
# Log initial setup
|
||||
root_logger.info("=" * 80)
|
||||
root_logger.info("FastAPI Server Logging Initialized")
|
||||
root_logger.info(f"Log Level: {settings.log_level.upper()}")
|
||||
root_logger.info(f"Server Log: {server_log_file.absolute()}")
|
||||
root_logger.info(f"Error Log: {error_log_file.absolute()}")
|
||||
root_logger.info(f"Access Log: {access_log_file.absolute()}")
|
||||
root_logger.info("Log Level: %s", settings.log_level.upper())
|
||||
root_logger.info("Server Log: %s", server_log_file.absolute())
|
||||
root_logger.info("Error Log: %s", error_log_file.absolute())
|
||||
root_logger.info("Access Log: %s", access_log_file.absolute())
|
||||
root_logger.info("=" * 80)
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
}
|
||||
@@ -49,3 +49,23 @@ async def queue_page(request: Request):
|
||||
request,
|
||||
title="Download Queue - Aniworld"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/loading", response_class=HTMLResponse)
|
||||
async def loading_page(request: Request):
|
||||
"""Serve the initialization loading page."""
|
||||
return render_template(
|
||||
"loading.html",
|
||||
request,
|
||||
title="Initializing - Aniworld"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/setup/unresolved", response_class=HTMLResponse)
|
||||
async def unresolved_page(request: Request):
|
||||
"""Serve the unresolved folders resolution page."""
|
||||
return render_template(
|
||||
"unresolved.html",
|
||||
request,
|
||||
title="Resolve Series - Aniworld"
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user