Compare commits
17 Commits
ceac22fc34
...
v1.1.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 54ac5e9ab7 | |||
| c93ac3e7b8 | |||
| 68c4335348 | |||
| be87f2e230 | |||
| c56e0f507d | |||
| cb0a36ccc2 | |||
| 3644b16447 | |||
| d5116e378e | |||
| 50a7083ce5 | |||
| 52c0ff2337 | |||
| a5fd88e224 | |||
| 98d4edad14 | |||
| bc8059b453 | |||
| 815a4f1520 | |||
| e3509f5c8f | |||
| 69c2fd01f9 | |||
| 0f36afd88c |
@@ -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
1
Docker/VERSION
Normal file
@@ -0,0 +1 @@
|
|||||||
|
v1.1.4
|
||||||
@@ -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,15 @@ 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")
|
||||||
|
|
||||||
|
# Log public key so it can be verified against the server's peer list
|
||||||
|
local PUBKEY
|
||||||
|
PUBKEY=$(wg show "$INTERFACE" public-key 2>/dev/null || echo "unknown")
|
||||||
|
echo "[vpn] Public key: ${PUBKEY}"
|
||||||
|
|
||||||
# Assign the address
|
# Assign the address
|
||||||
ip -4 address add "$VPN_ADDRESS" dev "$INTERFACE"
|
ip -4 address add "$VPN_ADDRESS" dev "$INTERFACE"
|
||||||
@@ -128,6 +146,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 +159,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,14 +189,22 @@ 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."
|
||||||
|
echo "[vpn] Routes:"
|
||||||
|
ip route show | sed 's/^/[vpn] /'
|
||||||
|
echo "[vpn] WireGuard status:"
|
||||||
|
wg show "$INTERFACE" 2>/dev/null | sed 's/^/[vpn] /'
|
||||||
}
|
}
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
@@ -170,6 +212,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +253,12 @@ health_loop() {
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
failures=$((failures + 1))
|
failures=$((failures + 1))
|
||||||
echo "[health] Ping failed ($failures/$max_failures)"
|
echo "[health] Check failed ($failures/$max_failures) — curl http://${CHECK_HOST} timed out"
|
||||||
|
# Dump WireGuard stats to show if handshake is stale and how much data flows
|
||||||
|
echo "[health] wg stats:"
|
||||||
|
wg show "$INTERFACE" 2>/dev/null | grep -E 'latest handshake|transfer|endpoint' | sed 's/^/[health] /' || echo "[health] wg0 not found"
|
||||||
|
echo "[health] routes:"
|
||||||
|
ip route show | grep -E 'wg0|default' | sed 's/^/[health] /'
|
||||||
|
|
||||||
if [ "$failures" -ge "$max_failures" ]; then
|
if [ "$failures" -ge "$max_failures" ]; then
|
||||||
echo "[health] VPN appears down. Restarting WireGuard..."
|
echo "[health] VPN appears down. Restarting WireGuard..."
|
||||||
@@ -221,8 +287,81 @@ cleanup() {
|
|||||||
|
|
||||||
trap cleanup SIGTERM SIGINT
|
trap cleanup SIGTERM SIGINT
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Startup connectivity checks — diagnose issues early
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
check_vpn_connectivity() {
|
||||||
|
echo "[check] ── Startup connectivity checks ──"
|
||||||
|
|
||||||
|
# 1. Wait for WireGuard handshake (up to 15s)
|
||||||
|
local elapsed=0
|
||||||
|
local handshake_ts=0
|
||||||
|
echo "[check] Waiting for WireGuard handshake (up to 15s)..."
|
||||||
|
while [ "$elapsed" -lt 15 ]; do
|
||||||
|
handshake_ts=$(wg show "$INTERFACE" latest-handshakes 2>/dev/null | awk '{print $2}' | head -1)
|
||||||
|
if [ -n "$handshake_ts" ] && [ "$handshake_ts" != "0" ]; then
|
||||||
|
local age=$(( $(date +%s) - handshake_ts ))
|
||||||
|
echo "[check] OK Handshake established ${age}s ago"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
elapsed=$((elapsed + 1))
|
||||||
|
done
|
||||||
|
if [ "$elapsed" -ge 15 ]; then
|
||||||
|
echo "[check] FAIL No WireGuard handshake after 15s — tunnel is not up"
|
||||||
|
echo "[check] This container's public key (must be on the server):"
|
||||||
|
echo "[check] PublicKey = $(wg show "$INTERFACE" public-key 2>/dev/null || echo 'unknown')"
|
||||||
|
echo "[check] AllowedIPs = ${VPN_ADDRESS}"
|
||||||
|
echo "[check] Verify on server: wg show"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Check whether traffic actually flows through the tunnel
|
||||||
|
echo "[check] Testing traffic through tunnel (http://${CHECK_HOST})..."
|
||||||
|
local rx_before
|
||||||
|
rx_before=$(wg show "$INTERFACE" transfer 2>/dev/null | awk '{print $2}' | head -1)
|
||||||
|
|
||||||
|
if curl -sf --max-time 8 "http://${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 http://${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"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[check] ── End of checks ──"
|
||||||
|
}
|
||||||
|
|
||||||
# ── Main ──
|
# ── Main ──
|
||||||
enable_forwarding
|
enable_forwarding
|
||||||
setup_killswitch
|
setup_killswitch
|
||||||
start_vpn
|
start_vpn
|
||||||
|
check_vpn_connectivity
|
||||||
health_loop
|
health_loop
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
[Interface]
|
[Interface]
|
||||||
PrivateKey = iO5spIue/6ciwUoR95hYtuxdtQxV/Q9EOoQ/jHe18kM=
|
PrivateKey = EPRa2f/v72LvIXAY4yqIRJifsSb+nCcYHqC2rwA94UI=
|
||||||
Address = 10.2.0.2/32
|
Address = 100.64.244.78/32
|
||||||
DNS = 10.2.0.1
|
DNS = 198.18.0.1,198.18.0.2
|
||||||
|
|
||||||
# Route zum VPN-Server direkt über dein lokales Netz
|
# Route zum VPN-Server direkt über dein lokales Netz
|
||||||
PostUp = ip route add 185.183.34.149 via 192.168.178.1 dev wlp4s0f0
|
PostUp = ip route add 91.148.236.64 via 192.168.178.1 dev wlp4s0f0
|
||||||
PostUp = ip route add 192.168.178.0/24 via 192.168.178.1 dev wlp4s0f0
|
PostUp = ip route add 192.168.178.0/24 via 192.168.178.1 dev wlp4s0f0
|
||||||
PostDown = ip route del 185.183.34.149 via 192.168.178.1 dev wlp4s0f0
|
PostDown = ip route del 91.148.236.64 via 192.168.178.1 dev wlp4s0f0
|
||||||
PostDown = ip route del 192.168.178.0/24 via 192.168.178.1 dev wlp4s0f0
|
PostDown = ip route del 192.168.178.0/24 via 192.168.178.1 dev wlp4s0f0
|
||||||
|
|
||||||
[Peer]
|
[Peer]
|
||||||
PublicKey = J4XVdtoBVc/EoI2Yk673Oes97WMnQSH5KfamZNjtM2s=
|
PublicKey = KgTUh3KLijVluDvNpzDCJJfrJ7EyLzYLmdHCksG4sRg=
|
||||||
AllowedIPs = 0.0.0.0/1, 128.0.0.0/1
|
AllowedIPs = 0.0.0.0/0
|
||||||
Endpoint = 185.183.34.149:51820
|
Endpoint = 91.148.236.64:51820
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ services:
|
|||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
- SYS_MODULE
|
- SYS_MODULE
|
||||||
|
- NET_RAW
|
||||||
sysctls:
|
sysctls:
|
||||||
- net.ipv4.ip_forward=1
|
- net.ipv4.ip_forward=1
|
||||||
- net.ipv4.conf.all.src_valid_mark=1
|
- net.ipv4.conf.all.src_valid_mark=1
|
||||||
|
|||||||
101
Docker/push.sh
101
Docker/push.sh
@@ -1,15 +1,19 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# filepath: /home/lukas/Volume/repo/Aniworld/Docker/push.sh
|
|
||||||
#
|
#
|
||||||
# Build and push Aniworld container images to the Gitea registry.
|
# Build and push AniWorld container images to the Gitea registry.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./push.sh # builds & pushes with tag "latest"
|
# ./push.sh # builds & pushes app with tag "latest"
|
||||||
# ./push.sh v1.2.3 # builds & pushes with tag "v1.2.3"
|
# ./push.sh app # builds & pushes app image
|
||||||
# ./push.sh v1.2.3 --no-build # pushes existing images only
|
# ./push.sh vpn # builds & pushes vpn image
|
||||||
|
# ./push.sh all # builds & pushes both images
|
||||||
|
# ./push.sh app v1.2.3 # builds & pushes app with tag "v1.2.3"
|
||||||
|
# ./push.sh vpn v1.2.3 # builds & pushes vpn with tag "v1.2.3"
|
||||||
|
# ./push.sh all v1.2.3 # builds & pushes both images
|
||||||
|
# ./push.sh app v1.2.3 --no-build # pushes existing image only
|
||||||
#
|
#
|
||||||
# Prerequisites:
|
# Prerequisites:
|
||||||
# podman login git.lpl-mind.de
|
# podman login git.lpl-mind.de (or: docker login git.lpl-mind.de)
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -23,12 +27,20 @@ PROJECT="aniworld"
|
|||||||
APP_IMAGE="${REGISTRY}/${NAMESPACE}/${PROJECT}/app"
|
APP_IMAGE="${REGISTRY}/${NAMESPACE}/${PROJECT}/app"
|
||||||
VPN_IMAGE="${REGISTRY}/${NAMESPACE}/${PROJECT}/vpn"
|
VPN_IMAGE="${REGISTRY}/${NAMESPACE}/${PROJECT}/vpn"
|
||||||
|
|
||||||
TAG="${1:-latest}"
|
# Parse arguments
|
||||||
|
TARGET="${1:-app}"
|
||||||
|
TAG="${2:-latest}"
|
||||||
SKIP_BUILD=false
|
SKIP_BUILD=false
|
||||||
if [[ "${2:-}" == "--no-build" ]]; then
|
if [[ "${3:-}" == "--no-build" ]]; then
|
||||||
SKIP_BUILD=true
|
SKIP_BUILD=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Validate target
|
||||||
|
if [[ "${TARGET}" != "app" && "${TARGET}" != "vpn" && "${TARGET}" != "all" ]]; then
|
||||||
|
echo "ERROR: Invalid target '${TARGET}'. Must be one of: app, vpn, all" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
|
||||||
@@ -36,62 +48,93 @@ PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
log() { echo -e "\n>>> $*"; }
|
log() { echo -e "\n>>> $*"; }
|
||||||
err() { echo -e "\n❌ ERROR: $*" >&2; exit 1; }
|
err() { echo -e "\nERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Detect container engine (podman preferred, docker fallback)
|
||||||
|
if command -v podman &>/dev/null; then
|
||||||
|
ENGINE="podman"
|
||||||
|
elif command -v docker &>/dev/null; then
|
||||||
|
ENGINE="docker"
|
||||||
|
else
|
||||||
|
err "Neither podman nor docker is installed."
|
||||||
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Pre-flight checks
|
# Pre-flight checks
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
echo " Aniworld — Build & Push"
|
echo " AniWorld — Build & Push"
|
||||||
|
echo " Engine : ${ENGINE}"
|
||||||
echo " Registry : ${REGISTRY}"
|
echo " Registry : ${REGISTRY}"
|
||||||
|
echo " Target : ${TARGET}"
|
||||||
echo " Tag : ${TAG}"
|
echo " Tag : ${TAG}"
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
|
|
||||||
command -v podman &>/dev/null || err "podman is not installed."
|
log "Logging in to ${REGISTRY}"
|
||||||
|
"${ENGINE}" login "${REGISTRY}"
|
||||||
if ! podman login --get-login "${REGISTRY}" &>/dev/null; then
|
|
||||||
err "Not logged in. Run:\n podman login ${REGISTRY}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Build
|
# Build
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
if [[ "${SKIP_BUILD}" == false ]]; then
|
build_app() {
|
||||||
log "Building app image → ${APP_IMAGE}:${TAG}"
|
log "Building app image → ${APP_IMAGE}:${TAG}"
|
||||||
podman build \
|
"${ENGINE}" build \
|
||||||
-t "${APP_IMAGE}:${TAG}" \
|
-t "${APP_IMAGE}:${TAG}" \
|
||||||
-f "${SCRIPT_DIR}/Dockerfile.app" \
|
-f "${SCRIPT_DIR}/Dockerfile.app" \
|
||||||
"${PROJECT_ROOT}"
|
"${PROJECT_ROOT}"
|
||||||
|
}
|
||||||
|
|
||||||
log "Building VPN image → ${VPN_IMAGE}:${TAG}"
|
build_vpn() {
|
||||||
podman build \
|
log "Building vpn image → ${VPN_IMAGE}:${TAG}"
|
||||||
|
"${ENGINE}" build \
|
||||||
-t "${VPN_IMAGE}:${TAG}" \
|
-t "${VPN_IMAGE}:${TAG}" \
|
||||||
-f "${SCRIPT_DIR}/Containerfile" \
|
-f "${SCRIPT_DIR}/Containerfile" \
|
||||||
"${SCRIPT_DIR}"
|
"${SCRIPT_DIR}"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${SKIP_BUILD}" == false ]]; then
|
||||||
|
case "${TARGET}" in
|
||||||
|
app) build_app ;;
|
||||||
|
vpn) build_vpn ;;
|
||||||
|
all) build_app; build_vpn ;;
|
||||||
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Push
|
# Push
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
log "Pushing ${APP_IMAGE}:${TAG}"
|
push_app() {
|
||||||
podman push "${APP_IMAGE}:${TAG}"
|
log "Pushing ${APP_IMAGE}:${TAG}"
|
||||||
|
"${ENGINE}" push "${APP_IMAGE}:${TAG}"
|
||||||
|
}
|
||||||
|
|
||||||
log "Pushing ${VPN_IMAGE}:${TAG}"
|
push_vpn() {
|
||||||
podman push "${VPN_IMAGE}:${TAG}"
|
log "Pushing ${VPN_IMAGE}:${TAG}"
|
||||||
|
"${ENGINE}" push "${VPN_IMAGE}:${TAG}"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${TARGET}" in
|
||||||
|
app) push_app ;;
|
||||||
|
vpn) push_vpn ;;
|
||||||
|
all) push_app; push_vpn ;;
|
||||||
|
esac
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Summary
|
# Summary
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo ""
|
echo ""
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
echo " ✅ Push complete!"
|
echo " Push complete!"
|
||||||
echo ""
|
echo ""
|
||||||
echo " Images:"
|
echo " Images:"
|
||||||
echo " ${APP_IMAGE}:${TAG}"
|
case "${TARGET}" in
|
||||||
echo " ${VPN_IMAGE}:${TAG}"
|
app) echo " ${APP_IMAGE}:${TAG}" ;;
|
||||||
|
vpn) echo " ${VPN_IMAGE}:${TAG}" ;;
|
||||||
|
all) echo " ${APP_IMAGE}:${TAG}"; echo " ${VPN_IMAGE}:${TAG}" ;;
|
||||||
|
esac
|
||||||
echo ""
|
echo ""
|
||||||
echo " Deploy on server:"
|
echo " Deploy on server:"
|
||||||
echo " podman login ${REGISTRY}"
|
echo " ${ENGINE} login ${REGISTRY}"
|
||||||
echo " podman-compose -f podman-compose.prod.yml pull"
|
echo " ${ENGINE} compose -f Docker/podman-compose.prod.yml pull"
|
||||||
echo " podman-compose -f podman-compose.prod.yml up -d"
|
echo " ${ENGINE} compose -f Docker/podman-compose.prod.yml up -d"
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
129
Docker/release.sh
Normal file
129
Docker/release.sh
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Bump the project version and push images to the registry.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./release.sh
|
||||||
|
#
|
||||||
|
# The current version is stored in VERSION (next to this script).
|
||||||
|
# You will be asked whether to bump major, minor, or patch.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
VERSION_FILE="${SCRIPT_DIR}/VERSION"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Read current version
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if [[ ! -f "${VERSION_FILE}" ]]; then
|
||||||
|
echo "0.0.0" > "${VERSION_FILE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT="$(cat "${VERSION_FILE}")"
|
||||||
|
# Strip leading 'v' for arithmetic
|
||||||
|
VERSION="${CURRENT#v}"
|
||||||
|
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<< "${VERSION}"
|
||||||
|
|
||||||
|
echo "============================================"
|
||||||
|
echo " AniWorld — Release"
|
||||||
|
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}"
|
||||||
|
echo "============================================"
|
||||||
|
echo ""
|
||||||
|
echo "Which image(s) would you like to release?"
|
||||||
|
echo " 1) app (Dockerfile.app)"
|
||||||
|
echo " 2) vpn (Containerfile)"
|
||||||
|
echo " 3) all (both images)"
|
||||||
|
echo ""
|
||||||
|
read -rp "Enter choice [1/2/3]: " TARGET_CHOICE
|
||||||
|
|
||||||
|
case "${TARGET_CHOICE}" in
|
||||||
|
1) TARGET="app" ;;
|
||||||
|
2) TARGET="vpn" ;;
|
||||||
|
3) TARGET="all" ;;
|
||||||
|
*)
|
||||||
|
echo "Invalid choice. Aborting." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "How would you like to bump the version?"
|
||||||
|
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.$((PATCH + 1)))"
|
||||||
|
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.$((MINOR + 1)).0)"
|
||||||
|
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH} → v$((MAJOR + 1)).0.0)"
|
||||||
|
echo ""
|
||||||
|
read -rp "Enter choice [1/2/3]: " CHOICE
|
||||||
|
|
||||||
|
case "${CHOICE}" in
|
||||||
|
1) NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))" ;;
|
||||||
|
2) NEW_TAG="v${MAJOR}.$((MINOR + 1)).0" ;;
|
||||||
|
3) NEW_TAG="v$((MAJOR + 1)).0.0" ;;
|
||||||
|
*)
|
||||||
|
echo "Invalid choice. Aborting." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "New version: ${NEW_TAG}"
|
||||||
|
echo "Target: ${TARGET}"
|
||||||
|
read -rp "Confirm? [y/N]: " CONFIRM
|
||||||
|
if [[ ! "${CONFIRM}" =~ ^[yY]$ ]]; then
|
||||||
|
echo "Aborted."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Write new version
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo "${NEW_TAG}" > "${VERSION_FILE}"
|
||||||
|
echo "Version file updated → ${VERSION_FILE}"
|
||||||
|
|
||||||
|
# Keep root package.json in sync.
|
||||||
|
FRONT_VERSION="${NEW_TAG#v}"
|
||||||
|
FRONT_PKG="${SCRIPT_DIR}/../package.json"
|
||||||
|
if [[ -f "${FRONT_PKG}" ]]; then
|
||||||
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_PKG}"
|
||||||
|
echo "package.json version updated → ${FRONT_VERSION}"
|
||||||
|
else
|
||||||
|
echo "Warning: package.json not found, skipping package.json version sync" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Keep root pyproject.toml in sync.
|
||||||
|
BACKEND_PYPROJECT="${SCRIPT_DIR}/../pyproject.toml"
|
||||||
|
if [[ -f "${BACKEND_PYPROJECT}" ]]; then
|
||||||
|
# Update version under [project] section if present
|
||||||
|
if grep -q '^\[project\]' "${BACKEND_PYPROJECT}"; then
|
||||||
|
sed -i "/^\[project\]/,/^\[/ s/^version = \".*\"/version = \"${FRONT_VERSION}\"/" "${BACKEND_PYPROJECT}"
|
||||||
|
else
|
||||||
|
sed -i "s/^version = \".*\"/version = \"${FRONT_VERSION}\"/" "${BACKEND_PYPROJECT}"
|
||||||
|
fi
|
||||||
|
echo "pyproject.toml version updated → ${FRONT_VERSION}"
|
||||||
|
else
|
||||||
|
echo "Warning: pyproject.toml not found, skipping pyproject.toml version sync" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Push containers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
bash "${SCRIPT_DIR}/push.sh" "${TARGET}" "${NEW_TAG}"
|
||||||
|
bash "${SCRIPT_DIR}/push.sh" "${TARGET}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Git tag (local only; push after container build)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
cd "${SCRIPT_DIR}/.."
|
||||||
|
git add Docker/VERSION package.json pyproject.toml
|
||||||
|
git commit -m "chore: 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."
|
||||||
@@ -6,23 +6,29 @@ Verifies:
|
|||||||
2. The container starts and becomes healthy.
|
2. The container starts and becomes healthy.
|
||||||
3. The public IP inside the VPN differs from the host IP.
|
3. The public IP inside the VPN differs from the host IP.
|
||||||
4. Kill switch blocks traffic when WireGuard is down.
|
4. Kill switch blocks traffic when WireGuard is down.
|
||||||
|
5. AllowedIPs routes are set dynamically from the config.
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
- podman installed
|
- podman installed
|
||||||
- Root/sudo (NET_ADMIN capability)
|
- Root/sudo (NET_ADMIN capability) for container runtime tests
|
||||||
- A valid WireGuard config at ./wg0.conf (or ./nl.conf)
|
- A valid WireGuard config at ./wg0.conf (or ./nl.conf)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
# Build-only test (no sudo needed):
|
||||||
|
python3 -m pytest test_vpn.py::TestVPNImage::test_00_build_image -v
|
||||||
|
|
||||||
|
# Full integration test (requires sudo):
|
||||||
sudo python3 -m pytest test_vpn.py -v
|
sudo python3 -m pytest test_vpn.py -v
|
||||||
# or
|
# or
|
||||||
sudo python3 test_vpn.py
|
sudo python3 test_vpn.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
import os
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -35,6 +41,11 @@ STARTUP_TIMEOUT = 30 # seconds to wait for VPN to come up
|
|||||||
HEALTH_POLL_INTERVAL = 2 # seconds between health checks
|
HEALTH_POLL_INTERVAL = 2 # seconds between health checks
|
||||||
|
|
||||||
|
|
||||||
|
def is_root() -> bool:
|
||||||
|
"""Check if running as root."""
|
||||||
|
return os.geteuid() == 0
|
||||||
|
|
||||||
|
|
||||||
def run(cmd: list[str], timeout: int = 30, check: bool = True) -> subprocess.CompletedProcess:
|
def run(cmd: list[str], timeout: int = 30, check: bool = True) -> subprocess.CompletedProcess:
|
||||||
"""Run a command and return the result."""
|
"""Run a command and return the result."""
|
||||||
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=check)
|
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=check)
|
||||||
@@ -55,6 +66,7 @@ class TestVPNImage(unittest.TestCase):
|
|||||||
"""Test suite for the WireGuard VPN container."""
|
"""Test suite for the WireGuard VPN container."""
|
||||||
|
|
||||||
host_ip: str = ""
|
host_ip: str = ""
|
||||||
|
container_id: str = ""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
@@ -84,6 +96,12 @@ class TestVPNImage(unittest.TestCase):
|
|||||||
assert result.returncode == 0, f"Build failed:\n{result.stderr}"
|
assert result.returncode == 0, f"Build failed:\n{result.stderr}"
|
||||||
logger.info("Image built successfully.")
|
logger.info("Image built successfully.")
|
||||||
|
|
||||||
|
# Skip container runtime tests if not root
|
||||||
|
if not is_root():
|
||||||
|
logger.warning("Not running as root — skipping container runtime tests.")
|
||||||
|
cls.container_id = ""
|
||||||
|
return
|
||||||
|
|
||||||
# ── 3. Start the container ──
|
# ── 3. Start the container ──
|
||||||
logger.info("Starting container '%s'...", CONTAINER_NAME)
|
logger.info("Starting container '%s'...", CONTAINER_NAME)
|
||||||
result = run(
|
result = run(
|
||||||
@@ -120,6 +138,8 @@ class TestVPNImage(unittest.TestCase):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def tearDownClass(cls):
|
def tearDownClass(cls):
|
||||||
"""Stop and remove the container."""
|
"""Stop and remove the container."""
|
||||||
|
if not is_root():
|
||||||
|
return
|
||||||
logger.info("Cleaning up test container...")
|
logger.info("Cleaning up test container...")
|
||||||
subprocess.run(["podman", "rm", "-f", CONTAINER_NAME], capture_output=True, check=False)
|
subprocess.run(["podman", "rm", "-f", CONTAINER_NAME], capture_output=True, check=False)
|
||||||
logger.info("Cleanup complete.")
|
logger.info("Cleanup complete.")
|
||||||
@@ -144,10 +164,22 @@ class TestVPNImage(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
return result.stdout.strip()
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
def _skip_if_not_root(self):
|
||||||
|
"""Skip test if not running as root."""
|
||||||
|
if not is_root():
|
||||||
|
self.skipTest("This test requires root/sudo privileges")
|
||||||
|
|
||||||
# ── Tests ────────────────────────────────────────────────
|
# ── Tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_00_build_image(self):
|
||||||
|
"""The image builds successfully."""
|
||||||
|
# This is already verified in setUpClass, just confirm here
|
||||||
|
result = run(["podman", "images", "--format", "{{.Repository}}:{{.Tag}}"])
|
||||||
|
self.assertIn(IMAGE_NAME, result.stdout, "Image was not built")
|
||||||
|
|
||||||
def test_01_ip_differs_from_host(self):
|
def test_01_ip_differs_from_host(self):
|
||||||
"""Public IP inside VPN is different from host IP."""
|
"""Public IP inside VPN is different from host IP."""
|
||||||
|
self._skip_if_not_root()
|
||||||
vpn_ip = self._get_vpn_ip()
|
vpn_ip = self._get_vpn_ip()
|
||||||
logger.info("VPN public IP: %s", vpn_ip)
|
logger.info("VPN public IP: %s", vpn_ip)
|
||||||
logger.info("Host public IP: %s", self.host_ip)
|
logger.info("Host public IP: %s", self.host_ip)
|
||||||
@@ -161,12 +193,42 @@ class TestVPNImage(unittest.TestCase):
|
|||||||
|
|
||||||
def test_02_wireguard_interface_exists(self):
|
def test_02_wireguard_interface_exists(self):
|
||||||
"""The wg0 interface is present in the container."""
|
"""The wg0 interface is present in the container."""
|
||||||
|
self._skip_if_not_root()
|
||||||
result = podman_exec(CONTAINER_NAME, ["wg", "show", "wg0"])
|
result = podman_exec(CONTAINER_NAME, ["wg", "show", "wg0"])
|
||||||
self.assertEqual(result.returncode, 0, f"wg show failed:\n{result.stderr}")
|
self.assertEqual(result.returncode, 0, f"wg show failed:\n{result.stderr}")
|
||||||
self.assertIn("peer", result.stdout.lower(), "No peer information in wg show output")
|
self.assertIn("peer", result.stdout.lower(), "No peer information in wg show output")
|
||||||
|
# AllowedIPs should be present in wg show output
|
||||||
|
self.assertIn("allowed ips", result.stdout.lower(), "AllowedIPs not found in wg show output")
|
||||||
|
|
||||||
def test_03_kill_switch_blocks_traffic(self):
|
def test_03_allowedips_routes_set(self):
|
||||||
|
"""Routes are set dynamically based on AllowedIPs from config."""
|
||||||
|
self._skip_if_not_root()
|
||||||
|
# Check that routes exist for the AllowedIPs
|
||||||
|
result = podman_exec(CONTAINER_NAME, ["ip", "route", "show", "dev", "wg0"])
|
||||||
|
self.assertEqual(result.returncode, 0, f"ip route show failed:\n{result.stderr}")
|
||||||
|
# The config has AllowedIPs = 0.0.0.0/0, which should result in:
|
||||||
|
# 0.0.0.0/1 dev wg0 and 128.0.0.0/1 dev wg0
|
||||||
|
self.assertIn("0.0.0.0/1", result.stdout, "Route 0.0.0.0/1 not found")
|
||||||
|
self.assertIn("128.0.0.0/1", result.stdout, "Route 128.0.0.0/1 not found")
|
||||||
|
# Make sure there is NO default route through wg0 (Table = off should prevent this)
|
||||||
|
self.assertNotIn("default dev wg0", result.stdout, "Default route through wg0 found — Table = off not working!")
|
||||||
|
logger.info("AllowedIPs routes verified: %s", result.stdout.strip())
|
||||||
|
|
||||||
|
def test_03b_dns_configured(self):
|
||||||
|
"""DNS is configured correctly with multiple nameserver lines."""
|
||||||
|
self._skip_if_not_root()
|
||||||
|
result = podman_exec(CONTAINER_NAME, ["cat", "/etc/resolv.conf"])
|
||||||
|
self.assertEqual(result.returncode, 0, f"cat /etc/resolv.conf failed:\n{result.stderr}")
|
||||||
|
# Should have two separate nameserver lines, not one with commas
|
||||||
|
self.assertIn("nameserver 198.18.0.1", result.stdout, "DNS 198.18.0.1 not found")
|
||||||
|
self.assertIn("nameserver 198.18.0.2", result.stdout, "DNS 198.18.0.2 not found")
|
||||||
|
# Make sure there are no commas in nameserver lines
|
||||||
|
self.assertNotIn("nameserver 198.18.0.1,198.18.0.2", result.stdout, "DNS servers written on one line with comma!")
|
||||||
|
logger.info("DNS config verified: %s", result.stdout.strip())
|
||||||
|
|
||||||
|
def test_04_kill_switch_blocks_traffic(self):
|
||||||
"""When WireGuard is down, traffic is blocked (kill switch)."""
|
"""When WireGuard is down, traffic is blocked (kill switch)."""
|
||||||
|
self._skip_if_not_root()
|
||||||
# Bring down the WireGuard interface by deleting it
|
# Bring down the WireGuard interface by deleting it
|
||||||
down_result = podman_exec(CONTAINER_NAME, ["ip", "link", "del", "wg0"], timeout=10)
|
down_result = podman_exec(CONTAINER_NAME, ["ip", "link", "del", "wg0"], timeout=10)
|
||||||
self.assertEqual(down_result.returncode, 0, f"ip link del wg0 failed:\n{down_result.stderr}")
|
self.assertEqual(down_result.returncode, 0, f"ip link del wg0 failed:\n{down_result.stderr}")
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
[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
|
PersistentKeepalive = 25
|
||||||
|
|
||||||
|
|||||||
@@ -1285,7 +1285,7 @@ Basic health check endpoint.
|
|||||||
{
|
{
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"timestamp": "2025-12-13T10:30:00.000Z",
|
"timestamp": "2025-12-13T10:30:00.000Z",
|
||||||
"version": "1.0.0"
|
"version": "1.0.1"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1303,7 +1303,7 @@ Comprehensive health check with database, filesystem, and system metrics.
|
|||||||
{
|
{
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"timestamp": "2025-12-13T10:30:00.000Z",
|
"timestamp": "2025-12-13T10:30:00.000Z",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"database": {
|
"database": {
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
10
docs/bla
10
docs/bla
@@ -1,10 +0,0 @@
|
|||||||
review frontend code and check for architektre issues
|
|
||||||
|
|
||||||
write the tasks in Task.md
|
|
||||||
for each task add the following informations
|
|
||||||
|
|
||||||
where is that found
|
|
||||||
goal. how it should be
|
|
||||||
possibale traps and issues
|
|
||||||
docs changes needed
|
|
||||||
why this is needed
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aniworld-web",
|
"name": "aniworld-web",
|
||||||
"version": "1.0.0",
|
"version": "1.1.4",
|
||||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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,8 +61,12 @@ 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -148,7 +148,27 @@ class AnimeSeriesService:
|
|||||||
select(AnimeSeries).where(AnimeSeries.key == key)
|
select(AnimeSeries).where(AnimeSeries.key == key)
|
||||||
)
|
)
|
||||||
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,44 @@ _rate_limit_lock = Lock()
|
|||||||
_RATE_LIMIT_WINDOW_SECONDS = 60.0
|
_RATE_LIMIT_WINDOW_SECONDS = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def _make_db_lookup():
|
||||||
|
"""Build a synchronous ``(folder) -> Serie | None`` callable for SerieScanner.
|
||||||
|
|
||||||
|
The returned function opens a short-lived sync DB session, queries for a
|
||||||
|
series whose ``folder`` column matches the given name, and converts the
|
||||||
|
ORM row to a ``Serie`` domain object. Returns ``None`` when the DB is not
|
||||||
|
yet initialised or no matching row is found.
|
||||||
|
"""
|
||||||
|
from src.core.entities.series import Serie
|
||||||
|
|
||||||
|
def _lookup(folder: str) -> Optional["Serie"]:
|
||||||
|
try:
|
||||||
|
from src.server.database.connection import get_sync_session
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
db = get_sync_session()
|
||||||
|
try:
|
||||||
|
row = AnimeSeriesService.get_by_folder_sync(db, folder)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return Serie(
|
||||||
|
key=row.key,
|
||||||
|
name=row.name or "",
|
||||||
|
site=row.site,
|
||||||
|
folder=row.folder,
|
||||||
|
episodeDict={},
|
||||||
|
year=row.year,
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
# DB not initialised yet (e.g. first boot before init_db())
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _lookup
|
||||||
|
|
||||||
|
|
||||||
def get_series_app() -> SeriesApp:
|
def get_series_app() -> SeriesApp:
|
||||||
"""
|
"""
|
||||||
Dependency to get SeriesApp instance.
|
Dependency to get SeriesApp instance.
|
||||||
@@ -134,7 +172,7 @@ def get_series_app() -> SeriesApp:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
_series_app = SeriesApp(anime_dir)
|
_series_app = SeriesApp(anime_dir, db_lookup=_make_db_lookup())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def get_base_context(
|
|||||||
"request": request,
|
"request": request,
|
||||||
"title": title,
|
"title": title,
|
||||||
"app_name": "Aniworld Download Manager",
|
"app_name": "Aniworld Download Manager",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"static_v": STATIC_VERSION,
|
"static_v": STATIC_VERSION,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from src.server.services.folder_scan_service import (
|
|||||||
_TMDB_SEMAPHORE,
|
_TMDB_SEMAPHORE,
|
||||||
FolderScanService,
|
FolderScanService,
|
||||||
FolderScanServiceError,
|
FolderScanServiceError,
|
||||||
|
perform_nfo_repair_scan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -651,4 +652,187 @@ 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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user