Compare commits

...

14 Commits

Author SHA1 Message Date
be87f2e230 chore: release v1.1.2 2026-05-17 18:31:59 +02:00
c56e0f507d fix(vpn): fix DNS iptables rules and add NET_RAW cap
DNS OUTPUT was restricted to -o wg0, but routing decision happens
after iptables OUTPUT — so DNS to VPN-internal addresses (198.18.0.x)
was blocked before the kernel selected the outgoing interface.
Allow DNS unconditionally; routing still sends it through wg0.

Add NET_RAW capability so ping works inside the container.
2026-05-17 18:31:38 +02:00
cb0a36ccc2 chore: release v1.1.1 2026-05-16 21:47:05 +02:00
3644b16447 feat(vpn): add version logging from VERSION file
- Read version from /etc/wireguard/VERSION instead of hardcoding
- Copy VERSION file into container image during build
- Update VERSION to v1.1.0
2026-05-16 21:46:19 +02:00
d5116e378e chore: release v0.1.0 2026-05-16 21:41:40 +02:00
50a7083ce5 fix(vpn): support AllowedIPs=0.0.0.0/0 and multi-DNS configs
- Parse AllowedIPs dynamically from WireGuard config instead of hardcoding routes
- Remove auto-created default route by wg setconf to prevent breaking endpoint connection
- Fix DNS parsing: write comma-separated DNS servers as separate nameserver lines
- Add test for AllowedIPs route verification and DNS configuration
- Update test to skip container runtime tests when not running as root
2026-05-16 21:41:27 +02:00
52c0ff2337 chore(docs): remove temporary planning file docs/bla 2026-05-16 21:22:44 +02:00
a5fd88e224 chore(vpn): update WireGuard endpoint and credentials
- Rotate to new VPN endpoint (91.148.236.64)
- Update private/public keys and client address
- Switch DNS to 198.18.0.1/0.2
- Add local network route preservation via PostUp/PostDown
- Align nl.conf and wg0.conf configurations
2026-05-16 21:22:04 +02:00
98d4edad14 feat(vpn): dynamic AllowedIPs routing and improved test coverage
- Parse AllowedIPs from WireGuard config in entrypoint.sh
- Add/remove routes dynamically instead of hardcoded 0.0.0.0/1 split
- Handle both 0.0.0.0/0 and custom AllowedIPs
- Add route cleanup on VPN stop (endpoint + AllowedIPs)
- Update test_vpn.py with AllowedIPs route verification
- Allow non-root build-only tests with automatic runtime skip
2026-05-16 21:21:56 +02:00
bc8059b453 feat(docker): add release script and enhance push script
- Add release.sh for automated versioning and image pushing
- Enhance push.sh with target selection (app/vpn/all)
- Add docker/podman engine auto-detection
- Improve usage docs and error handling
2026-05-16 21:21:45 +02:00
815a4f1520 chore: release v0.0.1 2026-05-16 21:20:20 +02:00
e3509f5c8f feat(scanner): add DB fallback for series key resolution
When SerieScanner encounters a folder without a local key or data file,
it now optionally falls back to a database lookup by folder name. This
prevents newly-added series from being silently skipped on rescan when
their metadata only lives in the DB.

Changes:
- SerieScanner accepts an optional db_lookup callable
- SeriesApp forwards db_lookup to SerieScanner
- AnimeSeriesService adds get_by_folder_sync() helper
- dependencies.py wires a sync DB lookup into get_series_app()
- Unit tests cover fallback hit, miss, and exception paths
2026-05-14 19:28:43 +02:00
69c2fd01f9 chore: bump version to 1.0.1 2026-05-14 17:30:13 +02:00
0f36afd88c refactor: move NFO repair from initialization_service to folder_scan_service
Moves perform_nfo_repair_scan and its helpers (_repair_one_series,
_NFO_REPAIR_SEMAPHORE) into folder_scan_service.py so NFO repair runs
during the scheduled folder scan instead of on startup.

- Removes NFO repair code from initialization_service.py
- Updates all test imports and patch targets
- Updates docs/NFO_GUIDE.md and docs/CHANGELOG.md references

All 174 related tests pass.
2026-05-14 17:01:01 +02:00
34 changed files with 764 additions and 200 deletions

View File

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

1
Docker/VERSION Normal file
View File

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

View File

