Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11e231a4ab | |||
| a11f8c4fa0 | |||
| cf5a06af11 | |||
| e07f75432e | |||
| 1696d5c65b | |||
| c8b386f47a | |||
| 3888da352a | |||
| 06e104db42 | |||
| d4594bd1d9 | |||
| d866e836f6 | |||
| 195dae13cb | |||
| 51be777e7d | |||
| 7930e49701 | |||
| 75c22fe296 | |||
| 7bcd0600d5 | |||
| a333329ae2 | |||
| 363f7899f8 | |||
| a08a8f7408 | |||
| 54ac5e9ab7 | |||
| c93ac3e7b8 | |||
| 68c4335348 | |||
| be87f2e230 | |||
| c56e0f507d | |||
| cb0a36ccc2 | |||
| 3644b16447 | |||
| d5116e378e | |||
| 50a7083ce5 | |||
| 52c0ff2337 | |||
| a5fd88e224 | |||
| 98d4edad14 | |||
| bc8059b453 |
@@ -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 +1 @@
|
|||||||
v0.0.1
|
v1.1.12
|
||||||
|
|||||||
@@ -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,27 +130,46 @@ 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)
|
||||||
|
# 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")
|
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
|
# Assign the address
|
||||||
ip -4 address add "$VPN_ADDRESS" dev "$INTERFACE"
|
ip -4 address add "$VPN_ADDRESS" dev "$INTERFACE"
|
||||||
|
|
||||||
# Set MTU
|
# Set MTU and bring up
|
||||||
ip link set mtu 1420 up dev "$INTERFACE"
|
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_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}')
|
||||||
|
|
||||||
# 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 ──
|
# ── 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
|
||||||
# Get the container's eth0 IP address (BusyBox-compatible, no grep -P)
|
# Get the container's eth0 IP address (BusyBox-compatible, no grep -P)
|
||||||
@@ -155,14 +184,37 @@ 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
|
||||||
|
# Add explicit route to DNS server through wg0 so it's found in main table
|
||||||
|
# (suppress_prefixlength 0 ignores default routes but allows host routes)
|
||||||
|
ip -4 route add "$dns" dev "$INTERFACE" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
echo "[vpn] DNS set to: ${VPN_DNS}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Add explicit host route for the health-check target so it is picked up by
|
||||||
|
# the 'lookup main suppress_prefixlength 0' rule (same as DNS servers above).
|
||||||
|
# Without this, CHECK_HOST falls through to the VPN table default route whose
|
||||||
|
# source-address selection can be defeated by the priority-100 'from ETH0_IP'
|
||||||
|
# policy rule, causing pings to bypass wg0 and be dropped by the kill switch.
|
||||||
|
ip -4 route add "${CHECK_HOST}" dev "$INTERFACE" 2>/dev/null || true
|
||||||
|
echo "[vpn] Health-check route: ${CHECK_HOST} → ${INTERFACE}"
|
||||||
|
|
||||||
echo "[vpn] WireGuard interface ${INTERFACE} is up."
|
echo "[vpn] 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 +222,21 @@ start_vpn() {
|
|||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
stop_vpn() {
|
stop_vpn() {
|
||||||
echo "[vpn] Stopping WireGuard interface ${INTERFACE}..."
|
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
|
ip link del "$INTERFACE" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,14 +252,31 @@ health_loop() {
|
|||||||
while true; do
|
while true; do
|
||||||
sleep "$CHECK_INTERVAL"
|
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
|
if [ "$failures" -gt 0 ]; then
|
||||||
echo "[health] VPN recovered."
|
echo "[health] VPN recovered."
|
||||||
failures=0
|
failures=0
|
||||||
fi
|
fi
|
||||||
|
# Secondary DNS check
|
||||||
|
if ping -c 1 -W 5 "google.com" > /dev/null 2>&1; then
|
||||||
|
: # DNS OK — silent
|
||||||
|
else
|
||||||
|
echo "[health] WARN google.com unreachable — possible DNS issue"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
failures=$((failures + 1))
|
failures=$((failures + 1))
|
||||||
echo "[health] Ping failed ($failures/$max_failures)"
|
echo "[health] Check failed ($failures/$max_failures) — ping ${CHECK_HOST} failed"
|
||||||
|
# Secondary check: distinguish IP failure from DNS failure
|
||||||
|
if ping -c 1 -W 5 "google.com" > /dev/null 2>&1; then
|
||||||
|
echo "[health] INFO google.com reachable — DNS works, ${CHECK_HOST} may be filtered"
|
||||||
|
else
|
||||||
|
echo "[health] INFO google.com also unreachable — DNS or general routing failure"
|
||||||
|
fi
|
||||||
|
# Dump WireGuard stats to show if handshake is stale and how much data flows
|
||||||
|
echo "[health] wg stats:"
|
||||||
|
wg show "$INTERFACE" 2>/dev/null | grep -E 'latest handshake|transfer|endpoint' | sed 's/^/[health] /' || echo "[health] wg0 not found"
|
||||||
|
echo "[health] routes:"
|
||||||
|
ip route show | grep -E 'wg0|default' | sed 's/^/[health] /'
|
||||||
|
|
||||||
if [ "$failures" -ge "$max_failures" ]; then
|
if [ "$failures" -ge "$max_failures" ]; then
|
||||||
echo "[health] VPN appears down. Restarting WireGuard..."
|
echo "[health] VPN appears down. Restarting WireGuard..."
|
||||||
@@ -221,8 +305,83 @@ 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 (ping ${CHECK_HOST})..."
|
||||||
|
local rx_before
|
||||||
|
rx_before=$(wg show "$INTERFACE" transfer 2>/dev/null | awk '{print $2}' | head -1)
|
||||||
|
|
||||||
|
if ping -c 1 -W 8 "${CHECK_HOST}" > /dev/null 2>&1; then
|
||||||
|
echo "[check] OK Traffic flows — tunnel is fully working"
|
||||||
|
else
|
||||||
|
local rx_after
|
||||||
|
rx_after=$(wg show "$INTERFACE" transfer 2>/dev/null | awk '{print $2}' | head -1)
|
||||||
|
echo "[check] FAIL ping ${CHECK_HOST} unreachable through tunnel"
|
||||||
|
|
||||||
|
if [ -n "$rx_before" ] && [ -n "$rx_after" ]; then
|
||||||
|
if [ "$rx_after" -le "$rx_before" ]; then
|
||||||
|
echo "[check] RX bytes unchanged (${rx_before} → ${rx_after})"
|
||||||
|
echo "[check] Server receives packets but does NOT route them back"
|
||||||
|
echo "[check] Fix on VPN server (${VPN_ENDPOINT}):"
|
||||||
|
echo "[check] sysctl net.ipv4.ip_forward # must output 1"
|
||||||
|
echo "[check] iptables -t nat -L POSTROUTING -v -n # must have MASQUERADE"
|
||||||
|
echo "[check] wg show # check peer + AllowedIPs"
|
||||||
|
else
|
||||||
|
echo "[check] RX increased (${rx_before} → ${rx_after}) — tunnel passes data"
|
||||||
|
echo "[check] Issue may be specific to ${CHECK_HOST} or DNS"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
local transfer
|
||||||
|
transfer=$(wg show "$INTERFACE" transfer 2>/dev/null | awk '{printf "rx=%s tx=%s", $2, $3}')
|
||||||
|
echo "[check] wg transfer: ${transfer}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. DNS check
|
||||||
|
echo "[check] Testing DNS resolution..."
|
||||||
|
if nslookup 1.1.1.1 > /dev/null 2>&1 || nslookup google.com > /dev/null 2>&1; then
|
||||||
|
echo "[check] OK DNS resolves"
|
||||||
|
else
|
||||||
|
echo "[check] FAIL DNS resolution failed"
|
||||||
|
echo "[check] resolv.conf: $(tr '\n' ' ' < /etc/resolv.conf)"
|
||||||
|
echo "[check] Check that DNS servers are reachable through wg0"
|
||||||
|
echo "[check] ── End of checks ──"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[check] ── End of checks ──"
|
||||||
|
}
|
||||||
|
|
||||||
# ── Main ──
|
# ── 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: 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.
|
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,18 @@
|
|||||||
[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
|
||||||
|
DNS = 8.8.8.8
|
||||||
|
|
||||||
|
# Route zum VPN-Server direkt über dein lokales Netz
|
||||||
|
PostUp = ip route add 91.148.236.64 via 192.168.178.1 dev wlp4s0f0
|
||||||
|
PostUp = ip route add 192.168.178.0/24 via 192.168.178.1 dev wlp4s0f0
|
||||||
|
PostDown = ip route del 91.148.236.64 via 192.168.178.1 dev wlp4s0f0
|
||||||
|
PostDown = ip route del 192.168.178.0/24 via 192.168.178.1 dev wlp4s0f0
|
||||||
|
|
||||||
[Peer]
|
[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
|
||||||
|
|
||||||
|
|||||||
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/
|
/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",
|
"name": "aniworld-web",
|
||||||
"version": "0.0.1",
|
"version": "1.1.12",
|
||||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -445,9 +445,12 @@ class SeriesApp:
|
|||||||
try:
|
try:
|
||||||
def download_progress_handler(progress_info):
|
def download_progress_handler(progress_info):
|
||||||
"""Handle download progress events from loader."""
|
"""Handle download progress events from loader."""
|
||||||
logger.debug(
|
# Throttle progress logging to avoid spam
|
||||||
"download_progress_handler called with: %s", progress_info
|
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)
|
downloaded = progress_info.get('downloaded_bytes', 0)
|
||||||
total_bytes = (
|
total_bytes = (
|
||||||
|
|||||||
@@ -271,7 +271,11 @@ class Serie:
|
|||||||
'Dororo (2025)'
|
'Dororo (2025)'
|
||||||
"""
|
"""
|
||||||
if self._year:
|
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
|
return self._name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ class AniworldLoader(Loader):
|
|||||||
'no_warnings': True,
|
'no_warnings': True,
|
||||||
'progress_with_newline': False,
|
'progress_with_newline': False,
|
||||||
'nocheckcertificate': True,
|
'nocheckcertificate': True,
|
||||||
|
'logger': logger,
|
||||||
'progress_hooks': [events_progress_hook],
|
'progress_hooks': [events_progress_hook],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +340,7 @@ class AniworldLoader(Loader):
|
|||||||
logger.debug("Using custom headers for download")
|
logger.debug("Using custom headers for download")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.debug("Starting YoutubeDL download")
|
logger.info("Starting download: %s", output_file)
|
||||||
logger.debug("Download link: %s...", link[:100])
|
logger.debug("Download link: %s...", link[:100])
|
||||||
logger.debug("YDL options: %s", ydl_opts)
|
logger.debug("YDL options: %s", ydl_opts)
|
||||||
|
|
||||||
|
|||||||
@@ -566,6 +566,7 @@ class EnhancedAniWorldLoader(Loader):
|
|||||||
"nocheckcertificate": True,
|
"nocheckcertificate": True,
|
||||||
"socket_timeout": self.download_timeout,
|
"socket_timeout": self.download_timeout,
|
||||||
"http_chunk_size": 1024 * 1024, # 1MB chunks
|
"http_chunk_size": 1024 * 1024, # 1MB chunks
|
||||||
|
"logger": self.logger,
|
||||||
}
|
}
|
||||||
if headers:
|
if headers:
|
||||||
ydl_opts['http_headers'] = 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))
|
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:
|
class NfoRepairService:
|
||||||
"""Service that detects and repairs incomplete tvshow.nfo files.
|
"""Service that detects and repairs incomplete tvshow.nfo files.
|
||||||
|
|
||||||
|
|||||||
@@ -83,11 +83,12 @@ class NFOService:
|
|||||||
>>> _extract_year_from_name("Attack on Titan")
|
>>> _extract_year_from_name("Attack on Titan")
|
||||||
("Attack on Titan", None)
|
("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)
|
match = re.search(r'\((\d{4})\)\s*$', serie_name)
|
||||||
if match:
|
if match:
|
||||||
year = int(match.group(1))
|
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 clean_name, year
|
||||||
return serie_name, None
|
return serie_name, None
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Example:
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
@@ -63,6 +64,11 @@ class TMDBClient:
|
|||||||
self.max_connections = max_connections
|
self.max_connections = max_connections
|
||||||
self.session: Optional[aiohttp.ClientSession] = None
|
self.session: Optional[aiohttp.ClientSession] = None
|
||||||
self._cache: Dict[str, Any] = {}
|
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 def __aenter__(self):
|
||||||
"""Async context manager entry."""
|
"""Async context manager entry."""
|
||||||
@@ -83,7 +89,7 @@ class TMDBClient:
|
|||||||
self,
|
self,
|
||||||
endpoint: str,
|
endpoint: str,
|
||||||
params: Optional[Dict[str, Any]] = None,
|
params: Optional[Dict[str, Any]] = None,
|
||||||
max_retries: int = 3
|
max_retries: int = 5
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Make an async request to TMDB API with retries.
|
"""Make an async request to TMDB API with retries.
|
||||||
|
|
||||||
@@ -110,58 +116,82 @@ class TMDBClient:
|
|||||||
logger.debug("Cache hit for %s", endpoint)
|
logger.debug("Cache hit for %s", endpoint)
|
||||||
return self._cache[cache_key]
|
return self._cache[cache_key]
|
||||||
|
|
||||||
delay = 1
|
delay = 2
|
||||||
last_error = None
|
last_error = None
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
# Rate limiting: ensure we don't exceed ~35 requests/second
|
||||||
try:
|
async with self._rate_limit_lock:
|
||||||
# Re-ensure session before each attempt in case it was closed
|
now = time.monotonic()
|
||||||
await self._ensure_session()
|
# Remove timestamps older than 1 second
|
||||||
|
self._request_timestamps = [
|
||||||
if self.session is None:
|
ts for ts in self._request_timestamps if now - ts < 1.0
|
||||||
raise TMDBAPIError("Session is not available")
|
]
|
||||||
|
if len(self._request_timestamps) >= self._max_requests_per_second:
|
||||||
logger.debug("TMDB API request: %s (attempt %s)", endpoint, attempt + 1)
|
sleep_time = 1.0 - (now - self._request_timestamps[0])
|
||||||
async with self.session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
|
if sleep_time > 0:
|
||||||
if resp.status == 401:
|
logger.debug("Rate throttling: waiting %.2fs", sleep_time)
|
||||||
raise TMDBAPIError("Invalid TMDB API key")
|
await asyncio.sleep(sleep_time)
|
||||||
elif resp.status == 404:
|
self._request_timestamps.append(time.monotonic())
|
||||||
raise TMDBAPIError(f"Resource not found: {endpoint}")
|
|
||||||
elif resp.status == 429:
|
async with self._semaphore:
|
||||||
# Rate limit - wait longer
|
for attempt in range(max_retries):
|
||||||
retry_after = int(resp.headers.get('Retry-After', delay * 2))
|
try:
|
||||||
logger.warning("Rate limited, waiting %ss", retry_after)
|
# Re-ensure session before each attempt in case it was closed
|
||||||
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
|
|
||||||
await self._ensure_session()
|
await self._ensure_session()
|
||||||
|
|
||||||
if attempt < max_retries - 1:
|
if self.session is None:
|
||||||
logger.warning("Request failed (attempt %s): %s, retrying in %ss", attempt + 1, e, delay)
|
raise TMDBAPIError("Session is not available")
|
||||||
await asyncio.sleep(delay)
|
|
||||||
delay *= 2
|
logger.debug("TMDB API request: %s (attempt %s)", endpoint, attempt + 1)
|
||||||
else:
|
async with self.session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
|
||||||
logger.error("Request failed after %s attempts: %s", max_retries, e)
|
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}")
|
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
|
# Create folder name with year if available
|
||||||
if year:
|
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:
|
else:
|
||||||
folder_name_with_year = name
|
folder_name_with_year = name
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def get_settings() -> Union[DevelopmentSettings, ProductionSettings]:
|
|||||||
Example:
|
Example:
|
||||||
>>> settings = get_settings()
|
>>> settings = get_settings()
|
||||||
>>> print(settings.log_level)
|
>>> print(settings.log_level)
|
||||||
DEBUG
|
INFO
|
||||||
"""
|
"""
|
||||||
if ENVIRONMENT in {"development", "testing"}:
|
if ENVIRONMENT in {"development", "testing"}:
|
||||||
return get_development_settings()
|
return get_development_settings()
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ class DevelopmentSettings(BaseSettings):
|
|||||||
@property
|
@property
|
||||||
def debug_enabled(self) -> bool:
|
def debug_enabled(self) -> bool:
|
||||||
"""Check if debug mode is enabled."""
|
"""Check if debug mode is enabled."""
|
||||||
return True
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def reload_enabled(self) -> bool:
|
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:
|
def _compute_expected_folder_name(title: str, year: str) -> str:
|
||||||
"""Compute the expected folder name from title and year.
|
"""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:
|
Args:
|
||||||
title: Series title from NFO.
|
title: Series title from NFO.
|
||||||
year: Release year from NFO.
|
year: Release year from NFO.
|
||||||
@@ -73,7 +76,15 @@ def _compute_expected_folder_name(title: str, year: str) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
Sanitised folder name in the format ``"{title} ({year})"``.
|
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)
|
return sanitize_folder_name(raw_name)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -334,6 +334,25 @@ async def test_add_series_sanitizes_folder_name(authenticated_client):
|
|||||||
assert "?" not in folder
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_add_series_returns_missing_episodes(authenticated_client):
|
async def test_add_series_returns_missing_episodes(authenticated_client):
|
||||||
"""Test that add_series returns loading progress info."""
|
"""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 "(2013)" in sanitized
|
||||||
assert "Attack on Titan" 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:
|
class TestEnsureFolderWithYear:
|
||||||
"""Test Serie.ensure_folder_with_year method."""
|
"""Test Serie.ensure_folder_with_year method."""
|
||||||
|
|||||||
@@ -75,6 +75,84 @@ class TestComputeExpectedFolderName:
|
|||||||
result = _compute_expected_folder_name("A / B", "2021")
|
result = _compute_expected_folder_name("A / B", "2021")
|
||||||
assert result == "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:
|
class TestIsSeriesBeingDownloaded:
|
||||||
"""Tests for _is_series_being_downloaded."""
|
"""Tests for _is_series_being_downloaded."""
|
||||||
|
|||||||
Reference in New Issue
Block a user