Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1696d5c65b | |||
| c8b386f47a | |||
| 3888da352a | |||
| 06e104db42 | |||
| d4594bd1d9 | |||
| d866e836f6 | |||
| 195dae13cb | |||
| 51be777e7d | |||
| 7930e49701 | |||
| 75c22fe296 | |||
| 7bcd0600d5 | |||
| a333329ae2 | |||
| 363f7899f8 | |||
| a08a8f7408 | |||
| 54ac5e9ab7 | |||
| c93ac3e7b8 | |||
| 68c4335348 | |||
| be87f2e230 | |||
| c56e0f507d | |||
| cb0a36ccc2 | |||
| 3644b16447 | |||
| d5116e378e | |||
| 50a7083ce5 | |||
| 52c0ff2337 | |||
| a5fd88e224 | |||
| 98d4edad14 | |||
| bc8059b453 |
@@ -13,7 +13,8 @@ RUN apk add --no-cache \
|
||||
# Create wireguard config directory (config is mounted at runtime)
|
||||
RUN mkdir -p /etc/wireguard
|
||||
|
||||
# Copy entrypoint
|
||||
# Copy version file and entrypoint
|
||||
COPY VERSION /etc/wireguard/VERSION
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
v0.0.1
|
||||
v1.1.9
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
VERSION_FILE="/etc/wireguard/VERSION"
|
||||
if [ -f "$VERSION_FILE" ]; then
|
||||
VERSION=$(cat "$VERSION_FILE")
|
||||
else
|
||||
VERSION="unknown"
|
||||
fi
|
||||
echo "[init] VPN Container Entrypoint ${VERSION}"
|
||||
|
||||
INTERFACE="wg0"
|
||||
MOUNT_CONFIG="/etc/wireguard/${INTERFACE}.conf"
|
||||
CONFIG_DIR="/run/wireguard"
|
||||
@@ -64,9 +72,11 @@ setup_killswitch() {
|
||||
iptables -A INPUT -i "$INTERFACE" -j ACCEPT
|
||||
iptables -A OUTPUT -o "$INTERFACE" -j ACCEPT
|
||||
|
||||
# Allow DNS to the VPN DNS server (through wg0)
|
||||
iptables -A OUTPUT -o "$INTERFACE" -p udp --dport 53 -j ACCEPT
|
||||
iptables -A OUTPUT -o "$INTERFACE" -p tcp --dport 53 -j ACCEPT
|
||||
# Allow DNS (VPN DNS servers are routed through wg0; allow before routing decision)
|
||||
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
|
||||
iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT
|
||||
iptables -A INPUT -p udp --sport 53 -j ACCEPT
|
||||
iptables -A INPUT -p tcp --sport 53 -j ACCEPT
|
||||
|
||||
# Allow DHCP (for container networking)
|
||||
iptables -A OUTPUT -p udp --dport 67:68 -j ACCEPT
|
||||
@@ -120,27 +130,46 @@ start_vpn() {
|
||||
ip link add "$INTERFACE" type wireguard
|
||||
|
||||
# Apply the WireGuard config (keys, peer, endpoint)
|
||||
# Filter out wg-quick directives that wg setconf doesn't understand
|
||||
wg setconf "$INTERFACE" <(grep -v -i '^\(Address\|DNS\|MTU\|Table\|PreUp\|PostUp\|PreDown\|PostDown\|SaveConfig\)' "$CONFIG_FILE")
|
||||
|
||||
# Log public key so it can be verified against the server's peer list
|
||||
local PUBKEY
|
||||
PUBKEY=$(wg show "$INTERFACE" public-key 2>/dev/null || echo "unknown")
|
||||
echo "[vpn] Public key: ${PUBKEY}"
|
||||
|
||||
# Assign the address
|
||||
ip -4 address add "$VPN_ADDRESS" dev "$INTERFACE"
|
||||
|
||||
# Set MTU
|
||||
# Set MTU and bring up
|
||||
ip link set mtu 1420 up dev "$INTERFACE"
|
||||
|
||||
# Find default gateway/interface for the endpoint route
|
||||
# ── fwmark-based routing (mirrors wg-quick behavior) ──
|
||||
# WireGuard marks its own encapsulated UDP packets with this fwmark.
|
||||
# Policy rules then ensure:
|
||||
# - Normal packets (no mark) → VPN routing table → wg0
|
||||
# - WireGuard-encapsulated packets (marked) → main table → eth0
|
||||
local FW_MARK=51820
|
||||
local FW_TABLE=51820
|
||||
wg set "$INTERFACE" fwmark "$FW_MARK"
|
||||
|
||||
# Remove any auto-created default route on wg0
|
||||
ip route del default dev "$INTERFACE" 2>/dev/null || true
|
||||
|
||||
# VPN routing table: send everything through the tunnel
|
||||
ip -4 route add default dev "$INTERFACE" table "$FW_TABLE"
|
||||
|
||||
# Policy rules:
|
||||
# 1. Packets NOT marked by WireGuard use the VPN table (→ wg0)
|
||||
# 2. suppress_prefixlength 0: ignore bare default routes in main table,
|
||||
# but keep more-specific routes (e.g. LAN, endpoint) working
|
||||
ip -4 rule add not fwmark "$FW_MARK" table "$FW_TABLE"
|
||||
ip -4 rule add table main suppress_prefixlength 0
|
||||
|
||||
# Find default gateway/interface
|
||||
DEFAULT_GW=$(ip route | grep '^default' | head -1 | awk '{print $3}')
|
||||
DEFAULT_IF=$(ip route | grep '^default' | head -1 | awk '{print $5}')
|
||||
|
||||
# Route VPN endpoint through the container's default gateway
|
||||
if [ -n "$DEFAULT_GW" ] && [ -n "$DEFAULT_IF" ]; then
|
||||
ip route add "$VPN_ENDPOINT/32" via "$DEFAULT_GW" dev "$DEFAULT_IF" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Route all traffic through the WireGuard tunnel
|
||||
ip route add 0.0.0.0/1 dev "$INTERFACE"
|
||||
ip route add 128.0.0.0/1 dev "$INTERFACE"
|
||||
|
||||
# ── Policy routing: ensure responses to incoming LAN traffic go back via eth0 ──
|
||||
if [ -n "$DEFAULT_GW" ] && [ -n "$DEFAULT_IF" ]; then
|
||||
# Get the container's eth0 IP address (BusyBox-compatible, no grep -P)
|
||||
@@ -155,14 +184,26 @@ start_vpn() {
|
||||
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')
|
||||
if [ -n "$VPN_DNS" ]; then
|
||||
echo "nameserver $VPN_DNS" > /etc/resolv.conf
|
||||
echo "[vpn] DNS set to ${VPN_DNS}"
|
||||
# Clear resolv.conf and add each DNS server on its own line
|
||||
> /etc/resolv.conf
|
||||
for dns in $(echo "$VPN_DNS" | tr ',' ' '); do
|
||||
echo "nameserver $dns" >> /etc/resolv.conf
|
||||
done
|
||||
echo "[vpn] DNS set to: ${VPN_DNS}"
|
||||
fi
|
||||
|
||||
echo "[vpn] WireGuard interface ${INTERFACE} is up."
|
||||
echo "[vpn] Main routes:"
|
||||
ip route show | sed 's/^/[vpn] /'
|
||||
echo "[vpn] VPN table ($FW_TABLE):"
|
||||
ip route show table "$FW_TABLE" 2>/dev/null | sed 's/^/[vpn] /'
|
||||
echo "[vpn] Policy rules:"
|
||||
ip rule show | sed 's/^/[vpn] /'
|
||||
echo "[vpn] WireGuard status:"
|
||||
wg show "$INTERFACE" 2>/dev/null | sed 's/^/[vpn] /'
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -170,6 +211,21 @@ start_vpn() {
|
||||
# ──────────────────────────────────────────────
|
||||
stop_vpn() {
|
||||
echo "[vpn] Stopping WireGuard interface ${INTERFACE}..."
|
||||
|
||||
local FW_MARK=51820
|
||||
local FW_TABLE=51820
|
||||
|
||||
# Remove fwmark-based policy rules
|
||||
ip -4 rule del not fwmark "$FW_MARK" table "$FW_TABLE" 2>/dev/null || true
|
||||
ip -4 rule del table main suppress_prefixlength 0 2>/dev/null || true
|
||||
|
||||
# Flush VPN routing table
|
||||
ip -4 route flush table "$FW_TABLE" 2>/dev/null || true
|
||||
|
||||
# Remove LAN policy routing
|
||||
ip -4 rule del table 100 2>/dev/null || true
|
||||
ip -4 route flush table 100 2>/dev/null || true
|
||||
|
||||
ip link del "$INTERFACE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
@@ -185,14 +241,19 @@ health_loop() {
|
||||
while true; do
|
||||
sleep "$CHECK_INTERVAL"
|
||||
|
||||
if curl -sf --max-time 5 "http://$CHECK_HOST" > /dev/null 2>&1; then
|
||||
if ping -c 1 -W 5 "$CHECK_HOST" > /dev/null 2>&1; then
|
||||
if [ "$failures" -gt 0 ]; then
|
||||
echo "[health] VPN recovered."
|
||||
failures=0
|
||||
fi
|
||||
else
|
||||
failures=$((failures + 1))
|
||||
echo "[health] Ping failed ($failures/$max_failures)"
|
||||
echo "[health] Check failed ($failures/$max_failures) — ping ${CHECK_HOST} failed"
|
||||
# Dump WireGuard stats to show if handshake is stale and how much data flows
|
||||
echo "[health] wg stats:"
|
||||
wg show "$INTERFACE" 2>/dev/null | grep -E 'latest handshake|transfer|endpoint' | sed 's/^/[health] /' || echo "[health] wg0 not found"
|
||||
echo "[health] routes:"
|
||||
ip route show | grep -E 'wg0|default' | sed 's/^/[health] /'
|
||||
|
||||
if [ "$failures" -ge "$max_failures" ]; then
|
||||
echo "[health] VPN appears down. Restarting WireGuard..."
|
||||
@@ -221,8 +282,81 @@ cleanup() {
|
||||
|
||||
trap cleanup SIGTERM SIGINT
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Startup connectivity checks — diagnose issues early
|
||||
# ──────────────────────────────────────────────
|
||||
check_vpn_connectivity() {
|
||||
echo "[check] ── Startup connectivity checks ──"
|
||||
|
||||
# 1. Wait for WireGuard handshake (up to 15s)
|
||||
local elapsed=0
|
||||
local handshake_ts=0
|
||||
echo "[check] Waiting for WireGuard handshake (up to 15s)..."
|
||||
while [ "$elapsed" -lt 15 ]; do
|
||||
handshake_ts=$(wg show "$INTERFACE" latest-handshakes 2>/dev/null | awk '{print $2}' | head -1)
|
||||
if [ -n "$handshake_ts" ] && [ "$handshake_ts" != "0" ]; then
|
||||
local age=$(( $(date +%s) - handshake_ts ))
|
||||
echo "[check] OK Handshake established ${age}s ago"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
if [ "$elapsed" -ge 15 ]; then
|
||||
echo "[check] FAIL No WireGuard handshake after 15s — tunnel is not up"
|
||||
echo "[check] This container's public key (must be on the server):"
|
||||
echo "[check] PublicKey = $(wg show "$INTERFACE" public-key 2>/dev/null || echo 'unknown')"
|
||||
echo "[check] AllowedIPs = ${VPN_ADDRESS}"
|
||||
echo "[check] Verify on server: wg show"
|
||||
fi
|
||||
|
||||
# 2. Check whether traffic actually flows through the tunnel
|
||||
echo "[check] Testing traffic through tunnel (ping ${CHECK_HOST})..."
|
||||
local rx_before
|
||||
rx_before=$(wg show "$INTERFACE" transfer 2>/dev/null | awk '{print $2}' | head -1)
|
||||
|
||||
if ping -c 1 -W 8 "${CHECK_HOST}" > /dev/null 2>&1; then
|
||||
echo "[check] OK Traffic flows — tunnel is fully working"
|
||||
else
|
||||
local rx_after
|
||||
rx_after=$(wg show "$INTERFACE" transfer 2>/dev/null | awk '{print $2}' | head -1)
|
||||
echo "[check] FAIL ping ${CHECK_HOST} unreachable through tunnel"
|
||||
|
||||
if [ -n "$rx_before" ] && [ -n "$rx_after" ]; then
|
||||
if [ "$rx_after" -le "$rx_before" ]; then
|
||||
echo "[check] RX bytes unchanged (${rx_before} → ${rx_after})"
|
||||
echo "[check] Server receives packets but does NOT route them back"
|
||||
echo "[check] Fix on VPN server (${VPN_ENDPOINT}):"
|
||||
echo "[check] sysctl net.ipv4.ip_forward # must output 1"
|
||||
echo "[check] iptables -t nat -L POSTROUTING -v -n # must have MASQUERADE"
|
||||
echo "[check] wg show # check peer + AllowedIPs"
|
||||
else
|
||||
echo "[check] RX increased (${rx_before} → ${rx_after}) — tunnel passes data"
|
||||
echo "[check] Issue may be specific to ${CHECK_HOST} or DNS"
|
||||
fi
|
||||
fi
|
||||
|
||||
local transfer
|
||||
transfer=$(wg show "$INTERFACE" transfer 2>/dev/null | awk '{printf "rx=%s tx=%s", $2, $3}')
|
||||
echo "[check] wg transfer: ${transfer}"
|
||||
fi
|
||||
|
||||
# 3. DNS check
|
||||
echo "[check] Testing DNS resolution..."
|
||||
if nslookup 1.1.1.1 > /dev/null 2>&1 || nslookup google.com > /dev/null 2>&1; then
|
||||
echo "[check] OK DNS resolves"
|
||||
else
|
||||
echo "[check] FAIL DNS resolution failed"
|
||||
echo "[check] resolv.conf: $(tr '\n' ' ' < /etc/resolv.conf)"
|
||||
echo "[check] Check that DNS servers are reachable through wg0"
|
||||
fi
|
||||
|
||||
echo "[check] ── End of checks ──"
|
||||
}
|
||||
|
||||
# ── Main ──
|
||||
enable_forwarding
|
||||
setup_killswitch
|
||||
start_vpn
|
||||
check_vpn_connectivity
|
||||
health_loop
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
[Interface]
|
||||
PrivateKey = iO5spIue/6ciwUoR95hYtuxdtQxV/Q9EOoQ/jHe18kM=
|
||||
Address = 10.2.0.2/32
|
||||
DNS = 10.2.0.1
|
||||
PrivateKey = EPRa2f/v72LvIXAY4yqIRJifsSb+nCcYHqC2rwA94UI=
|
||||
Address = 100.64.244.78/32
|
||||
DNS = 198.18.0.1,198.18.0.2
|
||||
|
||||
# Route zum VPN-Server direkt über dein lokales Netz
|
||||
PostUp = ip route add 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
|
||||
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
|
||||
|
||||
[Peer]
|
||||
PublicKey = J4XVdtoBVc/EoI2Yk673Oes97WMnQSH5KfamZNjtM2s=
|
||||
AllowedIPs = 0.0.0.0/1, 128.0.0.0/1
|
||||
Endpoint = 185.183.34.149:51820
|
||||
|
||||
PublicKey = KgTUh3KLijVluDvNpzDCJJfrJ7EyLzYLmdHCksG4sRg=
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 91.148.236.64:51820
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- SYS_MODULE
|
||||
- NET_RAW
|
||||
sysctls:
|
||||
- net.ipv4.ip_forward=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
|
||||
# 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:
|
||||
# ./push.sh # builds & pushes with tag "latest"
|
||||
# ./push.sh v1.2.3 # builds & pushes with tag "v1.2.3"
|
||||
# ./push.sh v1.2.3 --no-build # pushes existing images only
|
||||
# ./push.sh # builds & pushes app with tag "latest"
|
||||
# ./push.sh app # builds & pushes app image
|
||||
# ./push.sh vpn # builds & pushes vpn image
|
||||
# ./push.sh all # builds & pushes both images
|
||||
# ./push.sh app v1.2.3 # builds & pushes app with tag "v1.2.3"
|
||||
# ./push.sh vpn v1.2.3 # builds & pushes vpn with tag "v1.2.3"
|
||||
# ./push.sh all v1.2.3 # builds & pushes both images
|
||||
# ./push.sh app v1.2.3 --no-build # pushes existing image only
|
||||
#
|
||||
# Prerequisites:
|
||||
# podman login git.lpl-mind.de
|
||||
# podman login git.lpl-mind.de (or: docker login git.lpl-mind.de)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -23,12 +27,20 @@ PROJECT="aniworld"
|
||||
APP_IMAGE="${REGISTRY}/${NAMESPACE}/${PROJECT}/app"
|
||||
VPN_IMAGE="${REGISTRY}/${NAMESPACE}/${PROJECT}/vpn"
|
||||
|
||||
TAG="${1:-latest}"
|
||||
# Parse arguments
|
||||
TARGET="${1:-app}"
|
||||
TAG="${2:-latest}"
|
||||
SKIP_BUILD=false
|
||||
if [[ "${2:-}" == "--no-build" ]]; then
|
||||
if [[ "${3:-}" == "--no-build" ]]; then
|
||||
SKIP_BUILD=true
|
||||
fi
|
||||
|
||||
# Validate target
|
||||
if [[ "${TARGET}" != "app" && "${TARGET}" != "vpn" && "${TARGET}" != "all" ]]; then
|
||||
echo "ERROR: Invalid target '${TARGET}'. Must be one of: app, vpn, all" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
@@ -36,62 +48,93 @@ PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "============================================"
|
||||
echo " Aniworld — Build & Push"
|
||||
echo " AniWorld — Build & Push"
|
||||
echo " Engine : ${ENGINE}"
|
||||
echo " Registry : ${REGISTRY}"
|
||||
echo " Target : ${TARGET}"
|
||||
echo " Tag : ${TAG}"
|
||||
echo "============================================"
|
||||
|
||||
command -v podman &>/dev/null || err "podman is not installed."
|
||||
|
||||
if ! podman login --get-login "${REGISTRY}" &>/dev/null; then
|
||||
err "Not logged in. Run:\n podman login ${REGISTRY}"
|
||||
fi
|
||||
log "Logging in to ${REGISTRY}"
|
||||
"${ENGINE}" login "${REGISTRY}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "${SKIP_BUILD}" == false ]]; then
|
||||
build_app() {
|
||||
log "Building app image → ${APP_IMAGE}:${TAG}"
|
||||
podman build \
|
||||
"${ENGINE}" build \
|
||||
-t "${APP_IMAGE}:${TAG}" \
|
||||
-f "${SCRIPT_DIR}/Dockerfile.app" \
|
||||
"${PROJECT_ROOT}"
|
||||
}
|
||||
|
||||
log "Building VPN image → ${VPN_IMAGE}:${TAG}"
|
||||
podman build \
|
||||
build_vpn() {
|
||||
log "Building vpn image → ${VPN_IMAGE}:${TAG}"
|
||||
"${ENGINE}" build \
|
||||
-t "${VPN_IMAGE}:${TAG}" \
|
||||
-f "${SCRIPT_DIR}/Containerfile" \
|
||||
"${SCRIPT_DIR}"
|
||||
}
|
||||
|
||||
if [[ "${SKIP_BUILD}" == false ]]; then
|
||||
case "${TARGET}" in
|
||||
app) build_app ;;
|
||||
vpn) build_vpn ;;
|
||||
all) build_app; build_vpn ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Push
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Pushing ${APP_IMAGE}:${TAG}"
|
||||
podman push "${APP_IMAGE}:${TAG}"
|
||||
push_app() {
|
||||
log "Pushing ${APP_IMAGE}:${TAG}"
|
||||
"${ENGINE}" push "${APP_IMAGE}:${TAG}"
|
||||
}
|
||||
|
||||
log "Pushing ${VPN_IMAGE}:${TAG}"
|
||||
podman push "${VPN_IMAGE}:${TAG}"
|
||||
push_vpn() {
|
||||
log "Pushing ${VPN_IMAGE}:${TAG}"
|
||||
"${ENGINE}" push "${VPN_IMAGE}:${TAG}"
|
||||
}
|
||||
|
||||
case "${TARGET}" in
|
||||
app) push_app ;;
|
||||
vpn) push_vpn ;;
|
||||
all) push_app; push_vpn ;;
|
||||
esac
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " ✅ Push complete!"
|
||||
echo " Push complete!"
|
||||
echo ""
|
||||
echo " Images:"
|
||||
echo " ${APP_IMAGE}:${TAG}"
|
||||
echo " ${VPN_IMAGE}:${TAG}"
|
||||
case "${TARGET}" in
|
||||
app) echo " ${APP_IMAGE}:${TAG}" ;;
|
||||
vpn) echo " ${VPN_IMAGE}:${TAG}" ;;
|
||||
all) echo " ${APP_IMAGE}:${TAG}"; echo " ${VPN_IMAGE}:${TAG}" ;;
|
||||
esac
|
||||
echo ""
|
||||
echo " Deploy on server:"
|
||||
echo " podman login ${REGISTRY}"
|
||||
echo " podman-compose -f podman-compose.prod.yml pull"
|
||||
echo " podman-compose -f podman-compose.prod.yml up -d"
|
||||
echo " ${ENGINE} login ${REGISTRY}"
|
||||
echo " ${ENGINE} compose -f Docker/podman-compose.prod.yml pull"
|
||||
echo " ${ENGINE} compose -f Docker/podman-compose.prod.yml up -d"
|
||||
echo "============================================"
|
||||
129
Docker/release.sh
Normal file
129
Docker/release.sh
Normal file
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Bump the project version and push images to the registry.
|
||||
#
|
||||
# Usage:
|
||||
# ./release.sh
|
||||
#
|
||||
# The current version is stored in VERSION (next to this script).
|
||||
# You will be asked whether to bump major, minor, or patch.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERSION_FILE="${SCRIPT_DIR}/VERSION"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read current version
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ ! -f "${VERSION_FILE}" ]]; then
|
||||
echo "0.0.0" > "${VERSION_FILE}"
|
||||
fi
|
||||
|
||||
CURRENT="$(cat "${VERSION_FILE}")"
|
||||
# Strip leading 'v' for arithmetic
|
||||
VERSION="${CURRENT#v}"
|
||||
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "${VERSION}"
|
||||
|
||||
echo "============================================"
|
||||
echo " AniWorld — Release"
|
||||
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "Which image(s) would you like to release?"
|
||||
echo " 1) app (Dockerfile.app)"
|
||||
echo " 2) vpn (Containerfile)"
|
||||
echo " 3) all (both images)"
|
||||
echo ""
|
||||
read -rp "Enter choice [1/2/3]: " TARGET_CHOICE
|
||||
|
||||
case "${TARGET_CHOICE}" in
|
||||
1) TARGET="app" ;;
|
||||
2) TARGET="vpn" ;;
|
||||
3) TARGET="all" ;;
|
||||
*)
|
||||
echo "Invalid choice. Aborting." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "How would you like to bump the version?"
|
||||
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.$((PATCH + 1)))"
|
||||
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.$((MINOR + 1)).0)"
|
||||
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH} → v$((MAJOR + 1)).0.0)"
|
||||
echo ""
|
||||
read -rp "Enter choice [1/2/3]: " CHOICE
|
||||
|
||||
case "${CHOICE}" in
|
||||
1) NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))" ;;
|
||||
2) NEW_TAG="v${MAJOR}.$((MINOR + 1)).0" ;;
|
||||
3) NEW_TAG="v$((MAJOR + 1)).0.0" ;;
|
||||
*)
|
||||
echo "Invalid choice. Aborting." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "New version: ${NEW_TAG}"
|
||||
echo "Target: ${TARGET}"
|
||||
read -rp "Confirm? [y/N]: " CONFIRM
|
||||
if [[ ! "${CONFIRM}" =~ ^[yY]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Write new version
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "${NEW_TAG}" > "${VERSION_FILE}"
|
||||
echo "Version file updated → ${VERSION_FILE}"
|
||||
|
||||
# Keep root package.json in sync.
|
||||
FRONT_VERSION="${NEW_TAG#v}"
|
||||
FRONT_PKG="${SCRIPT_DIR}/../package.json"
|
||||
if [[ -f "${FRONT_PKG}" ]]; then
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_PKG}"
|
||||
echo "package.json version updated → ${FRONT_VERSION}"
|
||||
else
|
||||
echo "Warning: package.json not found, skipping package.json version sync" >&2
|
||||
fi
|
||||
|
||||
# Keep root pyproject.toml in sync.
|
||||
BACKEND_PYPROJECT="${SCRIPT_DIR}/../pyproject.toml"
|
||||
if [[ -f "${BACKEND_PYPROJECT}" ]]; then
|
||||
# Update version under [project] section if present
|
||||
if grep -q '^\[project\]' "${BACKEND_PYPROJECT}"; then
|
||||
sed -i "/^\[project\]/,/^\[/ s/^version = \".*\"/version = \"${FRONT_VERSION}\"/" "${BACKEND_PYPROJECT}"
|
||||
else
|
||||
sed -i "s/^version = \".*\"/version = \"${FRONT_VERSION}\"/" "${BACKEND_PYPROJECT}"
|
||||
fi
|
||||
echo "pyproject.toml version updated → ${FRONT_VERSION}"
|
||||
else
|
||||
echo "Warning: pyproject.toml not found, skipping pyproject.toml version sync" >&2
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Push containers
|
||||
# ---------------------------------------------------------------------------
|
||||
bash "${SCRIPT_DIR}/push.sh" "${TARGET}" "${NEW_TAG}"
|
||||
bash "${SCRIPT_DIR}/push.sh" "${TARGET}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Git tag (local only; push after container build)
|
||||
# ---------------------------------------------------------------------------
|
||||
cd "${SCRIPT_DIR}/.."
|
||||
git add Docker/VERSION package.json pyproject.toml
|
||||
git commit -m "chore: bump version"
|
||||
git tag -a "${NEW_TAG}" -m "Release ${NEW_TAG}"
|
||||
echo "Local git commit + tag ${NEW_TAG} created."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Push git commits & tag
|
||||
# ---------------------------------------------------------------------------
|
||||
git push origin HEAD
|
||||
git push origin "${NEW_TAG}"
|
||||
echo "Git commit and tag ${NEW_TAG} pushed."
|
||||
@@ -6,23 +6,29 @@ Verifies:
|
||||
2. The container starts and becomes healthy.
|
||||
3. The public IP inside the VPN differs from the host IP.
|
||||
4. Kill switch blocks traffic when WireGuard is down.
|
||||
5. AllowedIPs routes are set dynamically from the config.
|
||||
|
||||
Requirements:
|
||||
- podman installed
|
||||
- Root/sudo (NET_ADMIN capability)
|
||||
- Root/sudo (NET_ADMIN capability) for container runtime tests
|
||||
- A valid WireGuard config at ./wg0.conf (or ./nl.conf)
|
||||
|
||||
Usage:
|
||||
# Build-only test (no sudo needed):
|
||||
python3 -m pytest test_vpn.py::TestVPNImage::test_00_build_image -v
|
||||
|
||||
# Full integration test (requires sudo):
|
||||
sudo python3 -m pytest test_vpn.py -v
|
||||
# or
|
||||
sudo python3 test_vpn.py
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
import os
|
||||
|
||||
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
|
||||
|
||||
|
||||
def is_root() -> bool:
|
||||
"""Check if running as root."""
|
||||
return os.geteuid() == 0
|
||||
|
||||
|
||||
def run(cmd: list[str], timeout: int = 30, check: bool = True) -> subprocess.CompletedProcess:
|
||||
"""Run a command and return the result."""
|
||||
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=check)
|
||||
@@ -55,6 +66,7 @@ class TestVPNImage(unittest.TestCase):
|
||||
"""Test suite for the WireGuard VPN container."""
|
||||
|
||||
host_ip: str = ""
|
||||
container_id: str = ""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
@@ -84,6 +96,12 @@ class TestVPNImage(unittest.TestCase):
|
||||
assert result.returncode == 0, f"Build failed:\n{result.stderr}"
|
||||
logger.info("Image built successfully.")
|
||||
|
||||
# Skip container runtime tests if not root
|
||||
if not is_root():
|
||||
logger.warning("Not running as root — skipping container runtime tests.")
|
||||
cls.container_id = ""
|
||||
return
|
||||
|
||||
# ── 3. Start the container ──
|
||||
logger.info("Starting container '%s'...", CONTAINER_NAME)
|
||||
result = run(
|
||||
@@ -120,6 +138,8 @@ class TestVPNImage(unittest.TestCase):
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""Stop and remove the container."""
|
||||
if not is_root():
|
||||
return
|
||||
logger.info("Cleaning up test container...")
|
||||
subprocess.run(["podman", "rm", "-f", CONTAINER_NAME], capture_output=True, check=False)
|
||||
logger.info("Cleanup complete.")
|
||||
@@ -144,10 +164,22 @@ class TestVPNImage(unittest.TestCase):
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
def _skip_if_not_root(self):
|
||||
"""Skip test if not running as root."""
|
||||
if not is_root():
|
||||
self.skipTest("This test requires root/sudo privileges")
|
||||
|
||||
# ── Tests ────────────────────────────────────────────────
|
||||
|
||||
def test_00_build_image(self):
|
||||
"""The image builds successfully."""
|
||||
# This is already verified in setUpClass, just confirm here
|
||||
result = run(["podman", "images", "--format", "{{.Repository}}:{{.Tag}}"])
|
||||
self.assertIn(IMAGE_NAME, result.stdout, "Image was not built")
|
||||
|
||||
def test_01_ip_differs_from_host(self):
|
||||
"""Public IP inside VPN is different from host IP."""
|
||||
self._skip_if_not_root()
|
||||
vpn_ip = self._get_vpn_ip()
|
||||
logger.info("VPN public IP: %s", vpn_ip)
|
||||
logger.info("Host public IP: %s", self.host_ip)
|
||||
@@ -161,12 +193,42 @@ class TestVPNImage(unittest.TestCase):
|
||||
|
||||
def test_02_wireguard_interface_exists(self):
|
||||
"""The wg0 interface is present in the container."""
|
||||
self._skip_if_not_root()
|
||||
result = podman_exec(CONTAINER_NAME, ["wg", "show", "wg0"])
|
||||
self.assertEqual(result.returncode, 0, f"wg show failed:\n{result.stderr}")
|
||||
self.assertIn("peer", result.stdout.lower(), "No peer information in wg show output")
|
||||
# AllowedIPs should be present in wg show output
|
||||
self.assertIn("allowed ips", result.stdout.lower(), "AllowedIPs not found in wg show output")
|
||||
|
||||
def test_03_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)."""
|
||||
self._skip_if_not_root()
|
||||
# Bring down the WireGuard interface by deleting it
|
||||
down_result = podman_exec(CONTAINER_NAME, ["ip", "link", "del", "wg0"], timeout=10)
|
||||
self.assertEqual(down_result.returncode, 0, f"ip link del wg0 failed:\n{down_result.stderr}")
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
[Interface]
|
||||
PrivateKey = iO5spIue/6ciwUoR95hYtuxdtQxV/Q9EOoQ/jHe18kM=
|
||||
Address = 10.2.0.2/32
|
||||
DNS = 10.2.0.1
|
||||
PrivateKey = EPRa2f/v72LvIXAY4yqIRJifsSb+nCcYHqC2rwA94UI=
|
||||
Address = 100.64.244.78/32
|
||||
DNS = 198.18.0.1,198.18.0.2
|
||||
|
||||
# Route zum VPN-Server direkt über dein lokales Netz
|
||||
PostUp = ip route add 91.148.236.64 via 192.168.178.1 dev wlp4s0f0
|
||||
PostUp = ip route add 192.168.178.0/24 via 192.168.178.1 dev wlp4s0f0
|
||||
PostDown = ip route del 91.148.236.64 via 192.168.178.1 dev wlp4s0f0
|
||||
PostDown = ip route del 192.168.178.0/24 via 192.168.178.1 dev wlp4s0f0
|
||||
|
||||
[Peer]
|
||||
PublicKey = J4XVdtoBVc/EoI2Yk673Oes97WMnQSH5KfamZNjtM2s=
|
||||
PublicKey = KgTUh3KLijVluDvNpzDCJJfrJ7EyLzYLmdHCksG4sRg=
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 185.183.34.149:51820
|
||||
Endpoint = 91.148.236.64:51820
|
||||
PersistentKeepalive = 25
|
||||
|
||||
|
||||
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
|
||||
47
docs/key
47
docs/key
@@ -2,3 +2,50 @@ API key : 299ae8f630a31bda814263c551361448
|
||||
|
||||
/mnt/server/serien/Serien/
|
||||
|
||||
{
|
||||
"name": "Aniworld",
|
||||
"data_dir": "data",
|
||||
"scheduler": {
|
||||
"enabled": true,
|
||||
"interval_minutes": 60,
|
||||
"schedule_time": "03:00",
|
||||
"schedule_days": [
|
||||
"mon",
|
||||
"tue",
|
||||
"wed",
|
||||
"thu",
|
||||
"fri",
|
||||
"sat",
|
||||
"sun"
|
||||
],
|
||||
"auto_download_after_rescan": true,
|
||||
"folder_scan_enabled": true
|
||||
},
|
||||
"logging": {
|
||||
"level": "INFO",
|
||||
"file": null,
|
||||
"max_bytes": null,
|
||||
"backup_count": 3
|
||||
},
|
||||
"backup": {
|
||||
"enabled": false,
|
||||
"path": "data/backups",
|
||||
"keep_days": 30
|
||||
},
|
||||
"nfo": {
|
||||
"tmdb_api_key": "9bc3e547caff878615cbdba2cc421d37",
|
||||
"auto_create": true,
|
||||
"update_on_scan": true,
|
||||
"download_poster": true,
|
||||
"download_logo": true,
|
||||
"download_fanart": true,
|
||||
"image_size": "original"
|
||||
},
|
||||
"other": {
|
||||
"master_password_hash": "$pbkdf2-sha256$29000$HQNASKk1xpgTAgAgJGRMaQ$73TOCCM0UEZONyNXQEPa3SmIoXeG6C1l5mMFDNgYfMQ",
|
||||
"anime_directory": "/data"
|
||||
},
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aniworld-web",
|
||||
"version": "0.0.1",
|
||||
"version": "1.1.9",
|
||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -445,9 +445,12 @@ class SeriesApp:
|
||||
try:
|
||||
def download_progress_handler(progress_info):
|
||||
"""Handle download progress events from loader."""
|
||||
logger.debug(
|
||||
"download_progress_handler called with: %s", progress_info
|
||||
)
|
||||
# Throttle progress logging to avoid spam
|
||||
status = progress_info.get("status", "")
|
||||
if status in ("downloading", "finished"):
|
||||
logger.debug(
|
||||
"download_progress_handler called with: %s", progress_info
|
||||
)
|
||||
|
||||
downloaded = progress_info.get('downloaded_bytes', 0)
|
||||
total_bytes = (
|
||||
|
||||
@@ -271,7 +271,11 @@ class Serie:
|
||||
'Dororo (2025)'
|
||||
"""
|
||||
if self._year:
|
||||
return f"{self._name} ({self._year})"
|
||||
import re
|
||||
year_suffix = f" ({self._year})"
|
||||
# Strip ALL trailing year suffixes before appending to prevent duplication
|
||||
clean_name = re.sub(r'(\s*\(\d{4}\))+\s*$', '', self._name).strip()
|
||||
return f"{clean_name}{year_suffix}"
|
||||
return self._name
|
||||
|
||||
@property
|
||||
|
||||
@@ -331,6 +331,7 @@ class AniworldLoader(Loader):
|
||||
'no_warnings': True,
|
||||
'progress_with_newline': False,
|
||||
'nocheckcertificate': True,
|
||||
'logger': logger,
|
||||
'progress_hooks': [events_progress_hook],
|
||||
}
|
||||
|
||||
@@ -339,7 +340,7 @@ class AniworldLoader(Loader):
|
||||
logger.debug("Using custom headers for download")
|
||||
|
||||
try:
|
||||
logger.debug("Starting YoutubeDL download")
|
||||
logger.info("Starting download: %s", output_file)
|
||||
logger.debug("Download link: %s...", link[:100])
|
||||
logger.debug("YDL options: %s", ydl_opts)
|
||||
|
||||
|
||||
@@ -566,6 +566,7 @@ class EnhancedAniWorldLoader(Loader):
|
||||
"nocheckcertificate": True,
|
||||
"socket_timeout": self.download_timeout,
|
||||
"http_chunk_size": 1024 * 1024, # 1MB chunks
|
||||
"logger": self.logger,
|
||||
}
|
||||
if headers:
|
||||
ydl_opts['http_headers'] = headers
|
||||
|
||||
@@ -120,6 +120,37 @@ def nfo_needs_repair(nfo_path: Path) -> bool:
|
||||
return bool(find_missing_tags(nfo_path))
|
||||
|
||||
|
||||
def _read_tmdb_id(nfo_path: Path) -> int | None:
|
||||
"""Return the TMDB ID stored in an existing NFO, or ``None``.
|
||||
|
||||
Checks both ``<tmdbid>`` and ``<uniqueid type="tmdb">`` elements.
|
||||
|
||||
Args:
|
||||
nfo_path: Absolute path to the ``tvshow.nfo`` file.
|
||||
|
||||
Returns:
|
||||
Integer TMDB ID, or ``None`` if not found or not parseable.
|
||||
"""
|
||||
if not nfo_path.exists():
|
||||
return None
|
||||
try:
|
||||
root = etree.parse(str(nfo_path)).getroot()
|
||||
|
||||
for uniqueid in root.findall(".//uniqueid"):
|
||||
if uniqueid.get("type") == "tmdb" and uniqueid.text:
|
||||
return int(uniqueid.text)
|
||||
|
||||
tmdbid_elem = root.find(".//tmdbid")
|
||||
if tmdbid_elem is not None and tmdbid_elem.text:
|
||||
return int(tmdbid_elem.text)
|
||||
|
||||
except (etree.XMLSyntaxError, ValueError):
|
||||
pass
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class NfoRepairService:
|
||||
"""Service that detects and repairs incomplete tvshow.nfo files.
|
||||
|
||||
|
||||
@@ -83,11 +83,12 @@ class NFOService:
|
||||
>>> _extract_year_from_name("Attack on Titan")
|
||||
("Attack on Titan", None)
|
||||
"""
|
||||
# Match year in parentheses at the end: (YYYY)
|
||||
# Match the last year in parentheses at the end: (YYYY)
|
||||
match = re.search(r'\((\d{4})\)\s*$', serie_name)
|
||||
if match:
|
||||
year = int(match.group(1))
|
||||
clean_name = serie_name[:match.start()].strip()
|
||||
# Strip ALL trailing year suffixes to get a fully clean name
|
||||
clean_name = re.sub(r'(\s*\(\d{4}\))+\s*$', '', serie_name).strip()
|
||||
return clean_name, year
|
||||
return serie_name, None
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ Example:
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -63,6 +64,11 @@ class TMDBClient:
|
||||
self.max_connections = max_connections
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
self._cache: Dict[str, Any] = {}
|
||||
# TMDB allows ~40 req/s; use 30 concurrent + per-second throttle to stay safe
|
||||
self._semaphore = asyncio.Semaphore(30)
|
||||
self._rate_limit_lock = asyncio.Lock()
|
||||
self._request_timestamps: List[float] = []
|
||||
self._max_requests_per_second = 35 # Stay under TMDB's ~40/s limit
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
@@ -83,7 +89,7 @@ class TMDBClient:
|
||||
self,
|
||||
endpoint: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
max_retries: int = 3
|
||||
max_retries: int = 5
|
||||
) -> Dict[str, Any]:
|
||||
"""Make an async request to TMDB API with retries.
|
||||
|
||||
@@ -110,58 +116,82 @@ class TMDBClient:
|
||||
logger.debug("Cache hit for %s", endpoint)
|
||||
return self._cache[cache_key]
|
||||
|
||||
delay = 1
|
||||
delay = 2
|
||||
last_error = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Re-ensure session before each attempt in case it was closed
|
||||
await self._ensure_session()
|
||||
|
||||
if self.session is None:
|
||||
raise TMDBAPIError("Session is not available")
|
||||
|
||||
logger.debug("TMDB API request: %s (attempt %s)", endpoint, attempt + 1)
|
||||
async with self.session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
|
||||
if resp.status == 401:
|
||||
raise TMDBAPIError("Invalid TMDB API key")
|
||||
elif resp.status == 404:
|
||||
raise TMDBAPIError(f"Resource not found: {endpoint}")
|
||||
elif resp.status == 429:
|
||||
# Rate limit - wait longer
|
||||
retry_after = int(resp.headers.get('Retry-After', delay * 2))
|
||||
logger.warning("Rate limited, waiting %ss", retry_after)
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
self._cache[cache_key] = data
|
||||
return data
|
||||
|
||||
except asyncio.TimeoutError as e:
|
||||
last_error = e
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning("Request timeout (attempt %s), retrying in %ss", attempt + 1, delay)
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
else:
|
||||
logger.error("Request timed out after %s attempts", max_retries)
|
||||
|
||||
except (aiohttp.ClientError, AttributeError) as e:
|
||||
last_error = e
|
||||
# If connector/session was closed, try to recreate it
|
||||
if "Connector is closed" in str(e) or isinstance(e, AttributeError):
|
||||
logger.warning("Session issue detected, recreating session: %s", e)
|
||||
self.session = None
|
||||
# Rate limiting: ensure we don't exceed ~35 requests/second
|
||||
async with self._rate_limit_lock:
|
||||
now = time.monotonic()
|
||||
# Remove timestamps older than 1 second
|
||||
self._request_timestamps = [
|
||||
ts for ts in self._request_timestamps if now - ts < 1.0
|
||||
]
|
||||
if len(self._request_timestamps) >= self._max_requests_per_second:
|
||||
sleep_time = 1.0 - (now - self._request_timestamps[0])
|
||||
if sleep_time > 0:
|
||||
logger.debug("Rate throttling: waiting %.2fs", sleep_time)
|
||||
await asyncio.sleep(sleep_time)
|
||||
self._request_timestamps.append(time.monotonic())
|
||||
|
||||
async with self._semaphore:
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Re-ensure session before each attempt in case it was closed
|
||||
await self._ensure_session()
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning("Request failed (attempt %s): %s, retrying in %ss", attempt + 1, e, delay)
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
else:
|
||||
logger.error("Request failed after %s attempts: %s", max_retries, e)
|
||||
|
||||
if self.session is None:
|
||||
raise TMDBAPIError("Session is not available")
|
||||
|
||||
logger.debug("TMDB API request: %s (attempt %s)", endpoint, attempt + 1)
|
||||
async with self.session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
|
||||
if resp.status == 401:
|
||||
raise TMDBAPIError("Invalid TMDB API key")
|
||||
elif resp.status == 404:
|
||||
raise TMDBAPIError(f"Resource not found: {endpoint}")
|
||||
elif resp.status == 429:
|
||||
# Rate limit - wait longer with exponential backoff
|
||||
retry_after = int(resp.headers.get('Retry-After', max(delay * 2, 10)))
|
||||
logger.warning("Rate limited, waiting %ss", retry_after)
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
self._cache[cache_key] = data
|
||||
return data
|
||||
|
||||
except asyncio.TimeoutError as e:
|
||||
last_error = e
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning("Request timeout (attempt %s), retrying in %ss", attempt + 1, delay)
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, 30)
|
||||
else:
|
||||
logger.error("Request timed out after %s attempts", max_retries)
|
||||
|
||||
except (aiohttp.ClientError, AttributeError) as e:
|
||||
last_error = e
|
||||
# If connector/session was closed, try to recreate it
|
||||
if "Connector is closed" in str(e) or isinstance(e, AttributeError):
|
||||
logger.warning("Session issue detected, recreating session: %s", e)
|
||||
self.session = None
|
||||
await self._ensure_session()
|
||||
|
||||
# DNS / host-unreachable errors are not transient — abort immediately
|
||||
error_str = str(e)
|
||||
if "name resolution" in error_str.lower() or (
|
||||
isinstance(e, aiohttp.ClientConnectorError) and
|
||||
"Cannot connect to host" in error_str
|
||||
):
|
||||
logger.error("Non-transient connection error, aborting retries: %s", e)
|
||||
raise TMDBAPIError(f"Request failed after {attempt + 1} attempts: {e}") from e
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning("Request failed (attempt %s): %s, retrying in %ss", attempt + 1, e, delay)
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, 30)
|
||||
else:
|
||||
logger.error("Request failed after %s attempts: %s", max_retries, e)
|
||||
|
||||
raise TMDBAPIError(f"Request failed after {max_retries} attempts: {last_error}")
|
||||
|
||||
|
||||
@@ -730,7 +730,11 @@ async def add_series(
|
||||
|
||||
# Create folder name with year if available
|
||||
if year:
|
||||
folder_name_with_year = f"{name} ({year})"
|
||||
year_suffix = f" ({year})"
|
||||
if name.endswith(year_suffix):
|
||||
folder_name_with_year = name
|
||||
else:
|
||||
folder_name_with_year = f"{name}{year_suffix}"
|
||||
else:
|
||||
folder_name_with_year = name
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ def get_settings() -> Union[DevelopmentSettings, ProductionSettings]:
|
||||
Example:
|
||||
>>> settings = get_settings()
|
||||
>>> print(settings.log_level)
|
||||
DEBUG
|
||||
INFO
|
||||
"""
|
||||
if ENVIRONMENT in {"development", "testing"}:
|
||||
return get_development_settings()
|
||||
|
||||
@@ -215,7 +215,7 @@ class DevelopmentSettings(BaseSettings):
|
||||
@property
|
||||
def debug_enabled(self) -> bool:
|
||||
"""Check if debug mode is enabled."""
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def reload_enabled(self) -> bool:
|
||||
|
||||
@@ -66,6 +66,9 @@ def _parse_nfo_title_and_year(nfo_path: Path) -> Tuple[Optional[str], Optional[s
|
||||
def _compute_expected_folder_name(title: str, year: str) -> str:
|
||||
"""Compute the expected folder name from title and year.
|
||||
|
||||
Removes any existing year suffixes (e.g., "(2021)") before adding the
|
||||
canonical one to prevent duplication across multiple folder rename runs.
|
||||
|
||||
Args:
|
||||
title: Series title from NFO.
|
||||
year: Release year from NFO.
|
||||
@@ -73,7 +76,15 @@ def _compute_expected_folder_name(title: str, year: str) -> str:
|
||||
Returns:
|
||||
Sanitised folder name in the format ``"{title} ({year})"``.
|
||||
"""
|
||||
raw_name = f"{title} ({year})"
|
||||
import re
|
||||
|
||||
# Remove all trailing year suffixes to prevent duplication.
|
||||
# This handles cases where the title already contains one or more years.
|
||||
# Regex pattern: matches one or more " (YYYY)" at the end of the string
|
||||
clean_title = re.sub(r'(\s*\(\d{4}\))+\s*$', '', title).strip()
|
||||
|
||||
year_suffix = f" ({year})"
|
||||
raw_name = f"{clean_title}{year_suffix}"
|
||||
return sanitize_folder_name(raw_name)
|
||||
|
||||
|
||||
|
||||
@@ -334,6 +334,25 @@ async def test_add_series_sanitizes_folder_name(authenticated_client):
|
||||
assert "?" not in folder
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_series_does_not_duplicate_year(authenticated_client):
|
||||
"""Test that add_series doesn't duplicate year when name already contains it."""
|
||||
response = await authenticated_client.post(
|
||||
"/api/anime/add",
|
||||
json={
|
||||
"link": "https://aniworld.to/anime/stream/eighty-six",
|
||||
"name": "86 Eighty Six (2021)"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
|
||||
# Folder should contain year only once
|
||||
folder = data["folder"]
|
||||
assert folder.count("(2021)") == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_series_returns_missing_episodes(authenticated_client):
|
||||
"""Test that add_series returns loading progress info."""
|
||||
|
||||
314
tests/integration/test_add_anime_nfo_content.py
Normal file
314
tests/integration/test_add_anime_nfo_content.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""Integration test: add an anime and verify NFO contains required information.
|
||||
|
||||
This test adds 'Sacrificial Princess And The King Of Beasts' and verifies
|
||||
that the generated tvshow.nfo contains all required tags including plot,
|
||||
outline, title, year, etc.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from lxml import etree
|
||||
|
||||
from src.core.services.nfo_service import NFOService
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock TMDB data for "Sacrificial Princess And The King Of Beasts"
|
||||
# ---------------------------------------------------------------------------
|
||||
MOCK_TMDB_DATA = {
|
||||
"id": 222093,
|
||||
"name": "Sacrificial Princess and the King of Beasts",
|
||||
"original_name": "贄姫と獣の王",
|
||||
"overview": (
|
||||
"A girl is offered as a sacrifice to a beastly king, "
|
||||
"but instead of being eaten, she becomes his bride."
|
||||
),
|
||||
"tagline": "A tale of love between a sacrifice and a beast king.",
|
||||
"first_air_date": "2023-04-20",
|
||||
"vote_average": 7.5,
|
||||
"vote_count": 150,
|
||||
"status": "Ended",
|
||||
"episode_run_time": [24],
|
||||
"genres": [
|
||||
{"id": 16, "name": "Animation"},
|
||||
{"id": 10749, "name": "Romance"},
|
||||
],
|
||||
"networks": [{"id": 1, "name": "TBS"}],
|
||||
"origin_country": ["JP"],
|
||||
"poster_path": "/poster.jpg",
|
||||
"backdrop_path": "/backdrop.jpg",
|
||||
"external_ids": {"imdb_id": "tt19896734", "tvdb_id": 421737},
|
||||
"credits": {
|
||||
"cast": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Test Actor",
|
||||
"character": "Sariphi",
|
||||
"profile_path": "/actor.jpg",
|
||||
}
|
||||
]
|
||||
},
|
||||
"images": {"logos": [{"file_path": "/logo.png"}]},
|
||||
"seasons": [{"season_number": 1, "name": "Season 1"}],
|
||||
}
|
||||
|
||||
MOCK_CONTENT_RATINGS = {
|
||||
"results": [
|
||||
{"iso_3166_1": "DE", "rating": "12"},
|
||||
{"iso_3166_1": "US", "rating": "TV-14"},
|
||||
]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Required XML tags that must exist and be non-empty after creation
|
||||
# ---------------------------------------------------------------------------
|
||||
REQUIRED_SINGLE_TAGS = [
|
||||
"title",
|
||||
"originaltitle",
|
||||
"sorttitle",
|
||||
"year",
|
||||
"plot",
|
||||
"outline",
|
||||
"runtime",
|
||||
"premiered",
|
||||
"status",
|
||||
"tmdbid",
|
||||
"imdbid",
|
||||
"tvdbid",
|
||||
"dateadded",
|
||||
"watched",
|
||||
"mpaa",
|
||||
"tagline",
|
||||
]
|
||||
|
||||
REQUIRED_MULTI_TAGS = [
|
||||
"genre",
|
||||
"studio",
|
||||
"country",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anime_dir(tmp_path: Path) -> Path:
|
||||
"""Temporary anime root directory."""
|
||||
d = tmp_path / "anime"
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nfo_service(anime_dir: Path) -> NFOService:
|
||||
"""NFOService pointing at the temp directory."""
|
||||
return NFOService(
|
||||
tmdb_api_key="test_api_key",
|
||||
anime_directory=str(anime_dir),
|
||||
image_size="w500",
|
||||
auto_create=True,
|
||||
)
|
||||
|
||||
|
||||
class TestAddAnimeNFOContent:
|
||||
"""Test that adding an anime produces an NFO with required information."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_anime_nfo_contains_required_tags(
|
||||
self,
|
||||
nfo_service: NFOService,
|
||||
anime_dir: Path,
|
||||
) -> None:
|
||||
"""Add 'Sacrificial Princess And The King Of Beasts' and verify NFO.
|
||||
|
||||
Steps:
|
||||
1. Create the series folder on disk.
|
||||
2. Mock TMDB API responses.
|
||||
3. Call create_tvshow_nfo to generate the NFO.
|
||||
4. Parse the resulting XML and assert every required tag is present
|
||||
and non-empty.
|
||||
"""
|
||||
series_key = "sacrificial-princess-and-the-king-of-beasts"
|
||||
series_name = "Sacrificial Princess And The King Of Beasts"
|
||||
series_folder = f"{series_name} (2023)"
|
||||
|
||||
# Step 1: Create series folder
|
||||
series_path = anime_dir / series_folder
|
||||
series_path.mkdir()
|
||||
|
||||
# Step 2: Mock TMDB API calls
|
||||
with patch.object(
|
||||
nfo_service.tmdb_client,
|
||||
"search_tv_show",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_search, patch.object(
|
||||
nfo_service.tmdb_client,
|
||||
"get_tv_show_details",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_details, patch.object(
|
||||
nfo_service.tmdb_client,
|
||||
"get_tv_show_content_ratings",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_ratings, patch.object(
|
||||
nfo_service.image_downloader,
|
||||
"download_all_media",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_download:
|
||||
|
||||
mock_search.return_value = {
|
||||
"results": [
|
||||
{
|
||||
"id": 222093,
|
||||
"name": series_name,
|
||||
"first_air_date": "2023-04-20",
|
||||
"overview": (
|
||||
"A girl is offered as a sacrifice to a beastly king..."
|
||||
),
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_details.return_value = MOCK_TMDB_DATA
|
||||
mock_ratings.return_value = MOCK_CONTENT_RATINGS
|
||||
mock_download.return_value = {
|
||||
"poster": True,
|
||||
"logo": True,
|
||||
"fanart": True,
|
||||
}
|
||||
|
||||
# Step 3: Create NFO
|
||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||
serie_name=series_name,
|
||||
serie_folder=series_folder,
|
||||
year=2023,
|
||||
download_poster=True,
|
||||
download_logo=True,
|
||||
download_fanart=True,
|
||||
)
|
||||
|
||||
# Verify NFO was created
|
||||
assert nfo_path.exists(), f"NFO file not created at {nfo_path}"
|
||||
assert nfo_path.name == "tvshow.nfo"
|
||||
|
||||
# Step 4: Parse NFO XML and verify required tags
|
||||
nfo_content = nfo_path.read_text(encoding="utf-8")
|
||||
root = etree.fromstring(nfo_content.encode("utf-8"))
|
||||
|
||||
missing: list[str] = []
|
||||
for tag in REQUIRED_SINGLE_TAGS:
|
||||
elem = root.find(f".//{tag}")
|
||||
if elem is None or not (elem.text or "").strip():
|
||||
missing.append(tag)
|
||||
|
||||
for tag in REQUIRED_MULTI_TAGS:
|
||||
elems = root.findall(f".//{tag}")
|
||||
if not elems or not any((e.text or "").strip() for e in elems):
|
||||
missing.append(tag)
|
||||
|
||||
# At least one actor must be present
|
||||
actors = root.findall(".//actor/name")
|
||||
if not actors or not any((a.text or "").strip() for a in actors):
|
||||
missing.append("actor/name")
|
||||
|
||||
assert not missing, (
|
||||
f"Missing or empty required tags in NFO for '{series_name}':\n "
|
||||
+ "\n ".join(missing)
|
||||
+ f"\n\nFull NFO content:\n{nfo_content}"
|
||||
)
|
||||
|
||||
# Verify specific values for the requested anime
|
||||
assert root.findtext(".//title") == "Sacrificial Princess and the King of Beasts"
|
||||
assert root.findtext(".//year") == "2023"
|
||||
assert root.findtext(".//status") == "Ended"
|
||||
assert root.findtext(".//watched") == "false"
|
||||
assert root.findtext(".//tmdbid") == "222093"
|
||||
assert root.findtext(".//imdbid") == "tt19896734"
|
||||
assert root.findtext(".//tvdbid") == "421737"
|
||||
|
||||
# Plot and outline must be non-trivial
|
||||
plot = root.findtext(".//plot") or ""
|
||||
outline = root.findtext(".//outline") or ""
|
||||
assert len(plot) >= 10, f"plot too short: {plot!r}"
|
||||
assert len(outline) >= 10, f"outline too short: {outline!r}"
|
||||
|
||||
# Verify multi-value fields
|
||||
genres = [g.text for g in root.findall(".//genre") if g.text]
|
||||
assert "Animation" in genres
|
||||
assert "Romance" in genres
|
||||
|
||||
studios = [s.text for s in root.findall(".//studio") if s.text]
|
||||
assert "TBS" in studios
|
||||
|
||||
countries = [c.text for c in root.findall(".//country") if c.text]
|
||||
assert "JP" in countries
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_anime_nfo_has_plot_and_outline(
|
||||
self,
|
||||
nfo_service: NFOService,
|
||||
anime_dir: Path,
|
||||
) -> None:
|
||||
"""Specifically verify that plot and outline tags are populated.
|
||||
|
||||
This is a focused regression test ensuring the NFO always contains
|
||||
meaningful plot and outline data.
|
||||
"""
|
||||
series_name = "Sacrificial Princess And The King Of Beasts"
|
||||
series_folder = f"{series_name} (2023)"
|
||||
series_path = anime_dir / series_folder
|
||||
series_path.mkdir()
|
||||
|
||||
with patch.object(
|
||||
nfo_service.tmdb_client,
|
||||
"search_tv_show",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_search, patch.object(
|
||||
nfo_service.tmdb_client,
|
||||
"get_tv_show_details",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_details, patch.object(
|
||||
nfo_service.tmdb_client,
|
||||
"get_tv_show_content_ratings",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_ratings, patch.object(
|
||||
nfo_service.image_downloader,
|
||||
"download_all_media",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_download:
|
||||
|
||||
mock_search.return_value = {
|
||||
"results": [
|
||||
{
|
||||
"id": 222093,
|
||||
"name": series_name,
|
||||
"first_air_date": "2023-04-20",
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_details.return_value = MOCK_TMDB_DATA
|
||||
mock_ratings.return_value = MOCK_CONTENT_RATINGS
|
||||
mock_download.return_value = {"poster": False, "logo": False, "fanart": False}
|
||||
|
||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||
serie_name=series_name,
|
||||
serie_folder=series_folder,
|
||||
year=2023,
|
||||
download_poster=False,
|
||||
download_logo=False,
|
||||
download_fanart=False,
|
||||
)
|
||||
|
||||
assert nfo_path.exists()
|
||||
root = etree.parse(str(nfo_path)).getroot()
|
||||
|
||||
plot_elem = root.find(".//plot")
|
||||
outline_elem = root.find(".//outline")
|
||||
|
||||
assert plot_elem is not None, "<plot> tag missing from NFO"
|
||||
assert outline_elem is not None, "<outline> tag missing from NFO"
|
||||
|
||||
plot_text = (plot_elem.text or "").strip()
|
||||
outline_text = (outline_elem.text or "").strip()
|
||||
|
||||
assert plot_text, "<plot> tag is empty"
|
||||
assert outline_text, "<outline> tag is empty"
|
||||
assert "sacrifice" in plot_text.lower() or "beast" in plot_text.lower(), (
|
||||
f"plot does not contain expected content: {plot_text!r}"
|
||||
)
|
||||
429
tests/integration/test_sacrificial_princess_nfo.py
Normal file
429
tests/integration/test_sacrificial_princess_nfo.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""Integration test: add 'Sacrificial Princess And The King Of Beasts' and verify NFO completeness.
|
||||
|
||||
Simulates the production scenario where this anime is added and validates
|
||||
that the generated tvshow.nfo contains plot, outline, and all other required
|
||||
information. Also tests the repair path for an incomplete NFO.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from lxml import etree
|
||||
|
||||
from src.core.services.nfo_repair_service import (
|
||||
NfoRepairService,
|
||||
_read_tmdb_id,
|
||||
find_missing_tags,
|
||||
nfo_needs_repair,
|
||||
)
|
||||
from src.core.services.nfo_service import NFOService
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TMDB mock data matching production responses for this anime
|
||||
# ---------------------------------------------------------------------------
|
||||
SERIES_KEY = "sacrificial-princess-and-the-king-of-beasts"
|
||||
SERIES_NAME = "Sacrificial Princess And The King Of Beasts"
|
||||
SERIES_FOLDER = "Sacrificial Princess And The King Of Beasts (2023)"
|
||||
TMDB_ID = 222093
|
||||
|
||||
MOCK_TMDB_DETAILS = {
|
||||
"id": TMDB_ID,
|
||||
"name": "Sacrificial Princess and the King of Beasts",
|
||||
"original_name": "贄姫と獣の王",
|
||||
"overview": (
|
||||
"On the outskirts of the Demon King's realm lies a small village of "
|
||||
"humans who offer a sacrifice to the beast king every year. Sariphi, "
|
||||
"the latest sacrificial girl, expects to be devoured — but instead "
|
||||
"her fearless nature catches the king's attention and she becomes "
|
||||
"his unlikely companion."
|
||||
),
|
||||
"tagline": "A tale of love between a sacrifice and a beast king.",
|
||||
"first_air_date": "2023-04-20",
|
||||
"last_air_date": "2023-09-28",
|
||||
"vote_average": 7.5,
|
||||
"vote_count": 150,
|
||||
"status": "Ended",
|
||||
"episode_run_time": [24],
|
||||
"number_of_seasons": 1,
|
||||
"number_of_episodes": 24,
|
||||
"genres": [
|
||||
{"id": 16, "name": "Animation"},
|
||||
{"id": 10749, "name": "Romance"},
|
||||
{"id": 10765, "name": "Sci-Fi & Fantasy"},
|
||||
],
|
||||
"networks": [{"id": 160, "name": "TBS"}],
|
||||
"production_companies": [{"id": 291, "name": "J.C.Staff"}],
|
||||
"origin_country": ["JP"],
|
||||
"poster_path": "/sacrificial_poster.jpg",
|
||||
"backdrop_path": "/sacrificial_backdrop.jpg",
|
||||
"external_ids": {"imdb_id": "tt19896734", "tvdb_id": 421737},
|
||||
"credits": {
|
||||
"cast": [
|
||||
{
|
||||
"id": 2072089,
|
||||
"name": "Kana Hanazawa",
|
||||
"character": "Sariphi",
|
||||
"profile_path": "/hanazawa.jpg",
|
||||
"order": 0,
|
||||
},
|
||||
{
|
||||
"id": 1254783,
|
||||
"name": "Satoshi Hino",
|
||||
"character": "Leonhart",
|
||||
"profile_path": "/hino.jpg",
|
||||
"order": 1,
|
||||
},
|
||||
]
|
||||
},
|
||||
"images": {"logos": [{"file_path": "/sacrificial_logo.png"}]},
|
||||
"seasons": [
|
||||
{"season_number": 0, "name": "Specials"},
|
||||
{"season_number": 1, "name": "Season 1"},
|
||||
],
|
||||
}
|
||||
|
||||
MOCK_CONTENT_RATINGS = {
|
||||
"results": [
|
||||
{"iso_3166_1": "DE", "rating": "12"},
|
||||
{"iso_3166_1": "US", "rating": "TV-14"},
|
||||
]
|
||||
}
|
||||
|
||||
MOCK_SEARCH_RESULTS = {
|
||||
"results": [
|
||||
{
|
||||
"id": TMDB_ID,
|
||||
"name": "Sacrificial Princess and the King of Beasts",
|
||||
"first_air_date": "2023-04-20",
|
||||
"overview": (
|
||||
"On the outskirts of the Demon King's realm lies a small village "
|
||||
"of humans who offer a sacrifice to the beast king every year."
|
||||
),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tags that MUST be present and non-empty in a complete NFO
|
||||
# ---------------------------------------------------------------------------
|
||||
REQUIRED_TAGS = [
|
||||
"title",
|
||||
"originaltitle",
|
||||
"year",
|
||||
"plot",
|
||||
"outline",
|
||||
"runtime",
|
||||
"premiered",
|
||||
"status",
|
||||
"tmdbid",
|
||||
"imdbid",
|
||||
"genre",
|
||||
"studio",
|
||||
"country",
|
||||
"watched",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anime_dir(tmp_path: Path) -> Path:
|
||||
"""Temporary anime directory."""
|
||||
d = tmp_path / "anime"
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nfo_service(anime_dir: Path) -> NFOService:
|
||||
"""NFOService configured for the temp directory."""
|
||||
return NFOService(
|
||||
tmdb_api_key="test_api_key",
|
||||
anime_directory=str(anime_dir),
|
||||
image_size="w500",
|
||||
auto_create=True,
|
||||
)
|
||||
|
||||
|
||||
def _mock_tmdb_calls(nfo_service: NFOService):
|
||||
"""Context manager that patches all TMDB calls with mock data."""
|
||||
return _PatchContext(nfo_service)
|
||||
|
||||
|
||||
class _PatchContext:
|
||||
"""Helper to patch TMDB calls on an NFOService instance."""
|
||||
|
||||
def __init__(self, svc: NFOService):
|
||||
self._svc = svc
|
||||
self._patches = []
|
||||
|
||||
def __enter__(self):
|
||||
p1 = patch.object(
|
||||
self._svc.tmdb_client, "search_tv_show", new_callable=AsyncMock
|
||||
)
|
||||
p2 = patch.object(
|
||||
self._svc.tmdb_client, "get_tv_show_details", new_callable=AsyncMock
|
||||
)
|
||||
p3 = patch.object(
|
||||
self._svc.tmdb_client, "get_tv_show_content_ratings", new_callable=AsyncMock
|
||||
)
|
||||
p4 = patch.object(
|
||||
self._svc.image_downloader, "download_all_media", new_callable=AsyncMock
|
||||
)
|
||||
p5 = patch.object(
|
||||
self._svc.tmdb_client, "_ensure_session", new_callable=AsyncMock
|
||||
)
|
||||
p6 = patch.object(
|
||||
self._svc.tmdb_client, "close", new_callable=AsyncMock
|
||||
)
|
||||
|
||||
self._patches = [p1, p2, p3, p4, p5, p6]
|
||||
mocks = [p.start() for p in self._patches]
|
||||
|
||||
mocks[0].return_value = MOCK_SEARCH_RESULTS
|
||||
mocks[1].return_value = MOCK_TMDB_DETAILS
|
||||
mocks[2].return_value = MOCK_CONTENT_RATINGS
|
||||
mocks[3].return_value = {"poster": True, "logo": True, "fanart": True}
|
||||
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
for p in self._patches:
|
||||
p.stop()
|
||||
|
||||
|
||||
class TestSacrificialPrincessNFO:
|
||||
"""Tests for 'Sacrificial Princess And The King Of Beasts' NFO generation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_anime_creates_complete_nfo(
|
||||
self, nfo_service: NFOService, anime_dir: Path
|
||||
) -> None:
|
||||
"""Adding the anime produces an NFO with all required tags filled."""
|
||||
series_path = anime_dir / SERIES_FOLDER
|
||||
series_path.mkdir()
|
||||
|
||||
with _PatchContext(nfo_service):
|
||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||
serie_name=SERIES_NAME,
|
||||
serie_folder=SERIES_FOLDER,
|
||||
year=2023,
|
||||
download_poster=True,
|
||||
download_logo=True,
|
||||
download_fanart=True,
|
||||
)
|
||||
|
||||
assert nfo_path.exists(), f"NFO not created at {nfo_path}"
|
||||
|
||||
root = etree.parse(str(nfo_path)).getroot()
|
||||
missing = []
|
||||
for tag in REQUIRED_TAGS:
|
||||
elems = root.findall(f".//{tag}")
|
||||
if not elems or not any((e.text or "").strip() for e in elems):
|
||||
missing.append(tag)
|
||||
|
||||
# Actor check
|
||||
actors = root.findall(".//actor/name")
|
||||
if not actors or not any((a.text or "").strip() for a in actors):
|
||||
missing.append("actor/name")
|
||||
|
||||
assert not missing, (
|
||||
f"Missing or empty tags in NFO for '{SERIES_NAME}':\n"
|
||||
f" {', '.join(missing)}\n\n"
|
||||
f"NFO content:\n{nfo_path.read_text(encoding='utf-8')}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nfo_plot_and_outline_are_meaningful(
|
||||
self, nfo_service: NFOService, anime_dir: Path
|
||||
) -> None:
|
||||
"""Plot and outline must contain substantial descriptive text."""
|
||||
series_path = anime_dir / SERIES_FOLDER
|
||||
series_path.mkdir()
|
||||
|
||||
with _PatchContext(nfo_service):
|
||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||
serie_name=SERIES_NAME,
|
||||
serie_folder=SERIES_FOLDER,
|
||||
year=2023,
|
||||
download_poster=False,
|
||||
download_logo=False,
|
||||
download_fanart=False,
|
||||
)
|
||||
|
||||
root = etree.parse(str(nfo_path)).getroot()
|
||||
|
||||
plot = (root.findtext(".//plot") or "").strip()
|
||||
outline = (root.findtext(".//outline") or "").strip()
|
||||
|
||||
assert len(plot) >= 20, f"Plot too short ({len(plot)} chars): {plot!r}"
|
||||
assert len(outline) >= 20, f"Outline too short ({len(outline)} chars): {outline!r}"
|
||||
|
||||
# Should mention relevant keywords from the series
|
||||
combined = (plot + outline).lower()
|
||||
assert any(
|
||||
kw in combined for kw in ("sacrifice", "beast", "king", "sariphi")
|
||||
), f"Plot/outline missing expected content:\n plot={plot!r}\n outline={outline!r}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nfo_specific_values(
|
||||
self, nfo_service: NFOService, anime_dir: Path
|
||||
) -> None:
|
||||
"""Verify specific metadata values match the anime."""
|
||||
series_path = anime_dir / SERIES_FOLDER
|
||||
series_path.mkdir()
|
||||
|
||||
with _PatchContext(nfo_service):
|
||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||
serie_name=SERIES_NAME,
|
||||
serie_folder=SERIES_FOLDER,
|
||||
year=2023,
|
||||
download_poster=False,
|
||||
download_logo=False,
|
||||
download_fanart=False,
|
||||
)
|
||||
|
||||
root = etree.parse(str(nfo_path)).getroot()
|
||||
|
||||
assert root.findtext(".//year") == "2023"
|
||||
assert root.findtext(".//status") == "Ended"
|
||||
assert root.findtext(".//tmdbid") == str(TMDB_ID)
|
||||
assert root.findtext(".//imdbid") == "tt19896734"
|
||||
assert root.findtext(".//watched") == "false"
|
||||
assert root.findtext(".//premiered") == "2023-04-20"
|
||||
|
||||
genres = [g.text for g in root.findall(".//genre") if g.text]
|
||||
assert "Animation" in genres
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incomplete_nfo_detected_as_needing_repair(
|
||||
self, anime_dir: Path
|
||||
) -> None:
|
||||
"""An NFO with only a <title> tag is detected as incomplete."""
|
||||
series_path = anime_dir / SERIES_FOLDER
|
||||
series_path.mkdir()
|
||||
nfo_path = series_path / "tvshow.nfo"
|
||||
|
||||
# Simulate production state: minimal NFO with only title
|
||||
nfo_path.write_text(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tvshow>\n"
|
||||
f" <title>{SERIES_NAME}</title>\n"
|
||||
"</tvshow>\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert nfo_needs_repair(nfo_path) is True
|
||||
|
||||
missing = find_missing_tags(nfo_path)
|
||||
# All these should be detected as missing
|
||||
for tag_label in ["plot", "year", "runtime", "premiered", "genre", "studio"]:
|
||||
assert tag_label in missing, f"'{tag_label}' not detected as missing"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repair_fixes_incomplete_nfo(
|
||||
self, nfo_service: NFOService, anime_dir: Path
|
||||
) -> None:
|
||||
"""NfoRepairService re-fetches and creates a complete NFO from an incomplete one."""
|
||||
series_path = anime_dir / SERIES_FOLDER
|
||||
series_path.mkdir()
|
||||
nfo_path = series_path / "tvshow.nfo"
|
||||
|
||||
# Write an incomplete NFO with a tmdbid so update_tvshow_nfo can work
|
||||
nfo_path.write_text(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tvshow>\n"
|
||||
f" <title>{SERIES_NAME}</title>\n"
|
||||
f" <tmdbid>{TMDB_ID}</tmdbid>\n"
|
||||
"</tvshow>\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert nfo_needs_repair(nfo_path) is True
|
||||
|
||||
# Patch TMDB calls for the update path
|
||||
with patch.object(
|
||||
nfo_service.tmdb_client, "_ensure_session", new_callable=AsyncMock
|
||||
), patch.object(
|
||||
nfo_service.tmdb_client, "get_tv_show_details", new_callable=AsyncMock
|
||||
) as mock_details, patch.object(
|
||||
nfo_service.tmdb_client, "get_tv_show_content_ratings", new_callable=AsyncMock
|
||||
) as mock_ratings, patch.object(
|
||||
nfo_service.tmdb_client, "close", new_callable=AsyncMock
|
||||
):
|
||||
mock_details.return_value = MOCK_TMDB_DETAILS
|
||||
mock_ratings.return_value = MOCK_CONTENT_RATINGS
|
||||
|
||||
repair_service = NfoRepairService(nfo_service)
|
||||
repaired = await repair_service.repair_series(series_path, SERIES_FOLDER)
|
||||
|
||||
assert repaired is True
|
||||
|
||||
# After repair, NFO should be complete
|
||||
assert nfo_needs_repair(nfo_path) is False
|
||||
|
||||
# Verify content
|
||||
root = etree.parse(str(nfo_path)).getroot()
|
||||
plot = (root.findtext(".//plot") or "").strip()
|
||||
assert len(plot) >= 20, f"Plot still incomplete after repair: {plot!r}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repair_recreates_nfo_without_tmdb_id(
|
||||
self, nfo_service: NFOService, anime_dir: Path
|
||||
) -> None:
|
||||
"""If the NFO has no <tmdbid>, repair falls back to create_tvshow_nfo."""
|
||||
series_path = anime_dir / SERIES_FOLDER
|
||||
series_path.mkdir()
|
||||
nfo_path = series_path / "tvshow.nfo"
|
||||
|
||||
# Simulate the production worst-case: only a title, no TMDB ID
|
||||
nfo_path.write_text(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tvshow>\n"
|
||||
f" <title>{SERIES_NAME}</title>\n"
|
||||
"</tvshow>\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert _read_tmdb_id(nfo_path) is None
|
||||
assert nfo_needs_repair(nfo_path) is True
|
||||
|
||||
with _PatchContext(nfo_service):
|
||||
repair_service = NfoRepairService(nfo_service)
|
||||
repaired = await repair_service.repair_series(series_path, SERIES_FOLDER)
|
||||
|
||||
assert repaired is True
|
||||
assert nfo_path.exists()
|
||||
assert nfo_needs_repair(nfo_path) is False
|
||||
|
||||
root = etree.parse(str(nfo_path)).getroot()
|
||||
plot = (root.findtext(".//plot") or "").strip()
|
||||
assert len(plot) >= 20, f"Plot incomplete after recreate: {plot!r}"
|
||||
assert root.findtext(".//tmdbid") == str(TMDB_ID)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_nfo_not_repaired(
|
||||
self, nfo_service: NFOService, anime_dir: Path
|
||||
) -> None:
|
||||
"""A complete NFO should not trigger a repair."""
|
||||
series_path = anime_dir / SERIES_FOLDER
|
||||
series_path.mkdir()
|
||||
|
||||
# First create a complete NFO
|
||||
with _PatchContext(nfo_service):
|
||||
await nfo_service.create_tvshow_nfo(
|
||||
serie_name=SERIES_NAME,
|
||||
serie_folder=SERIES_FOLDER,
|
||||
year=2023,
|
||||
download_poster=False,
|
||||
download_logo=False,
|
||||
download_fanart=False,
|
||||
)
|
||||
|
||||
nfo_path = series_path / "tvshow.nfo"
|
||||
assert nfo_path.exists()
|
||||
assert nfo_needs_repair(nfo_path) is False
|
||||
|
||||
# Repair should be skipped
|
||||
repair_service = NfoRepairService(nfo_service)
|
||||
repaired = await repair_service.repair_series(series_path, SERIES_FOLDER)
|
||||
assert repaired is False
|
||||
@@ -495,6 +495,20 @@ class TestNameWithYearProperty:
|
||||
assert "(2013)" in sanitized
|
||||
assert "Attack on Titan" in sanitized
|
||||
|
||||
def test_name_with_year_does_not_duplicate(self):
|
||||
"""Test that name_with_year doesn't duplicate year."""
|
||||
serie = Serie(
|
||||
key="eighty-six",
|
||||
name="86 Eighty Six (2021)",
|
||||
site="aniworld.to",
|
||||
folder="86 Eighty Six (2021)",
|
||||
episodeDict={},
|
||||
year=2021
|
||||
)
|
||||
|
||||
assert serie.name_with_year == "86 Eighty Six (2021)"
|
||||
assert serie.name_with_year.count("(2021)") == 1
|
||||
|
||||
|
||||
class TestEnsureFolderWithYear:
|
||||
"""Test Serie.ensure_folder_with_year method."""
|
||||
|
||||
@@ -75,6 +75,84 @@ class TestComputeExpectedFolderName:
|
||||
result = _compute_expected_folder_name("A / B", "2021")
|
||||
assert result == "A B (2021)"
|
||||
|
||||
def test_does_not_duplicate_year(self) -> None:
|
||||
result = _compute_expected_folder_name("86 Eighty Six (2021)", "2021")
|
||||
assert result == "86 Eighty Six (2021)"
|
||||
assert result.count("(2021)") == 1
|
||||
|
||||
def test_removes_duplicate_year_suffixes_bug_86_eighty_six(self) -> None:
|
||||
"""Test the bug fix for duplicate year suffixes.
|
||||
|
||||
Issue: "86 Eighty Six (2021) (2021) (2021) (2021) (2021)"
|
||||
should become "86 Eighty Six (2021)"
|
||||
"""
|
||||
result = _compute_expected_folder_name(
|
||||
"86 Eighty Six (2021) (2021) (2021) (2021) (2021)", "2021"
|
||||
)
|
||||
assert result == "86 Eighty Six (2021)"
|
||||
assert result.count("(2021)") == 1
|
||||
|
||||
def test_removes_duplicate_year_suffixes_alma_chan(self) -> None:
|
||||
"""Test the bug fix for duplicate year suffixes with long title.
|
||||
|
||||
Issue: "Alma-chan Wants to Be a Family! (2025) (2025) (2025) (2025) (2025)"
|
||||
should become "Alma-chan Wants to Be a Family! (2025)"
|
||||
"""
|
||||
result = _compute_expected_folder_name(
|
||||
"Alma-chan Wants to Be a Family! (2025) (2025) (2025) (2025) (2025)",
|
||||
"2025",
|
||||
)
|
||||
assert result == "Alma-chan Wants to Be a Family! (2025)"
|
||||
assert result.count("(2025)") == 1
|
||||
|
||||
def test_removes_duplicate_year_suffixes_bogus_skill(self) -> None:
|
||||
"""Test the bug fix for duplicate year suffixes with very long title.
|
||||
|
||||
Issue: Long title with duplicated years should be cleaned.
|
||||
"""
|
||||
result = _compute_expected_folder_name(
|
||||
"Bogus Skill Fruitmaster About That Time I Became Able to Eat "
|
||||
"Unlimited Numbers of Skill Fruits (That Kill You) (2025) (2025)",
|
||||
"2025",
|
||||
)
|
||||
assert "(2025)" in result
|
||||
assert result.count("(2025)") == 1
|
||||
|
||||
def test_removes_multiple_different_year_suffixes(self) -> None:
|
||||
"""Test that old duplicate years are removed and new one added."""
|
||||
result = _compute_expected_folder_name(
|
||||
"Series (2020) (2020) (2020)", "2021"
|
||||
)
|
||||
assert result == "Series (2021)"
|
||||
assert "(2020)" not in result
|
||||
assert result.count("(2021)") == 1
|
||||
|
||||
def test_handles_whitespace_with_duplicate_years(self) -> None:
|
||||
"""Test that extra whitespace is removed along with duplicate years."""
|
||||
result = _compute_expected_folder_name(
|
||||
"Series (2021) (2021) (2021) ", "2021"
|
||||
)
|
||||
assert result == "Series (2021)"
|
||||
assert result.count("(2021)") == 1
|
||||
assert not result.endswith(" ")
|
||||
|
||||
def test_idempotent_multiple_calls(self) -> None:
|
||||
"""Test that calling the function multiple times produces the same result."""
|
||||
title = "86 Eighty Six (2021) (2021) (2021)"
|
||||
year = "2021"
|
||||
|
||||
# First call
|
||||
result1 = _compute_expected_folder_name(title, year)
|
||||
# Second call with the result
|
||||
result2 = _compute_expected_folder_name(result1, year)
|
||||
# Third call with the result
|
||||
result3 = _compute_expected_folder_name(result2, year)
|
||||
|
||||
# All results should be identical
|
||||
assert result1 == result2 == result3
|
||||
assert result1 == "86 Eighty Six (2021)"
|
||||
assert result1.count("(2021)") == 1
|
||||
|
||||
|
||||
class TestIsSeriesBeingDownloaded:
|
||||
"""Tests for _is_series_being_downloaded."""
|
||||
|
||||
Reference in New Issue
Block a user