@@ -1,6 +1,14 @@
#!/bin/bash #!/bin/bash
set -e set -e
VERSION_FILE="/etc/wireguard/VERSION"
if [ -f "$VERSION_FILE" ]; then
VERSION=$(cat "$VERSION_FILE")
else
VERSION="unknown"
fi
echo "[init] VPN Container Entrypoint ${VERSION}"
INTERFACE="wg0" INTERFACE="wg0"
MOUNT_CONFIG="/etc/wireguard/${INTERFACE}.conf" MOUNT_CONFIG="/etc/wireguard/${INTERFACE}.conf"
CONFIG_DIR="/run/wireguard" CONFIG_DIR="/run/wireguard"
@@ -64,9 +72,11 @@ setup_killswitch() {
iptables -A INPUT -i "$INTERFACE" -j ACCEPT iptables -A INPUT -i "$INTERFACE" -j ACCEPT
iptables -A OUTPUT -o "$INTERFACE" -j ACCEPT iptables -A OUTPUT -o "$INTERFACE" -j ACCEPT
# Allow DNS to the VPN DNS server (through wg0) # Allow DNS (VPN DNS servers are routed through wg0; allow before routing decision)
iptables -A OUTPUT -o "$INTERFACE" -p udp --dport 53 -j ACCEPT iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -o "$INTERFACE" -p tcp --dport 53 -j ACCEPT iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT
iptables -A INPUT -p udp --sport 53 -j ACCEPT
iptables -A INPUT -p tcp --sport 53 -j ACCEPT
# Allow DHCP (for container networking) # Allow DHCP (for container networking)
iptables -A OUTPUT -p udp --dport 67:68 -j ACCEPT iptables -A OUTPUT -p udp --dport 67:68 -j ACCEPT
@@ -120,7 +130,10 @@ start_vpn() {
ip link add "$INTERFACE" type wireguard ip link add "$INTERFACE" type wireguard
# Apply the WireGuard config (keys, peer, endpoint) # Apply the WireGuard config (keys, peer, endpoint)
wg setconf "$INTERFACE" <(grep -v -i '^\(Address\|DNS\|MTU\|Table\|PreUp\|PostUp\|PreDown\|PostDown\|SaveConfig\)' "$CONFIG_FILE") # We filter out Address/DNS/MTU/PreUp/PostUp/PreDown/PostDown/SaveConfig
# AllowedIPs is kept because WireGuard needs it to know which traffic to tunnel.
# We remove the auto-created default route afterwards and set our own.
wg setconf "$INTERFACE" <(grep -v -i '^\(Address\|DNS\|MTU\|PreUp\|PostUp\|PreDown\|PostDown\|SaveConfig\)' "$CONFIG_FILE")
# Assign the address # Assign the address
ip -4 address add "$VPN_ADDRESS" dev "$INTERFACE" ip -4 address add "$VPN_ADDRESS" dev "$INTERFACE"
@@ -128,6 +141,10 @@ start_vpn() {
# Set MTU # Set MTU
ip link set mtu 1420 up dev "$INTERFACE" ip link set mtu 1420 up dev "$INTERFACE"
# Remove the auto-created default route by wg setconf (if AllowedIPs = 0.0.0.0/0)
# We set our own routes manually to avoid breaking the endpoint connection
ip route del default dev "$INTERFACE" 2>/dev/null || true
# Find default gateway/interface for the endpoint route # Find default gateway/interface for the endpoint route
DEFAULT_GW=$(ip route | grep '^default' | head -1 | awk '{print $3}') DEFAULT_GW=$(ip route | grep '^default' | head -1 | awk '{print $3}')
DEFAULT_IF=$(ip route | grep '^default' | head -1 | awk '{print $5}') DEFAULT_IF=$(ip route | grep '^default' | head -1 | awk '{print $5}')
@@ -137,9 +154,21 @@ start_vpn() {
ip route add "$VPN_ENDPOINT/32" via "$DEFAULT_GW" dev "$DEFAULT_IF" 2>/dev/null || true ip route add "$VPN_ENDPOINT/32" via "$DEFAULT_GW" dev "$DEFAULT_IF" 2>/dev/null || true
fi fi
# Route all traffic through the WireGuard tunnel # Parse AllowedIPs from config and add routes dynamically
ip route add 0.0.0.0/1 dev "$INTERFACE" ALLOWED_IPS=$(grep -i '^AllowedIPs' "$CONFIG_FILE" | head -1 | sed 's/.*= *//;s/ //g')
ip route add 128.0.0.0/1 dev "$INTERFACE"
if [ -n "$ALLOWED_IPS" ]; then
for ip in $(echo "$ALLOWED_IPS" | tr ',' ' '); do
if [ "$ip" = "0.0.0.0/0" ]; then
# Use the split route trick to avoid overriding the default route
# (which would break the endpoint connection)
ip route add 0.0.0.0/1 dev "$INTERFACE" 2>/dev/null || true
ip route add 128.0.0.0/1 dev "$INTERFACE" 2>/dev/null || true
else
ip route add "$ip" dev "$INTERFACE" 2>/dev/null || true
fi
done
fi
# ── Policy routing: ensure responses to incoming LAN traffic go back via eth0 ── # ── Policy routing: ensure responses to incoming LAN traffic go back via eth0 ──
if [ -n "$DEFAULT_GW" ] && [ -n "$DEFAULT_IF" ]; then if [ -n "$DEFAULT_GW" ] && [ -n "$DEFAULT_IF" ]; then
@@ -155,11 +184,15 @@ start_vpn() {
fi fi
fi fi
# Set up DNS # Set up DNS (handle comma-separated DNS servers)
VPN_DNS=$(grep -i '^DNS' "$CONFIG_FILE" | head -1 | sed 's/.*= *//;s/ //g') VPN_DNS=$(grep -i '^DNS' "$CONFIG_FILE" | head -1 | sed 's/.*= *//;s/ //g')
if [ -n "$VPN_DNS" ]; then if [ -n "$VPN_DNS" ]; then
echo "nameserver $VPN_DNS" > /etc/resolv.conf # Clear resolv.conf and add each DNS server on its own line
echo "[vpn] DNS set to ${VPN_DNS}" > /etc/resolv.conf
for dns in $(echo "$VPN_DNS" | tr ',' ' '); do
echo "nameserver $dns" >> /etc/resolv.conf
done
echo "[vpn] DNS set to: ${VPN_DNS}"
fi fi
echo "[vpn] WireGuard interface ${INTERFACE} is up." echo "[vpn] WireGuard interface ${INTERFACE} is up."
@@ -170,6 +203,25 @@ start_vpn() {
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
stop_vpn() { stop_vpn() {
echo "[vpn] Stopping WireGuard interface ${INTERFACE}..." echo "[vpn] Stopping WireGuard interface ${INTERFACE}..."
# Remove routes added for AllowedIPs
ALLOWED_IPS=$(grep -i '^AllowedIPs' "$CONFIG_FILE" | head -1 | sed 's/.*= *//;s/ //g')
if [ -n "$ALLOWED_IPS" ]; then
for ip in $(echo "$ALLOWED_IPS" | tr ',' ' '); do
if [ "$ip" = "0.0.0.0/0" ]; then
ip route del 0.0.0.0/1 dev "$INTERFACE" 2>/dev/null || true
ip route del 128.0.0.0/1 dev "$INTERFACE" 2>/dev/null || true
else
ip route del "$ip" dev "$INTERFACE" 2>/dev/null || true
fi
done
fi
# Remove endpoint route
if [ -n "$VPN_ENDPOINT" ]; then
ip route del "$VPN_ENDPOINT/32" 2>/dev/null || true
fi
ip link del "$INTERFACE" 2>/dev/null || true ip link del "$INTERFACE" 2>/dev/null || true
} }

View File

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

View File

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

View File

@@ -1,15 +1,19 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# filepath: /home/lukas/Volume/repo/Aniworld/Docker/push.sh
# #
# Build and push Aniworld container images to the Gitea registry. # Build and push AniWorld container images to the Gitea registry.
# #
# Usage: # Usage:
# ./push.sh # builds & pushes with tag "latest" # ./push.sh # builds & pushes app with tag "latest"
# ./push.sh v1.2.3 # builds & pushes with tag "v1.2.3" # ./push.sh app # builds & pushes app image
# ./push.sh v1.2.3 --no-build # pushes existing images only # ./push.sh vpn # builds & pushes vpn image
# ./push.sh all # builds & pushes both images
# ./push.sh app v1.2.3 # builds & pushes app with tag "v1.2.3"
# ./push.sh vpn v1.2.3 # builds & pushes vpn with tag "v1.2.3"
# ./push.sh all v1.2.3 # builds & pushes both images
# ./push.sh app v1.2.3 --no-build # pushes existing image only
# #
# Prerequisites: # Prerequisites:
# podman login git.lpl-mind.de # podman login git.lpl-mind.de (or: docker login git.lpl-mind.de)
set -euo pipefail set -euo pipefail
@@ -23,12 +27,20 @@ PROJECT="aniworld"
APP_IMAGE="${REGISTRY}/${NAMESPACE}/${PROJECT}/app" APP_IMAGE="${REGISTRY}/${NAMESPACE}/${PROJECT}/app"
VPN_IMAGE="${REGISTRY}/${NAMESPACE}/${PROJECT}/vpn" VPN_IMAGE="${REGISTRY}/${NAMESPACE}/${PROJECT}/vpn"
TAG="${1:-latest}" # Parse arguments
TARGET="${1:-app}"
TAG="${2:-latest}"
SKIP_BUILD=false SKIP_BUILD=false
if [[ "${2:-}" == "--no-build" ]]; then if [[ "${3:-}" == "--no-build" ]]; then
SKIP_BUILD=true SKIP_BUILD=true
fi fi
# Validate target
if [[ "${TARGET}" != "app" && "${TARGET}" != "vpn" && "${TARGET}" != "all" ]]; then
echo "ERROR: Invalid target '${TARGET}'. Must be one of: app, vpn, all" >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
@@ -36,62 +48,93 @@ PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log() { echo -e "\n>>> $*"; } log() { echo -e "\n>>> $*"; }
err() { echo -e "\nERROR: $*" >&2; exit 1; } err() { echo -e "\nERROR: $*" >&2; exit 1; }
# Detect container engine (podman preferred, docker fallback)
if command -v podman &>/dev/null; then
ENGINE="podman"
elif command -v docker &>/dev/null; then
ENGINE="docker"
else
err "Neither podman nor docker is installed."
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Pre-flight checks # Pre-flight checks
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
echo "============================================" echo "============================================"
echo " Aniworld — Build & Push" echo " AniWorld — Build & Push"
echo " Engine : ${ENGINE}"
echo " Registry : ${REGISTRY}" echo " Registry : ${REGISTRY}"
echo " Target : ${TARGET}"
echo " Tag : ${TAG}" echo " Tag : ${TAG}"
echo "============================================" echo "============================================"
command -v podman &>/dev/null || err "podman is not installed." log "Logging in to ${REGISTRY}"
"${ENGINE}" login "${REGISTRY}"
if ! podman login --get-login "${REGISTRY}" &>/dev/null; then
err "Not logged in. Run:\n podman login ${REGISTRY}"
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Build # Build
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if [[ "${SKIP_BUILD}" == false ]]; then build_app() {
log "Building app image → ${APP_IMAGE}:${TAG}" log "Building app image → ${APP_IMAGE}:${TAG}"
podman build \ "${ENGINE}" build \
-t "${APP_IMAGE}:${TAG}" \ -t "${APP_IMAGE}:${TAG}" \
-f "${SCRIPT_DIR}/Dockerfile.app" \ -f "${SCRIPT_DIR}/Dockerfile.app" \
"${PROJECT_ROOT}" "${PROJECT_ROOT}"
}
log "Building VPN image → ${VPN_IMAGE}:${TAG}" build_vpn() {
podman build \ log "Building vpn image → ${VPN_IMAGE}:${TAG}"
"${ENGINE}" build \
-t "${VPN_IMAGE}:${TAG}" \ -t "${VPN_IMAGE}:${TAG}" \
-f "${SCRIPT_DIR}/Containerfile" \ -f "${SCRIPT_DIR}/Containerfile" \
"${SCRIPT_DIR}" "${SCRIPT_DIR}"
}
if [[ "${SKIP_BUILD}" == false ]]; then
case "${TARGET}" in
app) build_app ;;
vpn) build_vpn ;;
all) build_app; build_vpn ;;
esac
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Push # Push
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
push_app() {
log "Pushing ${APP_IMAGE}:${TAG}" log "Pushing ${APP_IMAGE}:${TAG}"
podman push "${APP_IMAGE}:${TAG}" "${ENGINE}" push "${APP_IMAGE}:${TAG}"
}
push_vpn() {
log "Pushing ${VPN_IMAGE}:${TAG}" log "Pushing ${VPN_IMAGE}:${TAG}"
podman push "${VPN_IMAGE}:${TAG}" "${ENGINE}" push "${VPN_IMAGE}:${TAG}"
}
case "${TARGET}" in
app) push_app ;;
vpn) push_vpn ;;
all) push_app; push_vpn ;;
esac
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Summary # Summary
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
echo "" echo ""
echo "============================================" echo "============================================"
echo " Push complete!" echo " Push complete!"
echo "" echo ""
echo " Images:" echo " Images:"
echo " ${APP_IMAGE}:${TAG}" case "${TARGET}" in
echo " ${VPN_IMAGE}:${TAG}" app) echo " ${APP_IMAGE}:${TAG}" ;;
vpn) echo " ${VPN_IMAGE}:${TAG}" ;;
all) echo " ${APP_IMAGE}:${TAG}"; echo " ${VPN_IMAGE}:${TAG}" ;;
esac
echo "" echo ""
echo " Deploy on server:" echo " Deploy on server:"
echo " podman login ${REGISTRY}" echo " ${ENGINE} login ${REGISTRY}"
echo " podman-compose -f podman-compose.prod.yml pull" echo " ${ENGINE} compose -f Docker/podman-compose.prod.yml pull"
echo " podman-compose -f podman-compose.prod.yml up -d" echo " ${ENGINE} compose -f Docker/podman-compose.prod.yml up -d"
echo "============================================" echo "============================================"

129
Docker/release.sh Normal file
View File

@@ -0,0 +1,129 @@
#!/usr/bin/env bash
#
# Bump the project version and push images to the registry.
#
# Usage:
# ./release.sh
#
# The current version is stored in VERSION (next to this script).
# You will be asked whether to bump major, minor, or patch.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VERSION_FILE="${SCRIPT_DIR}/VERSION"
# ---------------------------------------------------------------------------
# Read current version
# ---------------------------------------------------------------------------
if [[ ! -f "${VERSION_FILE}" ]]; then
echo "0.0.0" > "${VERSION_FILE}"
fi
CURRENT="$(cat "${VERSION_FILE}")"
# Strip leading 'v' for arithmetic
VERSION="${CURRENT#v}"
IFS='.' read -r MAJOR MINOR PATCH <<< "${VERSION}"
echo "============================================"
echo " AniWorld — Release"
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}"
echo "============================================"
echo ""
echo "Which image(s) would you like to release?"
echo " 1) app (Dockerfile.app)"
echo " 2) vpn (Containerfile)"
echo " 3) all (both images)"
echo ""
read -rp "Enter choice [1/2/3]: " TARGET_CHOICE
case "${TARGET_CHOICE}" in
1) TARGET="app" ;;
2) TARGET="vpn" ;;
3) TARGET="all" ;;
*)
echo "Invalid choice. Aborting." >&2
exit 1
;;
esac
echo ""
echo "How would you like to bump the version?"
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.$((PATCH + 1)))"
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.$((MINOR + 1)).0)"
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH} → v$((MAJOR + 1)).0.0)"
echo ""
read -rp "Enter choice [1/2/3]: " CHOICE
case "${CHOICE}" in
1) NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))" ;;
2) NEW_TAG="v${MAJOR}.$((MINOR + 1)).0" ;;
3) NEW_TAG="v$((MAJOR + 1)).0.0" ;;
*)
echo "Invalid choice. Aborting." >&2
exit 1
;;
esac
echo ""
echo "New version: ${NEW_TAG}"
echo "Target: ${TARGET}"
read -rp "Confirm? [y/N]: " CONFIRM
if [[ ! "${CONFIRM}" =~ ^[yY]$ ]]; then
echo "Aborted."
exit 0
fi
# ---------------------------------------------------------------------------
# Write new version
# ---------------------------------------------------------------------------
echo "${NEW_TAG}" > "${VERSION_FILE}"
echo "Version file updated → ${VERSION_FILE}"
# Keep root package.json in sync.
FRONT_VERSION="${NEW_TAG#v}"
FRONT_PKG="${SCRIPT_DIR}/../package.json"
if [[ -f "${FRONT_PKG}" ]]; then
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_PKG}"
echo "package.json version updated → ${FRONT_VERSION}"
else
echo "Warning: package.json not found, skipping package.json version sync" >&2
fi
# Keep root pyproject.toml in sync.
BACKEND_PYPROJECT="${SCRIPT_DIR}/../pyproject.toml"
if [[ -f "${BACKEND_PYPROJECT}" ]]; then
# Update version under [project] section if present
if grep -q '^\[project\]' "${BACKEND_PYPROJECT}"; then
sed -i "/^\[project\]/,/^\[/ s/^version = \".*\"/version = \"${FRONT_VERSION}\"/" "${BACKEND_PYPROJECT}"
else
sed -i "s/^version = \".*\"/version = \"${FRONT_VERSION}\"/" "${BACKEND_PYPROJECT}"
fi
echo "pyproject.toml version updated → ${FRONT_VERSION}"
else
echo "Warning: pyproject.toml not found, skipping pyproject.toml version sync" >&2
fi
# ---------------------------------------------------------------------------
# Push containers
# ---------------------------------------------------------------------------
bash "${SCRIPT_DIR}/push.sh" "${TARGET}" "${NEW_TAG}"
bash "${SCRIPT_DIR}/push.sh" "${TARGET}"
# ---------------------------------------------------------------------------
# Git tag (local only; push after container build)
# ---------------------------------------------------------------------------
cd "${SCRIPT_DIR}/.."
git add Docker/VERSION package.json pyproject.toml
git commit -m "chore: release ${NEW_TAG}"
git tag -a "${NEW_TAG}" -m "Release ${NEW_TAG}"
echo "Local git commit + tag ${NEW_TAG} created."
# ---------------------------------------------------------------------------
# Push git commits & tag
# ---------------------------------------------------------------------------
git push origin HEAD
git push origin "${NEW_TAG}"
echo "Git commit and tag ${NEW_TAG} pushed."

View File

@@ -6,23 +6,29 @@ Verifies:
2. The container starts and becomes healthy. 2. The container starts and becomes healthy.
3. The public IP inside the VPN differs from the host IP. 3. The public IP inside the VPN differs from the host IP.
4. Kill switch blocks traffic when WireGuard is down. 4. Kill switch blocks traffic when WireGuard is down.
5. AllowedIPs routes are set dynamically from the config.
Requirements: Requirements:
- podman installed - podman installed
- Root/sudo (NET_ADMIN capability) - Root/sudo (NET_ADMIN capability) for container runtime tests
- A valid WireGuard config at ./wg0.conf (or ./nl.conf) - A valid WireGuard config at ./wg0.conf (or ./nl.conf)
Usage: Usage:
# Build-only test (no sudo needed):
python3 -m pytest test_vpn.py::TestVPNImage::test_00_build_image -v
# Full integration test (requires sudo):
sudo python3 -m pytest test_vpn.py -v sudo python3 -m pytest test_vpn.py -v
# or # or
sudo python3 test_vpn.py sudo python3 test_vpn.py
""" """
import logging import logging
import os
import subprocess import subprocess
import sys
import time import time
import unittest import unittest
import os
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -35,6 +41,11 @@ STARTUP_TIMEOUT = 30 # seconds to wait for VPN to come up
HEALTH_POLL_INTERVAL = 2 # seconds between health checks HEALTH_POLL_INTERVAL = 2 # seconds between health checks
def is_root() -> bool:
"""Check if running as root."""
return os.geteuid() == 0
def run(cmd: list[str], timeout: int = 30, check: bool = True) -> subprocess.CompletedProcess: def run(cmd: list[str], timeout: int = 30, check: bool = True) -> subprocess.CompletedProcess:
"""Run a command and return the result.""" """Run a command and return the result."""
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=check) return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=check)
@@ -55,6 +66,7 @@ class TestVPNImage(unittest.TestCase):
"""Test suite for the WireGuard VPN container.""" """Test suite for the WireGuard VPN container."""
host_ip: str = "" host_ip: str = ""
container_id: str = ""
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@@ -84,6 +96,12 @@ class TestVPNImage(unittest.TestCase):
assert result.returncode == 0, f"Build failed:\n{result.stderr}" assert result.returncode == 0, f"Build failed:\n{result.stderr}"
logger.info("Image built successfully.") logger.info("Image built successfully.")
# Skip container runtime tests if not root
if not is_root():
logger.warning("Not running as root — skipping container runtime tests.")
cls.container_id = ""
return
# ── 3. Start the container ── # ── 3. Start the container ──
logger.info("Starting container '%s'...", CONTAINER_NAME) logger.info("Starting container '%s'...", CONTAINER_NAME)
result = run( result = run(
@@ -120,6 +138,8 @@ class TestVPNImage(unittest.TestCase):
@classmethod @classmethod
def tearDownClass(cls): def tearDownClass(cls):
"""Stop and remove the container.""" """Stop and remove the container."""
if not is_root():
return
logger.info("Cleaning up test container...") logger.info("Cleaning up test container...")
subprocess.run(["podman", "rm", "-f", CONTAINER_NAME], capture_output=True, check=False) subprocess.run(["podman", "rm", "-f", CONTAINER_NAME], capture_output=True, check=False)
logger.info("Cleanup complete.") logger.info("Cleanup complete.")
@@ -144,10 +164,22 @@ class TestVPNImage(unittest.TestCase):
) )
return result.stdout.strip() return result.stdout.strip()
def _skip_if_not_root(self):
"""Skip test if not running as root."""
if not is_root():
self.skipTest("This test requires root/sudo privileges")
# ── Tests ──────────────────────────────────────────────── # ── Tests ────────────────────────────────────────────────
def test_00_build_image(self):
"""The image builds successfully."""
# This is already verified in setUpClass, just confirm here
result = run(["podman", "images", "--format", "{{.Repository}}:{{.Tag}}"])
self.assertIn(IMAGE_NAME, result.stdout, "Image was not built")
def test_01_ip_differs_from_host(self): def test_01_ip_differs_from_host(self):
"""Public IP inside VPN is different from host IP.""" """Public IP inside VPN is different from host IP."""
self._skip_if_not_root()
vpn_ip = self._get_vpn_ip() vpn_ip = self._get_vpn_ip()
logger.info("VPN public IP: %s", vpn_ip) logger.info("VPN public IP: %s", vpn_ip)
logger.info("Host public IP: %s", self.host_ip) logger.info("Host public IP: %s", self.host_ip)
@@ -161,12 +193,42 @@ class TestVPNImage(unittest.TestCase):
def test_02_wireguard_interface_exists(self): def test_02_wireguard_interface_exists(self):
"""The wg0 interface is present in the container.""" """The wg0 interface is present in the container."""
self._skip_if_not_root()
result = podman_exec(CONTAINER_NAME, ["wg", "show", "wg0"]) result = podman_exec(CONTAINER_NAME, ["wg", "show", "wg0"])
self.assertEqual(result.returncode, 0, f"wg show failed:\n{result.stderr}") self.assertEqual(result.returncode, 0, f"wg show failed:\n{result.stderr}")
self.assertIn("peer", result.stdout.lower(), "No peer information in wg show output") self.assertIn("peer", result.stdout.lower(), "No peer information in wg show output")
# AllowedIPs should be present in wg show output
self.assertIn("allowed ips", result.stdout.lower(), "AllowedIPs not found in wg show output")
def test_03_kill_switch_blocks_traffic(self): def test_03_allowedips_routes_set(self):
"""Routes are set dynamically based on AllowedIPs from config."""
self._skip_if_not_root()
# Check that routes exist for the AllowedIPs
result = podman_exec(CONTAINER_NAME, ["ip", "route", "show", "dev", "wg0"])
self.assertEqual(result.returncode, 0, f"ip route show failed:\n{result.stderr}")
# The config has AllowedIPs = 0.0.0.0/0, which should result in:
# 0.0.0.0/1 dev wg0 and 128.0.0.0/1 dev wg0
self.assertIn("0.0.0.0/1", result.stdout, "Route 0.0.0.0/1 not found")
self.assertIn("128.0.0.0/1", result.stdout, "Route 128.0.0.0/1 not found")
# Make sure there is NO default route through wg0 (Table = off should prevent this)
self.assertNotIn("default dev wg0", result.stdout, "Default route through wg0 found — Table = off not working!")
logger.info("AllowedIPs routes verified: %s", result.stdout.strip())
def test_03b_dns_configured(self):
"""DNS is configured correctly with multiple nameserver lines."""
self._skip_if_not_root()
result = podman_exec(CONTAINER_NAME, ["cat", "/etc/resolv.conf"])
self.assertEqual(result.returncode, 0, f"cat /etc/resolv.conf failed:\n{result.stderr}")
# Should have two separate nameserver lines, not one with commas
self.assertIn("nameserver 198.18.0.1", result.stdout, "DNS 198.18.0.1 not found")
self.assertIn("nameserver 198.18.0.2", result.stdout, "DNS 198.18.0.2 not found")
# Make sure there are no commas in nameserver lines
self.assertNotIn("nameserver 198.18.0.1,198.18.0.2", result.stdout, "DNS servers written on one line with comma!")
logger.info("DNS config verified: %s", result.stdout.strip())
def test_04_kill_switch_blocks_traffic(self):
"""When WireGuard is down, traffic is blocked (kill switch).""" """When WireGuard is down, traffic is blocked (kill switch)."""
self._skip_if_not_root()
# Bring down the WireGuard interface by deleting it # Bring down the WireGuard interface by deleting it
down_result = podman_exec(CONTAINER_NAME, ["ip", "link", "del", "wg0"], timeout=10) down_result = podman_exec(CONTAINER_NAME, ["ip", "link", "del", "wg0"], timeout=10)
self.assertEqual(down_result.returncode, 0, f"ip link del wg0 failed:\n{down_result.stderr}") self.assertEqual(down_result.returncode, 0, f"ip link del wg0 failed:\n{down_result.stderr}")

View File

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

View File

@@ -1285,7 +1285,7 @@ Basic health check endpoint.
{ {
"status": "healthy", "status": "healthy",
"timestamp": "2025-12-13T10:30:00.000Z", "timestamp": "2025-12-13T10:30:00.000Z",
"version": "1.0.0" "version": "1.0.1"
} }
``` ```
@@ -1303,7 +1303,7 @@ Comprehensive health check with database, filesystem, and system metrics.
{ {
"status": "healthy", "status": "healthy",
"timestamp": "2025-12-13T10:30:00.000Z", "timestamp": "2025-12-13T10:30:00.000Z",
"version": "1.0.0", "version": "1.0.1",
"dependencies": { "dependencies": {
"database": { "database": {
"status": "healthy", "status": "healthy",

View File

@@ -74,7 +74,7 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
Provides `parse_nfo_tags()`, `find_missing_tags()`, `nfo_needs_repair()`, and Provides `parse_nfo_tags()`, `find_missing_tags()`, `nfo_needs_repair()`, and
`NfoRepairService.repair_series()`. 13 required tags are checked. `NfoRepairService.repair_series()`. 13 required tags are checked.
- **`perform_nfo_repair_scan()` - **`perform_nfo_repair_scan()`
(`src/server/services/initialization_service.py`)**: New async function (`src/server/services/folder_scan_service.py`)**: New async function
that iterates every series directory, checks whether `tvshow.nfo` is missing that iterates every series directory, checks whether `tvshow.nfo` is missing
required tags using `nfo_needs_repair()`, and queues the series for background required tags using `nfo_needs_repair()`, and queues the series for background
reload via `asyncio.create_task`. Skips gracefully when `tmdb_api_key` or reload via `asyncio.create_task`. Skips gracefully when `tmdb_api_key` or

View File

@@ -144,7 +144,7 @@ Location: `data/config.json`
"master_password_hash": "$pbkdf2-sha256$...", "master_password_hash": "$pbkdf2-sha256$...",
"anime_directory": "/path/to/anime" "anime_directory": "/path/to/anime"
}, },
"version": "1.0.0" "version": "1.0.1"
} }
``` ```

View File

@@ -815,8 +815,7 @@ This calls `NFOService.update_tvshow_nfo()` directly and overwrites the existing
| File | Purpose | | File | Purpose |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------- | | ----------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `src/core/services/nfo_repair_service.py` | `REQUIRED_TAGS`, `parse_nfo_tags`, `find_missing_tags`, `nfo_needs_repair`, `NfoRepairService` | | `src/core/services/nfo_repair_service.py` | `REQUIRED_TAGS`, `parse_nfo_tags`, `find_missing_tags`, `nfo_needs_repair`, `NfoRepairService` |
| `src/server/services/initialization_service.py` | `perform_nfo_repair_scan` — invoked from `FolderScanService` | | `src/server/services/folder_scan_service.py` | `perform_nfo_repair_scan` — invoked during the scheduled daily folder scan |
| `src/server/services/folder_scan_service.py` | Calls `perform_nfo_repair_scan` during the scheduled daily folder scan |
--- ---

View File

@@ -1,10 +0,0 @@
review frontend code and check for architektre issues
write the tasks in Task.md
for each task add the following informations
where is that found
goal. how it should be
possibale traps and issues
docs changes needed
why this is needed

View File

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

View File

@@ -15,7 +15,7 @@ import os
import re import re
import traceback import traceback
import uuid import uuid
from typing import Iterable, Iterator, Optional from typing import Callable, Iterable, Iterator, Optional
from events import Events from events import Events
@@ -43,12 +43,17 @@ class SerieScanner:
scanner = SerieScanner("/path/to/anime", loader) scanner = SerieScanner("/path/to/anime", loader)
scanner.scan() scanner.scan()
# Results are in scanner.keyDict # Results are in scanner.keyDict
# With DB lookup fallback:
scanner = SerieScanner("/path/to/anime", loader,
db_lookup=lambda folder: my_db.get_by_folder(folder))
""" """
def __init__( def __init__(
self, self,
basePath: str, basePath: str,
loader: Loader, loader: Loader,
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
) -> None: ) -> None:
""" """
Initialize the SerieScanner. Initialize the SerieScanner.
@@ -56,7 +61,11 @@ class SerieScanner:
Args: Args:
basePath: Base directory containing anime series basePath: Base directory containing anime series
loader: Loader instance for fetching series information loader: Loader instance for fetching series information
callback_manager: Optional callback manager for progress updates db_lookup: Optional callable ``(folder_name) -> Serie | None``.
When provided, it is called as a fallback when neither a
``key`` file nor a ``data`` file is found in the folder.
This allows the database to supply the series key for
folders that have never had a local key file.
Raises: Raises:
ValueError: If basePath is invalid or doesn't exist ValueError: If basePath is invalid or doesn't exist
@@ -75,6 +84,7 @@ class SerieScanner:
self.directory: str = abs_path self.directory: str = abs_path
self.keyDict: dict[str, Serie] = {} self.keyDict: dict[str, Serie] = {}
self.loader: Loader = loader self.loader: Loader = loader
self._db_lookup: Optional[Callable[[str], Optional[Serie]]] = db_lookup
self._current_operation_id: Optional[str] = None self._current_operation_id: Optional[str] = None
self.events = Events() self.events = Events()
@@ -268,6 +278,30 @@ class SerieScanner:
) )
serie = self.__read_data_from_file(folder) serie = self.__read_data_from_file(folder)
if serie is None or not serie.key or not serie.key.strip():
# Fallback: ask the database for a matching series
if self._db_lookup is not None:
try:
serie = self._db_lookup(folder)
if serie:
logger.info(
"DB lookup resolved folder '%s' -> key='%s'",
folder,
serie.key,
)
except Exception as exc:
logger.warning(
"DB lookup failed for folder '%s': %s",
folder,
exc,
)
serie = None
if serie is None or not serie.key or not serie.key.strip():
logger.warning(
"No key or data file found for folder '%s', skipping",
folder,
)
if ( if (
serie is not None serie is not None
and serie.key and serie.key

View File

@@ -14,7 +14,7 @@ import asyncio
import logging import logging
import os import os
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional
from events import Events from events import Events
@@ -143,12 +143,16 @@ class SeriesApp:
def __init__( def __init__(
self, self,
directory_to_search: str, directory_to_search: str,
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
): ):
""" """
Initialize SeriesApp. Initialize SeriesApp.
Args: Args:
directory_to_search: Base directory for anime series directory_to_search: Base directory for anime series
db_lookup: Optional callable ``(folder_name) -> Serie | None``
passed through to ``SerieScanner`` as a fallback key source
when no local ``key`` or ``data`` file exists.
""" """
self.directory_to_search = directory_to_search self.directory_to_search = directory_to_search
@@ -162,7 +166,7 @@ class SeriesApp:
self.loaders = Loaders() self.loaders = Loaders()
self.loader = self.loaders.GetLoader(key="aniworld.to") self.loader = self.loaders.GetLoader(key="aniworld.to")
self.serie_scanner = SerieScanner( self.serie_scanner = SerieScanner(
directory_to_search, self.loader directory_to_search, self.loader, db_lookup=db_lookup
) )
# Skip automatic loading from data files - series will be loaded # Skip automatic loading from data files - series will be loaded
# from database by the service layer during application setup # from database by the service layer during application setup

View File

@@ -22,7 +22,7 @@ class HealthStatus(BaseModel):
status: str status: str
timestamp: str timestamp: str
version: str = "1.0.0" version: str = "1.0.1"
service: str = "aniworld-api" service: str = "aniworld-api"
series_app_initialized: bool = False series_app_initialized: bool = False
anime_directory_configured: bool = False anime_directory_configured: bool = False
@@ -60,7 +60,7 @@ class DetailedHealthStatus(BaseModel):
status: str status: str
timestamp: str timestamp: str
version: str = "1.0.0" version: str = "1.0.1"
dependencies: DependencyHealth dependencies: DependencyHealth
startup_time: datetime startup_time: datetime

View File

@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
# Schema Version Constants # Schema Version Constants
# ============================================================================= # =============================================================================
CURRENT_SCHEMA_VERSION = "1.0.0" CURRENT_SCHEMA_VERSION = "1.0.1"
SCHEMA_VERSION_TABLE = "schema_version" SCHEMA_VERSION_TABLE = "schema_version"
# Expected tables in the current schema # Expected tables in the current schema
@@ -319,7 +319,7 @@ async def get_schema_version(engine: Optional[AsyncEngine] = None) -> str:
engine: Optional database engine (uses default if not provided) engine: Optional database engine (uses default if not provided)
Returns: Returns:
Schema version string (e.g., "1.0.0", "empty", "unknown") Schema version string (e.g., "1.0.1", "empty", "unknown")
""" """
if engine is None: if engine is None:
engine = get_engine() engine = get_engine()

View File

@@ -149,6 +149,26 @@ class AnimeSeriesService:
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
@staticmethod
def get_by_folder_sync(db: Session, folder: str) -> Optional[AnimeSeries]:
"""Look up an anime series by its filesystem folder name (sync).
Intended as a fallback for ``SerieScanner`` when neither a ``key``
file nor a ``data`` file exists on disk for a given folder.
Args:
db: Synchronous database session (from ``get_sync_session``).
folder: Filesystem folder name to match (e.g.
``"Rooster Fighter (2026)"``).
Returns:
``AnimeSeries`` instance or ``None`` if not found.
"""
result = db.execute(
select(AnimeSeries).where(AnimeSeries.folder == folder)
)
return result.scalar_one_or_none()
@staticmethod @staticmethod
async def get_all( async def get_all(
db: AsyncSession, db: AsyncSession,

View File

@@ -480,7 +480,7 @@ async def lifespan(_application: FastAPI):
app = FastAPI( app = FastAPI(
title="Aniworld Download Manager", title="Aniworld Download Manager",
description="Modern web interface for Aniworld anime download management", description="Modern web interface for Aniworld anime download management",
version="1.0.0", version="1.0.1",
docs_url="/api/docs", docs_url="/api/docs",
redoc_url="/api/redoc", redoc_url="/api/redoc",
lifespan=lifespan lifespan=lifespan

View File

@@ -44,7 +44,7 @@ class ConfigService:
""" """
# Current configuration schema version # Current configuration schema version
CONFIG_VERSION = "1.0.0" CONFIG_VERSION = "1.0.1"
def __init__( def __init__(
self, self,

View File

@@ -13,8 +13,8 @@ from typing import Optional
import structlog import structlog
from lxml import etree from lxml import etree
from src.config.settings import settings as _settings
from src.core.utils.image_downloader import ImageDownloader from src.core.utils.image_downloader import ImageDownloader
from src.server.services.initialization_service import perform_nfo_repair_scan
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@@ -24,6 +24,101 @@ _TMDB_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
# Semaphore to limit concurrent poster image downloads to 3. # Semaphore to limit concurrent poster image downloads to 3.
_POSTER_DOWNLOAD_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3) _POSTER_DOWNLOAD_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
# Semaphore to limit concurrent NFO repair TMDB operations to 3.
_NFO_REPAIR_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
async def _repair_one_series(series_dir: Path, series_name: str) -> None:
"""Repair a single series NFO in isolation.
Creates a fresh :class:`NFOService` and :class:`NfoRepairService` per
invocation so that each repair owns its own ``aiohttp`` session/connector
and concurrent tasks cannot interfere with each other.
A module-level semaphore (``_NFO_REPAIR_SEMAPHORE``) limits the number of
simultaneous TMDB requests to avoid rate-limiting.
Any exception is caught and logged so the asyncio task never silently
drops an unhandled error.
Args:
series_dir: Absolute path to the series folder.
series_name: Human-readable series name for log messages.
"""
from src.core.services.nfo_factory import NFOServiceFactory
from src.core.services.nfo_repair_service import NfoRepairService
async with _NFO_REPAIR_SEMAPHORE:
try:
factory = NFOServiceFactory()
nfo_service = factory.create()
repair_service = NfoRepairService(nfo_service)
await repair_service.repair_series(series_dir, series_name)
except Exception as exc: # pylint: disable=broad-except
logger.error(
"NFO repair failed for %s: %s",
series_name,
exc,
)
async def perform_nfo_repair_scan(background_loader=None) -> None:
"""Scan all series folders and repair incomplete tvshow.nfo files.
Called from ``FolderScanService.run_folder_scan()`` during the scheduled
daily folder scan (not on every startup). Checks each subfolder of
``settings.anime_directory`` for a ``tvshow.nfo`` and calls
``_repair_one_series`` for every file with absent or empty required tags.
Each repair task creates its own isolated :class:`NFOService` /
:class:`TMDBClient` so concurrent tasks never share an ``aiohttp``
session — this prevents "Connector is closed" errors when many repairs
run in parallel. A semaphore caps TMDB concurrency at 3 to stay within
rate limits.
The ``background_loader`` parameter is accepted for backwards-compatibility
but is no longer used.
Args:
background_loader: Unused. Kept to avoid breaking call-sites.
"""
from src.core.services.nfo_repair_service import nfo_needs_repair
if not _settings.tmdb_api_key:
logger.warning("NFO repair scan skipped — TMDB API key not configured")
return
if not _settings.anime_directory:
logger.warning("NFO repair scan skipped — anime directory not configured")
return
anime_dir = Path(_settings.anime_directory)
if not anime_dir.is_dir():
logger.warning("NFO repair scan skipped — anime directory not found: %s", anime_dir)
return
queued = 0
total = 0
for series_dir in sorted(anime_dir.iterdir()):
if not series_dir.is_dir():
continue
nfo_path = series_dir / "tvshow.nfo"
if not nfo_path.exists():
continue
total += 1
series_name = series_dir.name
if nfo_needs_repair(nfo_path):
queued += 1
# Each task creates its own NFOService so connectors are isolated.
asyncio.create_task(
_repair_one_series(series_dir, series_name),
name=f"nfo_repair:{series_name}",
)
logger.info(
"NFO repair scan complete: %d of %d series queued for repair",
queued,
total,
)
class FolderScanServiceError(Exception): class FolderScanServiceError(Exception):
"""Service-level exception for folder-scan operations.""" """Service-level exception for folder-scan operations."""

View File

@@ -377,101 +377,6 @@ async def perform_nfo_scan_if_needed(progress_service=None):
) )
_NFO_REPAIR_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
async def _repair_one_series(series_dir: Path, series_name: str) -> None:
"""Repair a single series NFO in isolation.
Creates a fresh :class:`NFOService` and :class:`NfoRepairService` per
invocation so that each repair owns its own ``aiohttp`` session/connector
and concurrent tasks cannot interfere with each other.
A module-level semaphore (``_NFO_REPAIR_SEMAPHORE``) limits the number of
simultaneous TMDB requests to avoid rate-limiting.
Any exception is caught and logged so the asyncio task never silently
drops an unhandled error.
Args:
series_dir: Absolute path to the series folder.
series_name: Human-readable series name for log messages.
"""
from src.core.services.nfo_factory import NFOServiceFactory
from src.core.services.nfo_repair_service import NfoRepairService
async with _NFO_REPAIR_SEMAPHORE:
try:
factory = NFOServiceFactory()
nfo_service = factory.create()
repair_service = NfoRepairService(nfo_service)
await repair_service.repair_series(series_dir, series_name)
except Exception as exc: # pylint: disable=broad-except
logger.error(
"NFO repair failed for %s: %s",
series_name,
exc,
)
async def perform_nfo_repair_scan(background_loader=None) -> None:
"""Scan all series folders and repair incomplete tvshow.nfo files.
Called from ``FolderScanService.run_folder_scan()`` during the scheduled
daily folder scan (not on every startup). Checks each subfolder of
``settings.anime_directory`` for a ``tvshow.nfo`` and calls
``_repair_one_series`` for every file with absent or empty required tags.
Each repair task creates its own isolated :class:`NFOService` /
:class:`TMDBClient` so concurrent tasks never share an ``aiohttp``
session — this prevents "Connector is closed" errors when many repairs
run in parallel. A semaphore caps TMDB concurrency at 3 to stay within
rate limits.
The ``background_loader`` parameter is accepted for backwards-compatibility
but is no longer used.
Args:
background_loader: Unused. Kept to avoid breaking call-sites.
"""
from src.core.services.nfo_repair_service import nfo_needs_repair
if not settings.tmdb_api_key:
logger.warning("NFO repair scan skipped — TMDB API key not configured")
return
if not settings.anime_directory:
logger.warning("NFO repair scan skipped — anime directory not configured")
return
anime_dir = Path(settings.anime_directory)
if not anime_dir.is_dir():
logger.warning("NFO repair scan skipped — anime directory not found: %s", anime_dir)
return
queued = 0
total = 0
for series_dir in sorted(anime_dir.iterdir()):
if not series_dir.is_dir():
continue
nfo_path = series_dir / "tvshow.nfo"
if not nfo_path.exists():
continue
total += 1
series_name = series_dir.name
if nfo_needs_repair(nfo_path):
queued += 1
# Each task creates its own NFOService so connectors are isolated.
asyncio.create_task(
_repair_one_series(series_dir, series_name),
name=f"nfo_repair:{series_name}",
)
logger.info(
"NFO repair scan complete: %d of %d series queued for repair",
queued,
total,
)
async def _check_media_scan_status() -> bool: async def _check_media_scan_status() -> bool:
"""Check if initial media scan has been completed. """Check if initial media scan has been completed.

View File

@@ -57,6 +57,44 @@ _rate_limit_lock = Lock()
_RATE_LIMIT_WINDOW_SECONDS = 60.0 _RATE_LIMIT_WINDOW_SECONDS = 60.0
def _make_db_lookup():
"""Build a synchronous ``(folder) -> Serie | None`` callable for SerieScanner.
The returned function opens a short-lived sync DB session, queries for a
series whose ``folder`` column matches the given name, and converts the
ORM row to a ``Serie`` domain object. Returns ``None`` when the DB is not
yet initialised or no matching row is found.
"""
from src.core.entities.series import Serie
def _lookup(folder: str) -> Optional["Serie"]:
try:
from src.server.database.connection import get_sync_session
from src.server.database.service import AnimeSeriesService
db = get_sync_session()
try:
row = AnimeSeriesService.get_by_folder_sync(db, folder)
finally:
db.close()
if row is None:
return None
return Serie(
key=row.key,
name=row.name or "",
site=row.site,
folder=row.folder,
episodeDict={},
year=row.year,
)
except RuntimeError:
# DB not initialised yet (e.g. first boot before init_db())
return None
return _lookup
def get_series_app() -> SeriesApp: def get_series_app() -> SeriesApp:
""" """
Dependency to get SeriesApp instance. Dependency to get SeriesApp instance.
@@ -134,7 +172,7 @@ def get_series_app() -> SeriesApp:
), ),
) )
_series_app = SeriesApp(anime_dir) _series_app = SeriesApp(anime_dir, db_lookup=_make_db_lookup())
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,

View File

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

View File

@@ -67,7 +67,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_incomplete_nfo_series_scheduled_for_repair(self, tmp_path): async def test_incomplete_nfo_series_scheduled_for_repair(self, tmp_path):
"""Series whose tvshow.nfo is missing required tags are scheduled via asyncio.create_task.""" """Series whose tvshow.nfo is missing required tags are scheduled via asyncio.create_task."""
from src.server.services.initialization_service import perform_nfo_repair_scan from src.server.services.folder_scan_service import perform_nfo_repair_scan
series_dir = tmp_path / "IncompleteAnime" series_dir = tmp_path / "IncompleteAnime"
series_dir.mkdir() series_dir.mkdir()
@@ -83,7 +83,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
mock_repair_service.repair_series = AsyncMock(return_value=True) mock_repair_service.repair_series = AsyncMock(return_value=True)
with patch( with patch(
"src.server.services.initialization_service.settings", mock_settings "src.server.services.folder_scan_service._settings", mock_settings
), patch( ), patch(
"src.core.services.nfo_repair_service.nfo_needs_repair", "src.core.services.nfo_repair_service.nfo_needs_repair",
return_value=True, return_value=True,
@@ -103,7 +103,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_complete_nfo_series_not_scheduled(self, tmp_path): async def test_complete_nfo_series_not_scheduled(self, tmp_path):
"""Series whose tvshow.nfo has all required tags are not scheduled for repair.""" """Series whose tvshow.nfo has all required tags are not scheduled for repair."""
from src.server.services.initialization_service import perform_nfo_repair_scan from src.server.services.folder_scan_service import perform_nfo_repair_scan
series_dir = tmp_path / "CompleteAnime" series_dir = tmp_path / "CompleteAnime"
series_dir.mkdir() series_dir.mkdir()
@@ -116,7 +116,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
mock_settings.anime_directory = str(tmp_path) mock_settings.anime_directory = str(tmp_path)
with patch( with patch(
"src.server.services.initialization_service.settings", mock_settings "src.server.services.folder_scan_service._settings", mock_settings
), patch( ), patch(
"src.core.services.nfo_repair_service.nfo_needs_repair", "src.core.services.nfo_repair_service.nfo_needs_repair",
return_value=False, return_value=False,

View File

@@ -472,7 +472,7 @@ async def test_validate_schema_with_inspection_error():
def test_schema_constants(): def test_schema_constants():
"""Test that schema constants are properly defined.""" """Test that schema constants are properly defined."""
assert CURRENT_SCHEMA_VERSION == "1.0.0" assert CURRENT_SCHEMA_VERSION == "1.0.1"
assert len(EXPECTED_TABLES) == 5 assert len(EXPECTED_TABLES) == 5
assert "anime_series" in EXPECTED_TABLES assert "anime_series" in EXPECTED_TABLES
assert "episodes" in EXPECTED_TABLES assert "episodes" in EXPECTED_TABLES

View File

@@ -20,6 +20,7 @@ from src.server.services.folder_scan_service import (
_TMDB_SEMAPHORE, _TMDB_SEMAPHORE,
FolderScanService, FolderScanService,
FolderScanServiceError, FolderScanServiceError,
perform_nfo_repair_scan,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

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

View File

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

View File

@@ -187,7 +187,7 @@ class TestTemplateHelpers:
assert context["request"] == mock_request assert context["request"] == mock_request
assert context["title"] == "Test Title" assert context["title"] == "Test Title"
assert context["app_name"] == "Aniworld Download Manager" assert context["app_name"] == "Aniworld Download Manager"
assert context["version"] == "1.0.0" assert context["version"] == "1.0.1"
def test_get_base_context_default_title(self): def test_get_base_context_default_title(self):
"""Test getting base context with default title.""" """Test getting base context with default title."""

View File

@@ -1,5 +1,6 @@
"""Tests for SerieScanner class - file-based operations.""" """Tests for SerieScanner class - file-based operations."""
import logging
import os import os
import tempfile import tempfile
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -652,3 +653,186 @@ class TestScanProgressEvents:
error_handler.assert_called_once() error_handler.assert_called_once()
call_data = error_handler.call_args[0][0] call_data = error_handler.call_args[0][0]
assert call_data["recoverable"] is True assert call_data["recoverable"] is True
class TestDbLookupFallback:
"""Tests for the db_lookup callback in SerieScanner."""
def _make_scanner(self, tmp_dir, mock_loader, db_lookup=None):
"""Create a scanner with an optional db_lookup."""
# Create a folder with an mp4 but NO key/data file
folder = os.path.join(tmp_dir, "Rooster Fighter (2026)")
os.makedirs(folder, exist_ok=True)
mp4 = os.path.join(folder, "Rooster Fighter - S01E001 - (German Dub).mp4")
with open(mp4, "w") as f:
f.write("dummy")
return SerieScanner(tmp_dir, mock_loader, db_lookup=db_lookup)
def test_db_lookup_stored_on_init(self, temp_directory, mock_loader):
"""db_lookup callable should be stored as _db_lookup."""
lookup = MagicMock(return_value=None)
scanner = SerieScanner(temp_directory, mock_loader, db_lookup=lookup)
assert scanner._db_lookup is lookup
def test_no_db_lookup_defaults_to_none(self, temp_directory, mock_loader):
"""Without db_lookup, _db_lookup should be None."""
scanner = SerieScanner(temp_directory, mock_loader)
assert scanner._db_lookup is None
def test_db_lookup_called_when_no_files(self, mock_loader):
"""db_lookup is called when neither key nor data file exists."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
lookup = MagicMock(return_value=None)
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
with patch.object(scanner, 'get_total_to_scan', return_value=1), \
patch.object(
scanner,
'_SerieScanner__get_missing_episodes_and_season',
return_value=({}, "aniworld.to"),
):
scanner.scan()
lookup.assert_called_once_with("Rooster Fighter (2026)")
def test_db_lookup_not_called_when_key_file_exists(self, mock_loader):
"""db_lookup is NOT called when a key file is present."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
folder = os.path.join(tmp_dir, "Rooster Fighter (2026)")
os.makedirs(folder, exist_ok=True)
mp4 = os.path.join(folder, "S01E001.mp4")
with open(mp4, "w") as f:
f.write("dummy")
with open(os.path.join(folder, "key"), "w") as f:
f.write("rooster-fighter")
lookup = MagicMock(return_value=None)
scanner = SerieScanner(tmp_dir, mock_loader, db_lookup=lookup)
with patch.object(scanner, 'get_total_to_scan', return_value=1), \
patch.object(
scanner,
'_SerieScanner__get_missing_episodes_and_season',
return_value=({1: []}, "aniworld.to"),
), \
patch.object(
SerieScanner,
'_SerieScanner__read_data_from_file',
return_value=Serie(
key="rooster-fighter", name="", site="aniworld.to",
folder="Rooster Fighter (2026)", episodeDict={},
),
):
scanner.scan()
lookup.assert_not_called()
def test_db_lookup_resolves_serie_and_scans(self, mock_loader):
"""When db_lookup returns a Serie, scanning continues normally."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
resolved = Serie(
key="rooster-fighter",
name="Rooster Fighter",
site="aniworld.to",
folder="Rooster Fighter (2026)",
episodeDict={},
year=2026,
)
lookup = MagicMock(return_value=resolved)
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
with patch.object(scanner, 'get_total_to_scan', return_value=1), \
patch.object(
scanner,
'_SerieScanner__get_missing_episodes_and_season',
return_value=({1: [1, 2, 3]}, "aniworld.to"),
), \
patch.object(resolved, 'save_to_file'):
scanner.scan()
assert "rooster-fighter" in scanner.keyDict
assert scanner.keyDict["rooster-fighter"].episodeDict == {1: [1, 2, 3]}
def test_db_lookup_returns_none_folder_skipped(self, mock_loader):
"""When db_lookup returns None, the folder is skipped with a warning."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
lookup = MagicMock(return_value=None)
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
with patch.object(scanner, 'get_total_to_scan', return_value=1):
scanner.scan()
assert len(scanner.keyDict) == 0
def test_db_lookup_exception_skips_folder(self, mock_loader):
"""When db_lookup raises, the folder is skipped gracefully."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
lookup = MagicMock(side_effect=RuntimeError("DB offline"))
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
with patch.object(scanner, 'get_total_to_scan', return_value=1):
scanner.scan() # should not raise
assert len(scanner.keyDict) == 0
def test_db_lookup_warning_logged_when_no_files(
self, mock_loader, caplog
):
"""A warning is logged for folders without key/data file."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=None)
with caplog.at_level(logging.WARNING, logger="src.core.SerieScanner"):
with patch.object(scanner, 'get_total_to_scan', return_value=1):
scanner.scan()
assert any(
"Rooster Fighter (2026)" in record.message
for record in caplog.records
if record.levelname == "WARNING"
)
def test_db_lookup_info_logged_on_resolution(
self, mock_loader, caplog
):
"""An INFO log is emitted when db_lookup resolves a folder."""
import tempfile
with tempfile.TemporaryDirectory() as tmp_dir:
resolved = Serie(
key="rooster-fighter",
name="",
site="aniworld.to",
folder="Rooster Fighter (2026)",
episodeDict={},
)
lookup = MagicMock(return_value=resolved)
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
with caplog.at_level(logging.INFO, logger="src.core.SerieScanner"), \
patch.object(scanner, 'get_total_to_scan', return_value=1), \
patch.object(
scanner,
'_SerieScanner__get_missing_episodes_and_season',
return_value=({}, "aniworld.to"),
), \
patch.object(resolved, 'save_to_file'):
scanner.scan()
assert any(
"rooster-fighter" in record.message
for record in caplog.records
if record.levelname == "INFO"
)

View File

@@ -30,7 +30,7 @@ class TestTemplateHelpers:
assert context["request"] == request assert context["request"] == request
assert context["title"] == "Test Title" assert context["title"] == "Test Title"
assert context["app_name"] == "Aniworld Download Manager" assert context["app_name"] == "Aniworld Download Manager"
assert context["version"] == "1.0.0" assert context["version"] == "1.0.1"
def test_get_base_context_default_title(self): def test_get_base_context_default_title(self):
"""Test that default title is used.""" """Test that default title is used."""