Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 810346bc8b | |||
| daa937bcb7 | |||
| 1c505bd722 | |||
| 3551838887 | |||
| 9a20541598 | |||
| 3f7651404d | |||
| bee24406e6 | |||
| 31eb0026cf | |||
| 24ea12bbaf | |||
| e74b602f60 | |||
| db65e28854 | |||
| 11e231a4ab | |||
| a11f8c4fa0 | |||
| cf5a06af11 | |||
| e07f75432e | |||
| 1696d5c65b | |||
| c8b386f47a | |||
| 3888da352a | |||
| 06e104db42 | |||
| d4594bd1d9 | |||
| d866e836f6 | |||
| 195dae13cb | |||
| 51be777e7d | |||
| 7930e49701 | |||
| 75c22fe296 |
@@ -2,12 +2,13 @@ FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies for compiled Python packages
|
||||
# Install system dependencies for compiled Python packages and ffmpeg for HLS support
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
g++ \
|
||||
libffi-dev \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies (cached layer)
|
||||
|
||||
@@ -1 +1 @@
|
||||
v1.1.5
|
||||
v1.1.13
|
||||
|
||||
@@ -191,10 +191,21 @@ start_vpn() {
|
||||
> /etc/resolv.conf
|
||||
for dns in $(echo "$VPN_DNS" | tr ',' ' '); do
|
||||
echo "nameserver $dns" >> /etc/resolv.conf
|
||||
# Add explicit route to DNS server through wg0 so it's found in main table
|
||||
# (suppress_prefixlength 0 ignores default routes but allows host routes)
|
||||
ip -4 route add "$dns" dev "$INTERFACE" 2>/dev/null || true
|
||||
done
|
||||
echo "[vpn] DNS set to: ${VPN_DNS}"
|
||||
fi
|
||||
|
||||
# Add explicit host route for the health-check target so it is picked up by
|
||||
# the 'lookup main suppress_prefixlength 0' rule (same as DNS servers above).
|
||||
# Without this, CHECK_HOST falls through to the VPN table default route whose
|
||||
# source-address selection can be defeated by the priority-100 'from ETH0_IP'
|
||||
# policy rule, causing pings to bypass wg0 and be dropped by the kill switch.
|
||||
ip -4 route add "${CHECK_HOST}" dev "$INTERFACE" 2>/dev/null || true
|
||||
echo "[vpn] Health-check route: ${CHECK_HOST} → ${INTERFACE}"
|
||||
|
||||
echo "[vpn] WireGuard interface ${INTERFACE} is up."
|
||||
echo "[vpn] Main routes:"
|
||||
ip route show | sed 's/^/[vpn] /'
|
||||
@@ -246,9 +257,21 @@ health_loop() {
|
||||
echo "[health] VPN recovered."
|
||||
failures=0
|
||||
fi
|
||||
# Secondary DNS check
|
||||
if ping -c 1 -W 5 "google.com" > /dev/null 2>&1; then
|
||||
: # DNS OK — silent
|
||||
else
|
||||
echo "[health] WARN google.com unreachable — possible DNS issue"
|
||||
fi
|
||||
else
|
||||
failures=$((failures + 1))
|
||||
echo "[health] Check failed ($failures/$max_failures) — ping ${CHECK_HOST} failed"
|
||||
# Secondary check: distinguish IP failure from DNS failure
|
||||
if ping -c 1 -W 5 "google.com" > /dev/null 2>&1; then
|
||||
echo "[health] INFO google.com reachable — DNS works, ${CHECK_HOST} may be filtered"
|
||||
else
|
||||
echo "[health] INFO google.com also unreachable — DNS or general routing failure"
|
||||
fi
|
||||
# Dump WireGuard stats to show if handshake is stale and how much data flows
|
||||
echo "[health] wg stats:"
|
||||
wg show "$INTERFACE" 2>/dev/null | grep -E 'latest handshake|transfer|endpoint' | sed 's/^/[health] /' || echo "[health] wg0 not found"
|
||||
@@ -349,6 +372,8 @@ check_vpn_connectivity() {
|
||||
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 ──"
|
||||
|
||||
@@ -15,6 +15,7 @@ services:
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- SYS_MODULE
|
||||
- NET_RAW
|
||||
sysctls:
|
||||
- net.ipv4.ip_forward=1
|
||||
- net.ipv4.conf.all.src_valid_mark=1
|
||||
@@ -22,7 +23,7 @@ services:
|
||||
- /server/server_aniworld/wg0.conf:/etc/wireguard/wg0.conf:ro
|
||||
- /lib/modules:/lib/modules:ro
|
||||
ports:
|
||||
- "2000:8000"
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- HEALTH_CHECK_INTERVAL=10
|
||||
- HEALTH_CHECK_HOST=1.1.1.1
|
||||
@@ -51,4 +52,5 @@ services:
|
||||
volumes:
|
||||
- /server/server_aniworld/data:/app/data
|
||||
- /server/server_aniworld/logs:/app/logs
|
||||
- /media/serien/Serien:/data
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
[Interface]
|
||||
PrivateKey = EPRa2f/v72LvIXAY4yqIRJifsSb+nCcYHqC2rwA94UI=
|
||||
Address = 100.64.244.78/32
|
||||
DNS = 198.18.0.1,198.18.0.2
|
||||
#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
|
||||
|
||||
@@ -61,4 +61,190 @@ This document provides guidance for developers working on the Aniworld project.
|
||||
- Commit message format
|
||||
- Pull request process
|
||||
8. Common Development Tasks
|
||||
|
||||
### Adding Queue Deduplication
|
||||
|
||||
The download queue prevents duplicate entries at two levels:
|
||||
|
||||
**In-Memory Deduplication** (`src/server/services/download_service.py`):
|
||||
- `_pending_by_episode` dict tracks pending episodes: key = `(serie_id, season, episode)`
|
||||
- `_add_to_pending_queue()` updates the dict when adding items
|
||||
- `add_to_queue()` checks this dict before adding episodes (includes batch-local dedup)
|
||||
- `_remove_from_pending_queue()` cleans up the dict when items are removed
|
||||
|
||||
**Database Constraint** (`src/server/models.py`):
|
||||
- `DownloadQueueItem` has a unique index on `episode_id` via `__table_args__`
|
||||
- Prevents duplicate queue entries at the database level
|
||||
- Unique constraint: `Index("ix_download_queue_episode_pending", "episode_id", unique=True)`
|
||||
|
||||
**Scheduler Cooldown** (`src/server/services/scheduler_service.py`):
|
||||
- `_last_auto_download_time` tracks when auto-download last ran
|
||||
- 5-minute cooldown prevents rapid re-triggers
|
||||
- Checked at start of `_auto_download_missing()`
|
||||
|
||||
### Mocking the Download Queue
|
||||
|
||||
When testing components that use the download queue:
|
||||
|
||||
```python
|
||||
# Mock repository for unit tests
|
||||
class MockQueueRepository:
|
||||
def __init__(self):
|
||||
self._items: Dict[str, DownloadItem] = {}
|
||||
|
||||
async def save_item(self, item: DownloadItem) -> DownloadItem:
|
||||
self._items[item.id] = item
|
||||
return item
|
||||
|
||||
async def get_all_items(self) -> List[DownloadItem]:
|
||||
return list(self._items.values())
|
||||
|
||||
# Use in fixture
|
||||
@pytest.fixture
|
||||
def mock_queue_repository():
|
||||
return MockQueueRepository()
|
||||
|
||||
@pytest.fixture
|
||||
def download_service(mock_anime_service, mock_queue_repository):
|
||||
return DownloadService(
|
||||
anime_service=mock_anime_service,
|
||||
queue_repository=mock_queue_repository,
|
||||
max_retries=3,
|
||||
)
|
||||
```
|
||||
|
||||
9. Troubleshooting Development Issues
|
||||
|
||||
### Async Context Managers for aiohttp
|
||||
|
||||
All `aiohttp.ClientSession` usages must be wrapped in `async with`:
|
||||
|
||||
```python
|
||||
# Correct — session properly closed on exit
|
||||
async with TMDBClient(api_key="key") as client:
|
||||
result = await client.search_tv_show("Show")
|
||||
|
||||
# Wrong — session may leak if exception occurs
|
||||
client = TMDBClient(api_key="key")
|
||||
result = await client.search_tv_show("Show")
|
||||
await client.close() # May not be called if exception raised earlier
|
||||
```
|
||||
|
||||
**Why:**
|
||||
- `aiohttp.ClientSession` holds TCP connections that must be explicitly closed
|
||||
- If exception occurs before `close()`, session leaks
|
||||
- Context manager guarantees `__aexit__` runs even on exceptions
|
||||
|
||||
**Services that use aiohttp:**
|
||||
- `TMDBClient` — has `__aenter__`/`__aexit__`, use `async with`
|
||||
- `ImageDownloader` — has `__aenter__`/`__aexit__`, use `async with`
|
||||
- `NFOService` — wraps both above, use `async with`
|
||||
|
||||
**Verification:**
|
||||
- Missing context manager usage triggers `__del__` warning on garbage collection
|
||||
- Integration tests verify no "Unclosed client session" errors in logs
|
||||
|
||||
### Scheduler Persistence and Recovery
|
||||
|
||||
APScheduler stores jobs in `data/scheduler.db` (SQLite) so they survive process restarts:
|
||||
|
||||
```python
|
||||
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
||||
|
||||
jobstores = {
|
||||
"default": SQLAlchemyJobStore(url="sqlite:///./data/scheduler.db"),
|
||||
}
|
||||
scheduler = AsyncIOScheduler(jobstores=jobstores)
|
||||
```
|
||||
|
||||
**Grace period:** `misfire_grace_time=3600` (1 hour). If server is down at scheduled time and restarts within 1 hour, missed job runs automatically via APScheduler coalesce behavior.
|
||||
|
||||
**Startup recovery:** On `start()`, scheduler loads persisted jobs from DB. APScheduler handles missed jobs internally when `coalesce=True`.
|
||||
|
||||
**Health endpoint:** `GET /health` returns `scheduler_next_run` and `scheduler_last_run` for external monitors (Uptime Kuma, Prometheus, etc.).
|
||||
|
||||
**If server is down >1 hour:** No automatic recovery. Manual trigger via `POST /api/scheduler/trigger-rescan` or wait for next scheduled run.
|
||||
|
||||
### Health Check Endpoints
|
||||
|
||||
The application provides health check endpoints for monitoring and container orchestration:
|
||||
|
||||
#### `GET /health`
|
||||
Basic health check returning service status and startup health check results.
|
||||
|
||||
**Response fields:**
|
||||
- `status`: "healthy", "degraded", or "unhealthy" based on startup checks
|
||||
- `timestamp`: ISO timestamp of the check
|
||||
- `series_app_initialized`: Whether the series app is loaded
|
||||
- `anime_directory_configured`: Whether anime_directory is set
|
||||
- `scheduler_next_run` / `scheduler_last_run`: Scheduler times
|
||||
- `checks`: Detailed startup check results (ffmpeg, DNS, anime_directory)
|
||||
|
||||
#### `GET /health/ready`
|
||||
Readiness check for container orchestrators (Kubernetes, Docker Swarm).
|
||||
|
||||
**Response when ready:**
|
||||
```json
|
||||
{
|
||||
"status": "ready",
|
||||
"ready": true,
|
||||
"timestamp": "2024-01-01T00:00:00",
|
||||
"checks": {...}
|
||||
}
|
||||
```
|
||||
|
||||
**Response when not ready (503):**
|
||||
```json
|
||||
{
|
||||
"status": "not_ready",
|
||||
"ready": false,
|
||||
"timestamp": "2024-01-01T00:00:00",
|
||||
"critical_failures": ["anime_directory: not configured"],
|
||||
"checks": {...}
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /health/detailed`
|
||||
Comprehensive health check including database, filesystem, and system metrics.
|
||||
|
||||
#### Startup Health Checks
|
||||
|
||||
On application startup, the following checks are performed:
|
||||
|
||||
| Check | Failure Status | Impact |
|
||||
|-------|---------------|--------|
|
||||
| `ffmpeg` | warning | HLS downloads may fail |
|
||||
| `dns_aniworld` | warning | Provider requests may fail |
|
||||
| `dns_tmdb` | warning | TMDB API calls may fail |
|
||||
| `anime_directory` | error | Download service disabled |
|
||||
|
||||
DNS checks are warnings because failures can be transient. anime_directory errors disable the download service to prevent failures.
|
||||
|
||||
### Troubleshooting Development Issues
|
||||
|
||||
#### Scheduler missed a run
|
||||
|
||||
1. Server was down at scheduled time (03:00 UTC by default).
|
||||
2. Check `data/scheduler.db` exists — if not, jobs are not persisted.
|
||||
3. If server was down >1 hour, missed job is dropped (misfire window exceeded).
|
||||
4. Trigger manually: `POST /api/scheduler/trigger-rescan`
|
||||
5. Monitor next run: `GET /health` → `scheduler_next_run`
|
||||
6. If problem repeats, increase `misfire_grace_time` in `scheduler_service.py`.
|
||||
|
||||
#### Startup health check failures
|
||||
|
||||
If `/health` returns `unhealthy` status:
|
||||
|
||||
1. **anime_directory error**: Directory not configured or not writable
|
||||
- Check `ANIME_DIRECTORY` environment variable
|
||||
- Verify directory exists and permissions allow write access
|
||||
- Download service will not initialize until resolved
|
||||
|
||||
2. **ffmpeg warning**: ffmpeg not found in PATH
|
||||
- HLS stream downloads will fail
|
||||
- Install ffmpeg: `apt install ffmpeg` or `brew install ffmpeg`
|
||||
|
||||
3. **DNS warnings**: Domain resolution failed
|
||||
- Check network connectivity
|
||||
- DNS failures are transient — warnings don't block startup
|
||||
- Retry later to verify: `GET /health`
|
||||
|
||||
@@ -62,6 +62,90 @@ This document describes the testing strategy, guidelines, and practices for the
|
||||
- What to mock
|
||||
- Mock patterns
|
||||
- External service mocks
|
||||
|
||||
### Mocking the Download Queue
|
||||
|
||||
Use `MockQueueRepository` for testing download queue functionality:
|
||||
|
||||
```python
|
||||
from src.server.models.download import DownloadItem, EpisodeIdentifier
|
||||
|
||||
class MockQueueRepository:
|
||||
def __init__(self):
|
||||
self._items: Dict[str, DownloadItem] = {}
|
||||
|
||||
async def save_item(self, item: DownloadItem) -> DownloadItem:
|
||||
self._items[item.id] = item
|
||||
return item
|
||||
|
||||
async def get_item(self, item_id: str) -> Optional[DownloadItem]:
|
||||
return self._items.get(item_id)
|
||||
|
||||
async def get_all_items(self) -> List[DownloadItem]:
|
||||
return list(self._items.values())
|
||||
|
||||
async def set_error(self, item_id: str, error: str) -> bool:
|
||||
if item_id in self._items:
|
||||
self._items[item_id].error = error
|
||||
return True
|
||||
return False
|
||||
|
||||
async def delete_item(self, item_id: str) -> bool:
|
||||
if item_id in self._items:
|
||||
del self._items[item_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
async def clear_all(self) -> int:
|
||||
count = len(self._items)
|
||||
self._items.clear()
|
||||
return count
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- The mock uses in-memory storage, no database required
|
||||
- All async methods are implemented (even if just pass-through)
|
||||
- `save_item` uses `item.id` as key (must be set before calling)
|
||||
- Suitable for unit tests only (no persistence)
|
||||
|
||||
### Mocking aiohttp Sessions
|
||||
|
||||
When testing code that uses `aiohttp.ClientSession`:
|
||||
|
||||
```python
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from aiohttp import ClientSession
|
||||
|
||||
# Mock aiohttp session for testing
|
||||
class MockAiohttpSession:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
def get(self, url, **kwargs):
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={"data": "test"})
|
||||
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
mock_response.__aexit__ = AsyncMock(return_value=None)
|
||||
return mock_response
|
||||
|
||||
# Use in fixture
|
||||
@pytest.fixture
|
||||
async def mock_tmdb_session():
|
||||
session = MockAiohttpSession()
|
||||
yield session
|
||||
# Cleanup verification
|
||||
assert session.closed, "Session was not closed"
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- Always verify `session.closed` is `True` after context manager exits
|
||||
- Mock `__aenter__` and `__aexit__` for response context managers
|
||||
- Set `closed = False` on mock session for unclosed warning tests
|
||||
|
||||
7. Coverage Requirements
|
||||
8. CI/CD Integration
|
||||
9. Writing Good Tests
|
||||
|
||||
47
docs/key
47
docs/key
@@ -2,3 +2,50 @@ API key : 299ae8f630a31bda814263c551361448
|
||||
|
||||
/mnt/server/serien/Serien/
|
||||
|
||||
{
|
||||
"name": "Aniworld",
|
||||
"data_dir": "data",
|
||||
"scheduler": {
|
||||
"enabled": true,
|
||||
"interval_minutes": 60,
|
||||
"schedule_time": "03:00",
|
||||
"schedule_days": [
|
||||
"mon",
|
||||
"tue",
|
||||
"wed",
|
||||
"thu",
|
||||
"fri",
|
||||
"sat",
|
||||
"sun"
|
||||
],
|
||||
"auto_download_after_rescan": true,
|
||||
"folder_scan_enabled": true
|
||||
},
|
||||
"logging": {
|
||||
"level": "INFO",
|
||||
"file": null,
|
||||
"max_bytes": null,
|
||||
"backup_count": 3
|
||||
},
|
||||
"backup": {
|
||||
"enabled": false,
|
||||
"path": "data/backups",
|
||||
"keep_days": 30
|
||||
},
|
||||
"nfo": {
|
||||
"tmdb_api_key": "9bc3e547caff878615cbdba2cc421d37",
|
||||
"auto_create": true,
|
||||
"update_on_scan": true,
|
||||
"download_poster": true,
|
||||
"download_logo": true,
|
||||
"download_fanart": true,
|
||||
"image_size": "original"
|
||||
},
|
||||
"other": {
|
||||
"master_password_hash": "$pbkdf2-sha256$29000$HQNASKk1xpgTAgAgJGRMaQ$73TOCCM0UEZONyNXQEPa3SmIoXeG6C1l5mMFDNgYfMQ",
|
||||
"anime_directory": "/data"
|
||||
},
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
|
||||
|
||||
154
docs/runner.csx
Normal file
154
docs/runner.csx
Normal file
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env dotnet-script
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
|
||||
// ── Ctrl+C: kill active process and exit cleanly ──────────────────────────────
|
||||
var cts = new CancellationTokenSource();
|
||||
Process? activeProcess = null;
|
||||
|
||||
Console.CancelKeyPress += (_, e) =>
|
||||
{
|
||||
e.Cancel = true;
|
||||
Console.WriteLine("\n[runner] Interrupted — shutting down...");
|
||||
cts.Cancel();
|
||||
try { activeProcess?.Kill(entireProcessTree: true); } catch { }
|
||||
};
|
||||
|
||||
// ── Paths ─────────────────────────────────────────────────────────────────────
|
||||
var repoRoot = Directory.GetCurrentDirectory();
|
||||
var tasksFile = Path.Combine(repoRoot, "Docs", "Tasks.md");
|
||||
|
||||
if (!File.Exists(tasksFile))
|
||||
{
|
||||
Console.Error.WriteLine($"[runner] ERROR: Tasks.md not found at {tasksFile}");
|
||||
Console.Error.WriteLine("[runner] Run this script from the repository root.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
|
||||
// ── Read & split by "---" separator lines ────────────────────────────────────
|
||||
var content = File.ReadAllText(tasksFile);
|
||||
var items = Regex
|
||||
.Split(content, @"\r?\n---\r?\n")
|
||||
.Select(s => s.Trim())
|
||||
.Where(s => s.Length > 0)
|
||||
.ToList();
|
||||
|
||||
Console.WriteLine($"[runner] Found {items.Count} section(s) in Tasks.md");
|
||||
|
||||
// ── Helper: run copilot and stream output, return full output ─────────────────
|
||||
async Task<string> RunCopilot(IEnumerable<string> extraArgs, string prompt)
|
||||
{
|
||||
var output = new StringBuilder();
|
||||
|
||||
var argList = new List<string> { "launch", "copilot", "--model", "minimax-m2.7:cloud", "--yes", "--", "--allow-all-tools" };
|
||||
argList.AddRange(extraArgs);
|
||||
argList.Add("-p");
|
||||
argList.Add(prompt);
|
||||
|
||||
var psi = new ProcessStartInfo("ollama")
|
||||
{
|
||||
WorkingDirectory = repoRoot,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
foreach (var a in argList)
|
||||
psi.ArgumentList.Add(a);
|
||||
|
||||
activeProcess = new Process { StartInfo = psi };
|
||||
|
||||
activeProcess.OutputDataReceived += (_, e) =>
|
||||
{
|
||||
if (e.Data is null) return;
|
||||
Console.WriteLine(e.Data);
|
||||
output.AppendLine(e.Data);
|
||||
};
|
||||
activeProcess.ErrorDataReceived += (_, e) =>
|
||||
{
|
||||
if (e.Data is null) return;
|
||||
Console.Error.WriteLine(e.Data);
|
||||
output.AppendLine(e.Data);
|
||||
};
|
||||
|
||||
activeProcess.Start();
|
||||
activeProcess.BeginOutputReadLine();
|
||||
activeProcess.BeginErrorReadLine();
|
||||
|
||||
await activeProcess.WaitForExitAsync(cts.Token);
|
||||
activeProcess = null;
|
||||
|
||||
return output.ToString();
|
||||
}
|
||||
|
||||
// ── Main loop ─────────────────────────────────────────────────────────────────
|
||||
for (int i = 0; i < items.Count; i++)
|
||||
{
|
||||
var item = items[i];
|
||||
if (cts.IsCancellationRequested) break;
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("[runner] ══════════════════════════════════════════════");
|
||||
Console.WriteLine($"[runner] Task:\n{item}");
|
||||
Console.WriteLine("[runner] ══════════════════════════════════════════════");
|
||||
Console.WriteLine();
|
||||
|
||||
// Step 1 — run the task prompt
|
||||
await RunCopilot(Enumerable.Empty<string>(), $"/caveman full");
|
||||
await RunCopilot(new[] { "--continue" }, $"read ./Docs/instructions.md. {item}");
|
||||
if (cts.IsCancellationRequested) break;
|
||||
|
||||
// Step 2 — confirm completion in the same chat session
|
||||
Console.WriteLine("\n[runner] Asking for task confirmation...\n");
|
||||
var confirmation = await RunCopilot(
|
||||
new[] { "--continue" },
|
||||
"are you sure tasks is done. reply with yes"
|
||||
);
|
||||
if (cts.IsCancellationRequested) break;
|
||||
|
||||
// Step 3 — check for "yes" in the reply, with retry logic for issue resolution
|
||||
int maxRetries = 3;
|
||||
int retryCount = 0;
|
||||
bool taskConfirmed = confirmation.Contains("yes", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
while (!taskConfirmed && retryCount < maxRetries)
|
||||
{
|
||||
retryCount++;
|
||||
Console.WriteLine($"\n[runner] Attempt {retryCount}/{maxRetries}: Resolving remaining issues and running tests...\n");
|
||||
|
||||
confirmation = await RunCopilot(
|
||||
new[] { "--continue" },
|
||||
"resolve any remaining issues, make sure all tests are running and pass. then confirm with yes if done"
|
||||
);
|
||||
if (cts.IsCancellationRequested) break;
|
||||
|
||||
taskConfirmed = confirmation.Contains("yes", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (!taskConfirmed)
|
||||
{
|
||||
Console.WriteLine($"\n[runner] Task not confirmed as done after {maxRetries} attempts. Stopping.");
|
||||
break;
|
||||
}
|
||||
|
||||
// Step 4 — commit the work
|
||||
Console.WriteLine("\n[runner] Task confirmed. Making git commit...\n");
|
||||
|
||||
await RunCopilot(Enumerable.Empty<string>(), $"/caveman full");
|
||||
await RunCopilot(new[] { "--continue" }, "make git commit");
|
||||
if (cts.IsCancellationRequested) break;
|
||||
|
||||
// Step 5 — remove completed task from Tasks.md
|
||||
var remaining = items.Skip(i + 1).ToList();
|
||||
File.WriteAllText(tasksFile, string.Join("\n\n---\n\n", remaining));
|
||||
Console.WriteLine("[runner] Removed completed task from Tasks.md");
|
||||
}
|
||||
|
||||
Console.WriteLine("\n[runner] Finished.");
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aniworld-web",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.13",
|
||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -271,7 +271,11 @@ class Serie:
|
||||
'Dororo (2025)'
|
||||
"""
|
||||
if self._year:
|
||||
return f"{self._name} ({self._year})"
|
||||
import re
|
||||
year_suffix = f" ({self._year})"
|
||||
# Strip ALL trailing year suffixes before appending to prevent duplication
|
||||
clean_name = re.sub(r'(\s*\(\d{4}\))+\s*$', '', self._name).strip()
|
||||
return f"{clean_name}{year_suffix}"
|
||||
return self._name
|
||||
|
||||
@property
|
||||
|
||||
@@ -333,6 +333,9 @@ class AniworldLoader(Loader):
|
||||
'nocheckcertificate': True,
|
||||
'logger': logger,
|
||||
'progress_hooks': [events_progress_hook],
|
||||
# Use ffmpeg for HLS streams and transport stream format
|
||||
'downloader': 'ffmpeg',
|
||||
'hls_use_mpegts': True,
|
||||
}
|
||||
|
||||
if header:
|
||||
|
||||
@@ -567,6 +567,9 @@ class EnhancedAniWorldLoader(Loader):
|
||||
"socket_timeout": self.download_timeout,
|
||||
"http_chunk_size": 1024 * 1024, # 1MB chunks
|
||||
"logger": self.logger,
|
||||
# Use ffmpeg for HLS streams and transport stream format
|
||||
"downloader": "ffmpeg",
|
||||
"hls_use_mpegts": True,
|
||||
}
|
||||
if headers:
|
||||
ydl_opts['http_headers'] = headers
|
||||
|
||||
@@ -120,6 +120,37 @@ def nfo_needs_repair(nfo_path: Path) -> bool:
|
||||
return bool(find_missing_tags(nfo_path))
|
||||
|
||||
|
||||
def _read_tmdb_id(nfo_path: Path) -> int | None:
|
||||
"""Return the TMDB ID stored in an existing NFO, or ``None``.
|
||||
|
||||
Checks both ``<tmdbid>`` and ``<uniqueid type="tmdb">`` elements.
|
||||
|
||||
Args:
|
||||
nfo_path: Absolute path to the ``tvshow.nfo`` file.
|
||||
|
||||
Returns:
|
||||
Integer TMDB ID, or ``None`` if not found or not parseable.
|
||||
"""
|
||||
if not nfo_path.exists():
|
||||
return None
|
||||
try:
|
||||
root = etree.parse(str(nfo_path)).getroot()
|
||||
|
||||
for uniqueid in root.findall(".//uniqueid"):
|
||||
if uniqueid.get("type") == "tmdb" and uniqueid.text:
|
||||
return int(uniqueid.text)
|
||||
|
||||
tmdbid_elem = root.find(".//tmdbid")
|
||||
if tmdbid_elem is not None and tmdbid_elem.text:
|
||||
return int(tmdbid_elem.text)
|
||||
|
||||
except (etree.XMLSyntaxError, ValueError):
|
||||
pass
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class NfoRepairService:
|
||||
"""Service that detects and repairs incomplete tvshow.nfo files.
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ Example:
|
||||
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
@@ -53,6 +54,18 @@ class NFOService:
|
||||
self.image_size = image_size
|
||||
self.auto_create = auto_create
|
||||
|
||||
async def __aenter__(self) -> "NFOService":
|
||||
"""Enter async context manager."""
|
||||
await self.tmdb_client.__aenter__()
|
||||
await self.image_downloader.__aenter__()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Exit async context manager and cleanup resources."""
|
||||
await self.tmdb_client.close()
|
||||
await self.image_downloader.close()
|
||||
return False
|
||||
|
||||
def has_nfo(self, serie_folder: str) -> bool:
|
||||
"""Check if tvshow.nfo exists for a series.
|
||||
|
||||
@@ -83,11 +96,12 @@ class NFOService:
|
||||
>>> _extract_year_from_name("Attack on Titan")
|
||||
("Attack on Titan", None)
|
||||
"""
|
||||
# Match year in parentheses at the end: (YYYY)
|
||||
# Match the last year in parentheses at the end: (YYYY)
|
||||
match = re.search(r'\((\d{4})\)\s*$', serie_name)
|
||||
if match:
|
||||
year = int(match.group(1))
|
||||
clean_name = serie_name[:match.start()].strip()
|
||||
# Strip ALL trailing year suffixes to get a fully clean name
|
||||
clean_name = re.sub(r'(\s*\(\d{4}\))+\s*$', '', serie_name).strip()
|
||||
return clean_name, year
|
||||
return serie_name, None
|
||||
|
||||
@@ -110,7 +124,8 @@ class NFOService:
|
||||
year: Optional[int] = None,
|
||||
download_poster: bool = True,
|
||||
download_logo: bool = True,
|
||||
download_fanart: bool = True
|
||||
download_fanart: bool = True,
|
||||
alt_titles: Optional[List[str]] = None
|
||||
) -> Path:
|
||||
"""Create tvshow.nfo by scraping TMDB.
|
||||
|
||||
@@ -122,6 +137,7 @@ class NFOService:
|
||||
download_poster: Whether to download poster.jpg
|
||||
download_logo: Whether to download logo.png
|
||||
download_fanart: Whether to download fanart.jpg
|
||||
alt_titles: Alternative titles (e.g., Japanese title) for fallback search
|
||||
|
||||
Returns:
|
||||
Path to created NFO file
|
||||
@@ -148,16 +164,11 @@ class NFOService:
|
||||
|
||||
try:
|
||||
await self.tmdb_client._ensure_session()
|
||||
|
||||
# Search for TV show with clean name (without year)
|
||||
logger.debug("Searching TMDB for: %s", search_name)
|
||||
search_results = await self.tmdb_client.search_tv_show(search_name)
|
||||
|
||||
if not search_results.get("results"):
|
||||
raise TMDBAPIError(f"No results found for: {search_name}")
|
||||
|
||||
# Find best match (consider year if provided)
|
||||
tv_show = self._find_best_match(search_results["results"], search_name, year)
|
||||
|
||||
# Search for TV show - try multiple strategies
|
||||
tv_show, search_source = await self._search_with_fallback(
|
||||
search_name, year, alt_titles
|
||||
)
|
||||
tv_id = tv_show["id"]
|
||||
|
||||
logger.info("Found match: %s (ID: %s)", tv_show['name'], tv_id)
|
||||
@@ -518,6 +529,137 @@ class NFOService:
|
||||
# Return first result (usually best match)
|
||||
return results[0]
|
||||
|
||||
async def _search_with_fallback(
|
||||
self,
|
||||
primary_query: str,
|
||||
year: Optional[int],
|
||||
alt_titles: Optional[List[str]] = None
|
||||
) -> Tuple[Dict[str, Any], str]:
|
||||
"""Search TMDB with fallback strategies.
|
||||
|
||||
Tries multiple search strategies in order:
|
||||
1. Primary query with year filter
|
||||
2. Alternative titles (e.g., Japanese name)
|
||||
3. Multi-language search (en-US)
|
||||
4. Search without year constraint
|
||||
5. Punctuation-normalized search
|
||||
|
||||
Args:
|
||||
primary_query: Primary search term
|
||||
year: Release year for filtering
|
||||
alt_titles: Alternative titles to try if primary fails
|
||||
|
||||
Returns:
|
||||
Tuple of (matched TV show dict, source description string)
|
||||
|
||||
Raises:
|
||||
TMDBAPIError: If all search strategies fail
|
||||
"""
|
||||
search_strategies = [
|
||||
# Strategy 1: Primary query as-is
|
||||
{"query": primary_query, "year": year, "lang": "de-DE", "desc": "primary"},
|
||||
]
|
||||
|
||||
# Strategy 2: Try alt titles (typically Japanese)
|
||||
if alt_titles:
|
||||
for alt in alt_titles:
|
||||
if alt != primary_query:
|
||||
search_strategies.append(
|
||||
{"query": alt, "year": year, "lang": "ja-JP", "desc": f"alt_title:{alt}"}
|
||||
)
|
||||
search_strategies.append(
|
||||
{"query": alt, "year": year, "lang": "en-US", "desc": f"alt_title:{alt}"}
|
||||
)
|
||||
|
||||
# Strategy 3: Try English search
|
||||
search_strategies.append(
|
||||
{"query": primary_query, "year": year, "lang": "en-US", "desc": "english"}
|
||||
)
|
||||
|
||||
# Strategy 4: Try without year constraint
|
||||
if year:
|
||||
search_strategies.append(
|
||||
{"query": primary_query, "year": None, "lang": "de-DE", "desc": "no_year"}
|
||||
)
|
||||
|
||||
# Strategy 5: Normalize punctuation
|
||||
normalized = self._normalize_query_for_search(primary_query)
|
||||
if normalized != primary_query:
|
||||
search_strategies.append(
|
||||
{"query": normalized, "year": year, "lang": "de-DE", "desc": f"normalized:{normalized}"}
|
||||
)
|
||||
|
||||
last_error = None
|
||||
for strategy in search_strategies:
|
||||
query = strategy["query"]
|
||||
lang = strategy["lang"]
|
||||
desc = strategy["desc"]
|
||||
|
||||
try:
|
||||
logger.debug(
|
||||
"TMDB search attempt: query='%s', lang=%s, year=%s, strategy=%s",
|
||||
query, lang, strategy["year"], desc
|
||||
)
|
||||
search_results = await self.tmdb_client.search_tv_show(
|
||||
query,
|
||||
language=lang
|
||||
)
|
||||
|
||||
if search_results.get("results"):
|
||||
# Apply year filter if we have one
|
||||
results = search_results["results"]
|
||||
if strategy["year"]:
|
||||
year_filtered = [
|
||||
r for r in results
|
||||
if r.get("first_air_date", "").startswith(str(strategy["year"]))
|
||||
]
|
||||
if year_filtered:
|
||||
match = year_filtered[0]
|
||||
else:
|
||||
# Year didn't match, still use first result but log it
|
||||
match = results[0]
|
||||
logger.debug(
|
||||
"Year %s not found in results for '%s', using: %s",
|
||||
strategy["year"], query, match["name"]
|
||||
)
|
||||
else:
|
||||
match = results[0]
|
||||
|
||||
logger.info(
|
||||
"TMDB search succeeded: '%s' found via strategy '%s' (ID: %s)",
|
||||
match["name"], desc, match["id"]
|
||||
)
|
||||
return match, desc
|
||||
else:
|
||||
logger.debug("No results for '%s' via %s", query, desc)
|
||||
|
||||
except TMDBAPIError as e:
|
||||
last_error = e
|
||||
logger.debug("Search strategy '%s' failed: %s", desc, e)
|
||||
continue
|
||||
|
||||
# All strategies exhausted
|
||||
raise TMDBAPIError(
|
||||
f"No results found for: {primary_query} (tried {len(search_strategies)} strategies)"
|
||||
)
|
||||
|
||||
def _normalize_query_for_search(self, query: str) -> str:
|
||||
"""Normalize query by removing punctuation and special chars.
|
||||
|
||||
Args:
|
||||
query: Original search query
|
||||
|
||||
Returns:
|
||||
Query with punctuation removed
|
||||
"""
|
||||
# Remove common punctuation but keep CJK characters
|
||||
normalized = unicodedata.normalize('NFKC', query)
|
||||
# Remove punctuation but not CJK
|
||||
normalized = re.sub(r'[^\w\s\u3000-\u9fff\u4e00-\u9faf]', '', normalized)
|
||||
# Collapse multiple spaces
|
||||
normalized = re.sub(r'\s+', ' ', normalized).strip()
|
||||
return normalized
|
||||
|
||||
|
||||
|
||||
async def _download_media_files(
|
||||
|
||||
@@ -12,6 +12,7 @@ Example:
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -38,6 +39,7 @@ class TMDBClient:
|
||||
|
||||
DEFAULT_BASE_URL = "https://api.themoviedb.org/3"
|
||||
DEFAULT_IMAGE_BASE_URL = "https://image.tmdb.org/t/p"
|
||||
NEGATIVE_CACHE_TTL = 86400 # 24 hours
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -63,6 +65,12 @@ class TMDBClient:
|
||||
self.max_connections = max_connections
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
self._cache: Dict[str, Any] = {}
|
||||
self._negative_cache: Dict[str, float] = {} # query -> timestamp when cached
|
||||
# TMDB allows ~40 req/s; use 30 concurrent + per-second throttle to stay safe
|
||||
self._semaphore = asyncio.Semaphore(30)
|
||||
self._rate_limit_lock = asyncio.Lock()
|
||||
self._request_timestamps: List[float] = []
|
||||
self._max_requests_per_second = 35 # Stay under TMDB's ~40/s limit
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
@@ -83,7 +91,7 @@ class TMDBClient:
|
||||
self,
|
||||
endpoint: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
max_retries: int = 3
|
||||
max_retries: int = 5
|
||||
) -> Dict[str, Any]:
|
||||
"""Make an async request to TMDB API with retries.
|
||||
|
||||
@@ -110,58 +118,100 @@ class TMDBClient:
|
||||
logger.debug("Cache hit for %s", endpoint)
|
||||
return self._cache[cache_key]
|
||||
|
||||
delay = 1
|
||||
# Check negative cache (cached empty results)
|
||||
negative_cache_key = f"{endpoint}:{str(sorted(params.items()))}"
|
||||
if negative_cache_key in self._negative_cache:
|
||||
if time.monotonic() - self._negative_cache[negative_cache_key] < self.NEGATIVE_CACHE_TTL:
|
||||
logger.debug("Negative cache hit for %s (cached empty result)", endpoint)
|
||||
return {"results": []}
|
||||
else:
|
||||
# Expired negative cache entry
|
||||
del self._negative_cache[negative_cache_key]
|
||||
|
||||
delay = 2
|
||||
last_error = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Re-ensure session before each attempt in case it was closed
|
||||
await self._ensure_session()
|
||||
|
||||
if self.session is None:
|
||||
raise TMDBAPIError("Session is not available")
|
||||
|
||||
logger.debug("TMDB API request: %s (attempt %s)", endpoint, attempt + 1)
|
||||
async with self.session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
|
||||
if resp.status == 401:
|
||||
raise TMDBAPIError("Invalid TMDB API key")
|
||||
elif resp.status == 404:
|
||||
raise TMDBAPIError(f"Resource not found: {endpoint}")
|
||||
elif resp.status == 429:
|
||||
# Rate limit - wait longer
|
||||
retry_after = int(resp.headers.get('Retry-After', delay * 2))
|
||||
logger.warning("Rate limited, waiting %ss", retry_after)
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
self._cache[cache_key] = data
|
||||
return data
|
||||
|
||||
except asyncio.TimeoutError as e:
|
||||
last_error = e
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning("Request timeout (attempt %s), retrying in %ss", attempt + 1, delay)
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
else:
|
||||
logger.error("Request timed out after %s attempts", max_retries)
|
||||
|
||||
except (aiohttp.ClientError, AttributeError) as e:
|
||||
last_error = e
|
||||
# If connector/session was closed, try to recreate it
|
||||
if "Connector is closed" in str(e) or isinstance(e, AttributeError):
|
||||
logger.warning("Session issue detected, recreating session: %s", e)
|
||||
self.session = None
|
||||
# Rate limiting: ensure we don't exceed ~35 requests/second
|
||||
async with self._rate_limit_lock:
|
||||
now = time.monotonic()
|
||||
# Remove timestamps older than 1 second
|
||||
self._request_timestamps = [
|
||||
ts for ts in self._request_timestamps if now - ts < 1.0
|
||||
]
|
||||
if len(self._request_timestamps) >= self._max_requests_per_second:
|
||||
sleep_time = 1.0 - (now - self._request_timestamps[0])
|
||||
if sleep_time > 0:
|
||||
logger.debug("Rate throttling: waiting %.2fs", sleep_time)
|
||||
await asyncio.sleep(sleep_time)
|
||||
self._request_timestamps.append(time.monotonic())
|
||||
|
||||
async with self._semaphore:
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Re-ensure session before each attempt in case it was closed
|
||||
await self._ensure_session()
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning("Request failed (attempt %s): %s, retrying in %ss", attempt + 1, e, delay)
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
else:
|
||||
logger.error("Request failed after %s attempts: %s", max_retries, e)
|
||||
|
||||
if self.session is None:
|
||||
raise TMDBAPIError("Session is not available")
|
||||
|
||||
logger.debug("TMDB API request: %s (attempt %s)", endpoint, attempt + 1)
|
||||
async with self.session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
|
||||
if resp.status == 401:
|
||||
raise TMDBAPIError("Invalid TMDB API key")
|
||||
elif resp.status == 404:
|
||||
raise TMDBAPIError(f"Resource not found: {endpoint}")
|
||||
elif resp.status == 429:
|
||||
# Rate limit - wait longer with exponential backoff
|
||||
retry_after = int(resp.headers.get('Retry-After', max(delay * 2, 10)))
|
||||
logger.warning("Rate limited, waiting %ss", retry_after)
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
self._cache[cache_key] = data
|
||||
# Cache negative result if empty
|
||||
if endpoint.startswith("search/") and not data.get("results"):
|
||||
self._negative_cache[negative_cache_key] = time.monotonic()
|
||||
logger.debug("Cached negative result for %s", endpoint)
|
||||
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,
|
||||
exc_info=True,
|
||||
)
|
||||
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}")
|
||||
|
||||
@@ -190,6 +240,34 @@ class TMDBClient:
|
||||
{"query": query, "language": language, "page": page}
|
||||
)
|
||||
|
||||
async def search_multi(
|
||||
self,
|
||||
query: str,
|
||||
language: str = "en-US",
|
||||
page: int = 1
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for movies and TV shows by name using TMDB multi search.
|
||||
|
||||
Multi search returns both movies and TV shows, useful for anime
|
||||
that might be indexed as movies on TMDB.
|
||||
|
||||
Args:
|
||||
query: Search query (show name)
|
||||
language: Language for results (default: English)
|
||||
page: Page number for pagination
|
||||
|
||||
Returns:
|
||||
Search results with list of movies and TV shows
|
||||
|
||||
Example:
|
||||
>>> results = await client.search_multi("Suzume no Tojimari")
|
||||
>>> shows = [r for r in results["results"] if r["media_type"] == "tv"]
|
||||
"""
|
||||
return await self._request(
|
||||
"search/multi",
|
||||
{"query": query, "language": language, "page": page}
|
||||
)
|
||||
|
||||
async def get_tv_show_details(
|
||||
self,
|
||||
tv_id: int,
|
||||
@@ -309,8 +387,38 @@ class TMDBClient:
|
||||
await self.session.close()
|
||||
self.session = None
|
||||
logger.debug("TMDB client session closed")
|
||||
|
||||
def __del__(self):
|
||||
"""Warn if session is unclosed during garbage collection."""
|
||||
if self.session is not None and not self.session.closed:
|
||||
logger.warning(
|
||||
"TMDBClient: unclosed session detected. "
|
||||
"Use 'async with TMDBClient(...)' or call close() explicitly."
|
||||
)
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the request cache."""
|
||||
self._cache.clear()
|
||||
logger.debug("TMDB client cache cleared")
|
||||
|
||||
def clear_negative_cache(self):
|
||||
"""Clear the negative result cache."""
|
||||
self._negative_cache.clear()
|
||||
logger.debug("TMDB negative cache cleared")
|
||||
|
||||
def cleanup_expired_negative_cache(self) -> int:
|
||||
"""Remove expired entries from negative cache.
|
||||
|
||||
Returns:
|
||||
Number of entries removed
|
||||
"""
|
||||
now = time.monotonic()
|
||||
expired_keys = [
|
||||
key for key, timestamp in self._negative_cache.items()
|
||||
if now - timestamp >= self.NEGATIVE_CACHE_TTL
|
||||
]
|
||||
for key in expired_keys:
|
||||
del self._negative_cache[key]
|
||||
if expired_keys:
|
||||
logger.debug("Removed %d expired negative cache entries", len(expired_keys))
|
||||
return len(expired_keys)
|
||||
|
||||
@@ -730,7 +730,11 @@ async def add_series(
|
||||
|
||||
# Create folder name with year if available
|
||||
if year:
|
||||
folder_name_with_year = f"{name} ({year})"
|
||||
year_suffix = f" ({year})"
|
||||
if name.endswith(year_suffix):
|
||||
folder_name_with_year = name
|
||||
else:
|
||||
folder_name_with_year = f"{name}{year_suffix}"
|
||||
else:
|
||||
folder_name_with_year = name
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import psutil
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -26,6 +26,9 @@ class HealthStatus(BaseModel):
|
||||
service: str = "aniworld-api"
|
||||
series_app_initialized: bool = False
|
||||
anime_directory_configured: bool = False
|
||||
scheduler_next_run: Optional[str] = None
|
||||
scheduler_last_run: Optional[str] = None
|
||||
checks: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class DatabaseHealth(BaseModel):
|
||||
@@ -171,29 +174,90 @@ def get_system_metrics() -> SystemMetrics:
|
||||
|
||||
|
||||
@router.get("", response_model=HealthStatus)
|
||||
async def basic_health_check() -> HealthStatus:
|
||||
async def basic_health_check(request: Request) -> HealthStatus:
|
||||
"""Basic health check endpoint.
|
||||
|
||||
This endpoint does not depend on anime_directory configuration
|
||||
and should always return 200 OK for basic health monitoring.
|
||||
Includes service information for identification.
|
||||
Includes scheduler next/last run times for monitoring tools.
|
||||
Includes startup health check results.
|
||||
|
||||
Returns:
|
||||
HealthStatus: Simple health status with timestamp and service info.
|
||||
"""
|
||||
from src.config.settings import settings
|
||||
from src.server.utils.dependencies import _series_app
|
||||
|
||||
# Get scheduler status for health monitoring
|
||||
scheduler_status: dict = {}
|
||||
try:
|
||||
from src.server.services.scheduler_service import get_scheduler_service
|
||||
scheduler_status = get_scheduler_service().get_status()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get startup checks from app state
|
||||
checks = getattr(request.app.state, "startup_checks", None)
|
||||
|
||||
# Determine overall status based on checks
|
||||
overall_status = "healthy"
|
||||
if checks:
|
||||
for check_name, check_data in checks.items():
|
||||
if check_data.get("status") == "error":
|
||||
overall_status = "unhealthy"
|
||||
break
|
||||
elif check_data.get("status") == "warning":
|
||||
overall_status = "degraded"
|
||||
|
||||
logger.debug("Basic health check requested")
|
||||
return HealthStatus(
|
||||
status="healthy",
|
||||
status=overall_status,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
service="aniworld-api",
|
||||
series_app_initialized=_series_app is not None,
|
||||
anime_directory_configured=bool(settings.anime_directory),
|
||||
scheduler_next_run=scheduler_status.get("next_run"),
|
||||
scheduler_last_run=scheduler_status.get("last_run"),
|
||||
checks=checks,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ready")
|
||||
async def ready_check(request: Request) -> Dict[str, Any]:
|
||||
"""Readiness check endpoint for container orchestrators.
|
||||
|
||||
Returns 503 if critical dependencies are not available.
|
||||
This endpoint is used by Kubernetes, Docker Swarm, etc. to determine
|
||||
if the container should receive traffic.
|
||||
|
||||
Returns:
|
||||
dict: Readiness status with checks details.
|
||||
"""
|
||||
checks = getattr(request.app.state, "startup_checks", {})
|
||||
|
||||
critical_failures = []
|
||||
for check_name, check_data in checks.items():
|
||||
if check_data.get("status") == "error":
|
||||
critical_failures.append(f"{check_name}: {check_data.get('message')}")
|
||||
|
||||
if critical_failures:
|
||||
return {
|
||||
"status": "not_ready",
|
||||
"ready": False,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"critical_failures": critical_failures,
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "ready",
|
||||
"ready": True,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/detailed", response_model=DetailedHealthStatus)
|
||||
async def detailed_health_check(
|
||||
db: AsyncSession = Depends(get_database_session),
|
||||
|
||||
@@ -15,7 +15,7 @@ from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||
|
||||
from src.server.database.base import Base, TimestampMixin
|
||||
@@ -347,6 +347,16 @@ class DownloadQueueItem(Base, TimestampMixin):
|
||||
index=True
|
||||
)
|
||||
|
||||
# Unique constraint to prevent duplicate pending queue items
|
||||
# An episode can only have one queue entry at a time
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_download_queue_episode_pending",
|
||||
"episode_id",
|
||||
unique=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Error handling
|
||||
error_message: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True,
|
||||
|
||||
@@ -104,6 +104,107 @@ async def _check_incomplete_series_on_startup(background_loader) -> None:
|
||||
logger.exception("Failed to check incomplete series on startup")
|
||||
|
||||
|
||||
async def _run_startup_health_checks(logger) -> dict:
|
||||
"""Run startup health checks for critical dependencies.
|
||||
|
||||
Checks:
|
||||
- ffmpeg availability
|
||||
- DNS resolution for aniworld.to and api.themoviedb.org
|
||||
- anime_directory configuration and writability
|
||||
|
||||
Args:
|
||||
logger: Logger instance for recording check results.
|
||||
|
||||
Returns:
|
||||
dict: Health check results with status and details for each check.
|
||||
"""
|
||||
import asyncio
|
||||
import shutil
|
||||
import socket
|
||||
from typing import Dict, Any
|
||||
|
||||
checks: Dict[str, Any] = {
|
||||
"ffmpeg": {"status": "unknown", "message": None},
|
||||
"dns_aniworld": {"status": "unknown", "message": None},
|
||||
"dns_tmdb": {"status": "unknown", "message": None},
|
||||
"anime_directory": {"status": "unknown", "message": None, "path": None},
|
||||
}
|
||||
|
||||
# Check ffmpeg availability
|
||||
try:
|
||||
ffmpeg_path = shutil.which("ffmpeg")
|
||||
if ffmpeg_path:
|
||||
checks["ffmpeg"]["status"] = "ok"
|
||||
checks["ffmpeg"]["message"] = f"Found at {ffmpeg_path}"
|
||||
logger.debug("ffmpeg health check passed: %s", ffmpeg_path)
|
||||
else:
|
||||
checks["ffmpeg"]["status"] = "warning"
|
||||
checks["ffmpeg"]["message"] = "ffmpeg not found in PATH"
|
||||
logger.warning("ffmpeg health check failed: not in PATH")
|
||||
except Exception as e:
|
||||
checks["ffmpeg"]["status"] = "error"
|
||||
checks["ffmpeg"]["message"] = str(e)
|
||||
logger.warning("Could not check ffmpeg: %s", e)
|
||||
|
||||
# Check DNS resolution for aniworld.to
|
||||
try:
|
||||
socket.gethostbyname("aniworld.to")
|
||||
checks["dns_aniworld"]["status"] = "ok"
|
||||
checks["dns_aniworld"]["message"] = "Resolved successfully"
|
||||
logger.debug("DNS health check passed for aniworld.to")
|
||||
except socket.gaierror as e:
|
||||
checks["dns_aniworld"]["status"] = "warning"
|
||||
checks["dns_aniworld"]["message"] = f"DNS resolution failed: {e}"
|
||||
logger.warning("DNS health check failed for aniworld.to: %s", e)
|
||||
except Exception as e:
|
||||
checks["dns_aniworld"]["status"] = "warning"
|
||||
checks["dns_aniworld"]["message"] = f"Unexpected error: {e}"
|
||||
logger.warning("Unexpected DNS error for aniworld.to: %s", e)
|
||||
|
||||
# Check DNS resolution for api.themoviedb.org
|
||||
try:
|
||||
socket.gethostbyname("api.themoviedb.org")
|
||||
checks["dns_tmdb"]["status"] = "ok"
|
||||
checks["dns_tmdb"]["message"] = "Resolved successfully"
|
||||
logger.debug("DNS health check passed for api.themoviedb.org")
|
||||
except socket.gaierror as e:
|
||||
checks["dns_tmdb"]["status"] = "warning"
|
||||
checks["dns_tmdb"]["message"] = f"DNS resolution failed: {e}"
|
||||
logger.warning("DNS health check failed for api.themoviedb.org: %s", e)
|
||||
except Exception as e:
|
||||
checks["dns_tmdb"]["status"] = "warning"
|
||||
checks["dns_tmdb"]["message"] = f"Unexpected error: {e}"
|
||||
logger.warning("Unexpected DNS error for api.themoviedb.org: %s", e)
|
||||
|
||||
# Check anime_directory configuration and writability
|
||||
from src.config.settings import settings
|
||||
anime_dir = settings.anime_directory
|
||||
|
||||
if not anime_dir:
|
||||
checks["anime_directory"]["status"] = "error"
|
||||
checks["anime_directory"]["message"] = "anime_directory not configured"
|
||||
checks["anime_directory"]["path"] = None
|
||||
logger.error("anime_directory health check failed: not configured")
|
||||
else:
|
||||
import os
|
||||
checks["anime_directory"]["path"] = anime_dir
|
||||
|
||||
if not os.path.isdir(anime_dir):
|
||||
checks["anime_directory"]["status"] = "error"
|
||||
checks["anime_directory"]["message"] = f"Directory does not exist: {anime_dir}"
|
||||
logger.error("anime_directory health check failed: %s does not exist", anime_dir)
|
||||
elif not os.access(anime_dir, os.W_OK):
|
||||
checks["anime_directory"]["status"] = "error"
|
||||
checks["anime_directory"]["message"] = f"Directory not writable: {anime_dir}"
|
||||
logger.error("anime_directory health check failed: %s not writable", anime_dir)
|
||||
else:
|
||||
checks["anime_directory"]["status"] = "ok"
|
||||
checks["anime_directory"]["message"] = f"Directory exists and is writable: {anime_dir}"
|
||||
logger.debug("anime_directory health check passed: %s", anime_dir)
|
||||
|
||||
return checks
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_application: FastAPI):
|
||||
"""Manage application lifespan (startup and shutdown).
|
||||
@@ -329,6 +430,27 @@ async def lifespan(_application: FastAPI):
|
||||
logger.info(
|
||||
"API documentation available at http://127.0.0.1:8000/api/docs"
|
||||
)
|
||||
|
||||
# Check for ffmpeg availability and warn if missing
|
||||
try:
|
||||
import shutil as _shutil
|
||||
if _shutil.which("ffmpeg") is None:
|
||||
logger.warning(
|
||||
"ffmpeg not found in PATH. HLS streams may fail to download. "
|
||||
"Install ffmpeg to enable HLS support."
|
||||
)
|
||||
else:
|
||||
logger.debug("ffmpeg found at: %s", _shutil.which("ffmpeg"))
|
||||
except Exception as _exc:
|
||||
logger.warning("Could not check for ffmpeg: %s", _exc)
|
||||
|
||||
# Run startup health checks and store results for /health endpoint
|
||||
try:
|
||||
startup_checks = await _run_startup_health_checks(logger)
|
||||
app.state.startup_checks = startup_checks
|
||||
except Exception as _exc:
|
||||
logger.warning("Could not run startup health checks: %s", _exc)
|
||||
app.state.startup_checks = {}
|
||||
except Exception as e:
|
||||
logger.error("Error during startup: %s", e, exc_info=True)
|
||||
startup_error = e
|
||||
|
||||
@@ -79,6 +79,9 @@ class DownloadService:
|
||||
self._pending_queue: deque[DownloadItem] = deque()
|
||||
# Helper dict for O(1) lookup of pending items by ID
|
||||
self._pending_items_by_id: Dict[str, DownloadItem] = {}
|
||||
# Helper dict for O(1) lookup of pending items by episode identity
|
||||
# Key: (serie_id, season, episode), Value: item ID
|
||||
self._pending_by_episode: Dict[tuple, str] = {}
|
||||
self._active_download: Optional[DownloadItem] = None
|
||||
self._completed_items: deque[DownloadItem] = deque(maxlen=100)
|
||||
self._failed_items: deque[DownloadItem] = deque(maxlen=50)
|
||||
@@ -409,7 +412,7 @@ class DownloadService:
|
||||
def _add_to_pending_queue(
|
||||
self, item: DownloadItem, front: bool = False
|
||||
) -> None:
|
||||
"""Add item to pending queue and update helper dict.
|
||||
"""Add item to pending queue and update helper dicts.
|
||||
|
||||
Args:
|
||||
item: Download item to add
|
||||
@@ -420,9 +423,12 @@ class DownloadService:
|
||||
else:
|
||||
self._pending_queue.append(item)
|
||||
self._pending_items_by_id[item.id] = item
|
||||
# Track by episode identity for deduplication
|
||||
ep_key = (item.serie_id, item.episode.season, item.episode.episode)
|
||||
self._pending_by_episode[ep_key] = item.id
|
||||
|
||||
def _remove_from_pending_queue(self, item_or_id: str) -> Optional[DownloadItem]: # noqa: E501
|
||||
"""Remove item from pending queue and update helper dict.
|
||||
"""Remove item from pending queue and update helper dicts.
|
||||
|
||||
Args:
|
||||
item_or_id: Item ID to remove
|
||||
@@ -442,6 +448,10 @@ class DownloadService:
|
||||
try:
|
||||
self._pending_queue.remove(item)
|
||||
del self._pending_items_by_id[item_id]
|
||||
# Clean up episode tracking
|
||||
ep_key = (item.serie_id, item.episode.season, item.episode.episode)
|
||||
if self._pending_by_episode.get(ep_key) == item_id:
|
||||
del self._pending_by_episode[ep_key]
|
||||
return item
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
@@ -481,10 +491,35 @@ class DownloadService:
|
||||
# Initialize queue progress tracking if not already done
|
||||
await self._init_queue_progress()
|
||||
|
||||
# Filter out episodes already in pending queue
|
||||
episodes_to_add = []
|
||||
skipped_count = 0
|
||||
seen_in_batch: set = set() # Track duplicates within this batch
|
||||
for ep in episodes:
|
||||
ep_key = (serie_id, ep.season, ep.episode)
|
||||
if ep_key in self._pending_by_episode or ep_key in seen_in_batch:
|
||||
logger.debug(
|
||||
"Skipping duplicate episode in queue",
|
||||
serie_key=serie_id,
|
||||
season=ep.season,
|
||||
episode=ep.episode,
|
||||
)
|
||||
skipped_count += 1
|
||||
continue
|
||||
seen_in_batch.add(ep_key)
|
||||
episodes_to_add.append(ep)
|
||||
|
||||
if skipped_count > 0:
|
||||
logger.info(
|
||||
"Skipped %d duplicate episodes in queue",
|
||||
skipped_count,
|
||||
serie_key=serie_id,
|
||||
)
|
||||
|
||||
created_ids = []
|
||||
|
||||
try:
|
||||
for episode in episodes:
|
||||
for episode in episodes_to_add:
|
||||
item = DownloadItem(
|
||||
id=self._generate_item_id(),
|
||||
serie_id=serie_id,
|
||||
|
||||
@@ -66,6 +66,9 @@ def _parse_nfo_title_and_year(nfo_path: Path) -> Tuple[Optional[str], Optional[s
|
||||
def _compute_expected_folder_name(title: str, year: str) -> str:
|
||||
"""Compute the expected folder name from title and year.
|
||||
|
||||
Removes any existing year suffixes (e.g., "(2021)") before adding the
|
||||
canonical one to prevent duplication across multiple folder rename runs.
|
||||
|
||||
Args:
|
||||
title: Series title from NFO.
|
||||
year: Release year from NFO.
|
||||
@@ -73,7 +76,15 @@ def _compute_expected_folder_name(title: str, year: str) -> str:
|
||||
Returns:
|
||||
Sanitised folder name in the format ``"{title} ({year})"``.
|
||||
"""
|
||||
raw_name = f"{title} ({year})"
|
||||
import re
|
||||
|
||||
# Remove all trailing year suffixes to prevent duplication.
|
||||
# This handles cases where the title already contains one or more years.
|
||||
# Regex pattern: matches one or more " (YYYY)" at the end of the string
|
||||
clean_title = re.sub(r'(\s*\(\d{4}\))+\s*$', '', title).strip()
|
||||
|
||||
year_suffix = f" ({year})"
|
||||
raw_name = f"{clean_title}{year_suffix}"
|
||||
return sanitize_folder_name(raw_name)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
Uses APScheduler's AsyncIOScheduler with CronTrigger for precise
|
||||
cron-based scheduling. The legacy interval-based loop has been removed
|
||||
in favour of the cron approach.
|
||||
|
||||
Jobs are persisted to a SQLite database so they survive process restarts.
|
||||
On startup, if the last scheduled run was missed (server was down at the
|
||||
cron time), the job is triggered immediately within a grace period.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,6 +14,7 @@ from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
import structlog
|
||||
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
@@ -20,6 +25,10 @@ logger = structlog.get_logger(__name__)
|
||||
|
||||
_JOB_ID = "scheduled_rescan"
|
||||
|
||||
# Grace period for missed jobs (1 hour — handles server downtime between
|
||||
# scheduled time and startup).
|
||||
_MISFIRE_GRACE_SECONDS = 3600
|
||||
|
||||
|
||||
class SchedulerServiceError(Exception):
|
||||
"""Service-level exception for scheduler operations."""
|
||||
@@ -44,6 +53,9 @@ class SchedulerService:
|
||||
self._config: Optional[SchedulerConfig] = None
|
||||
self._last_scan_time: Optional[datetime] = None
|
||||
self._scan_in_progress: bool = False
|
||||
# Cooldown tracking for auto-download to prevent rapid re-triggers
|
||||
self._last_auto_download_time: Optional[datetime] = None
|
||||
self._auto_download_cooldown_seconds: int = 300 # 5 minutes default
|
||||
logger.info("SchedulerService initialised")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -68,7 +80,10 @@ class SchedulerService:
|
||||
logger.error("Failed to load scheduler configuration", error=str(exc))
|
||||
raise SchedulerServiceError(f"Failed to load config: {exc}") from exc
|
||||
|
||||
self._scheduler = AsyncIOScheduler()
|
||||
jobstores = {
|
||||
"default": SQLAlchemyJobStore(url="sqlite:///./data/scheduler.db"),
|
||||
}
|
||||
self._scheduler = AsyncIOScheduler(jobstores=jobstores)
|
||||
|
||||
if not self._config.enabled:
|
||||
logger.info("Scheduler is disabled in configuration — not adding jobs")
|
||||
@@ -82,11 +97,12 @@ class SchedulerService:
|
||||
)
|
||||
else:
|
||||
self._scheduler.add_job(
|
||||
self._perform_rescan,
|
||||
_run_rescan_job,
|
||||
trigger=trigger,
|
||||
id=_JOB_ID,
|
||||
replace_existing=True,
|
||||
misfire_grace_time=300,
|
||||
misfire_grace_time=_MISFIRE_GRACE_SECONDS,
|
||||
coalesce=True,
|
||||
)
|
||||
logger.info(
|
||||
"Scheduler started with cron trigger",
|
||||
@@ -97,6 +113,16 @@ class SchedulerService:
|
||||
self._scheduler.start()
|
||||
self._is_running = True
|
||||
|
||||
# Startup recovery: if the server was down at the scheduled time and
|
||||
# the job is within the misfire window, APScheduler will run it
|
||||
# automatically. Log the scheduled time for visibility.
|
||||
job = self._scheduler.get_job(_JOB_ID)
|
||||
if job and job.next_run_time:
|
||||
logger.info(
|
||||
"Scheduler next run",
|
||||
next_run=job.next_run_time.isoformat(),
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the APScheduler gracefully."""
|
||||
if not self._is_running:
|
||||
@@ -172,11 +198,12 @@ class SchedulerService:
|
||||
)
|
||||
else:
|
||||
self._scheduler.add_job(
|
||||
self._perform_rescan,
|
||||
_run_rescan_job,
|
||||
trigger=trigger,
|
||||
id=_JOB_ID,
|
||||
replace_existing=True,
|
||||
misfire_grace_time=300,
|
||||
misfire_grace_time=_MISFIRE_GRACE_SECONDS,
|
||||
coalesce=True,
|
||||
)
|
||||
logger.info(
|
||||
"Scheduler job added with cron trigger",
|
||||
@@ -256,12 +283,26 @@ class SchedulerService:
|
||||
|
||||
async def _auto_download_missing(self) -> None:
|
||||
"""Queue and start downloads for all series with missing episodes."""
|
||||
from datetime import timedelta # noqa: PLC0415
|
||||
|
||||
from src.server.models.download import EpisodeIdentifier # noqa: PLC0415
|
||||
from src.server.utils.dependencies import ( # noqa: PLC0415
|
||||
get_anime_service,
|
||||
get_download_service,
|
||||
)
|
||||
|
||||
# Check cooldown to prevent rapid re-triggers
|
||||
now = datetime.now(timezone.utc)
|
||||
if self._last_auto_download_time is not None:
|
||||
elapsed = now - self._last_auto_download_time
|
||||
if elapsed < timedelta(seconds=self._auto_download_cooldown_seconds):
|
||||
logger.debug(
|
||||
"Auto-download skipped: cooldown active",
|
||||
elapsed_seconds=elapsed.total_seconds(),
|
||||
cooldown_seconds=self._auto_download_cooldown_seconds,
|
||||
)
|
||||
return
|
||||
|
||||
anime_service = get_anime_service()
|
||||
download_service = get_download_service()
|
||||
|
||||
@@ -303,6 +344,9 @@ class SchedulerService:
|
||||
await self._broadcast("auto_download_started", {"queued_count": queued_count})
|
||||
logger.info("Auto-download completed", queued_count=queued_count)
|
||||
|
||||
# Update cooldown timestamp after successful auto-download
|
||||
self._last_auto_download_time = datetime.now(timezone.utc)
|
||||
|
||||
async def _perform_rescan(self) -> None:
|
||||
"""Execute a library rescan and optionally trigger auto-download."""
|
||||
if self._scan_in_progress:
|
||||
@@ -389,6 +433,20 @@ class SchedulerService:
|
||||
self._scan_in_progress = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level job runner
|
||||
#
|
||||
# APScheduler cannot serialize bound methods (SchedulerService instance
|
||||
# contains a reference to the scheduler itself, creating a circular pickle
|
||||
# error). Using a module-level function avoids this.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _run_rescan_job() -> None:
|
||||
"""Module-level job entry point — delegates to the current service."""
|
||||
svc = get_scheduler_service()
|
||||
await svc._perform_rescan()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -334,6 +334,25 @@ async def test_add_series_sanitizes_folder_name(authenticated_client):
|
||||
assert "?" not in folder
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_series_does_not_duplicate_year(authenticated_client):
|
||||
"""Test that add_series doesn't duplicate year when name already contains it."""
|
||||
response = await authenticated_client.post(
|
||||
"/api/anime/add",
|
||||
json={
|
||||
"link": "https://aniworld.to/anime/stream/eighty-six",
|
||||
"name": "86 Eighty Six (2021)"
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
|
||||
# Folder should contain year only once
|
||||
folder = data["folder"]
|
||||
assert folder.count("(2021)") == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_series_returns_missing_episodes(authenticated_client):
|
||||
"""Test that add_series returns loading progress info."""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Pytest configuration and shared fixtures for all tests."""
|
||||
|
||||
import logging
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
@@ -149,3 +150,44 @@ def mock_series_app_download(monkeypatch):
|
||||
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_logging_state():
|
||||
"""Reset logging handlers and propagate flags before and after each test.
|
||||
|
||||
Tests that call setup_logging() or logging.config.dictConfig() may leave
|
||||
FileHandlers and propagate=False on various loggers. This fixture clears
|
||||
handlers and resets propagate for all relevant loggers before/after tests.
|
||||
"""
|
||||
# All loggers that might have handlers or propagate changes from test setup
|
||||
logger_names = (
|
||||
"aniworld", "uvicorn", "uvicorn.access", "uvicorn.error",
|
||||
"watchfiles.main"
|
||||
)
|
||||
|
||||
def clear_logger_state(logger_name):
|
||||
logger = logging.getLogger(logger_name)
|
||||
for handler in logger.handlers[:]:
|
||||
logger.removeHandler(handler)
|
||||
handler.close()
|
||||
# Reset propagate to default (True) for child loggers
|
||||
# Root logger propagate is always True by default
|
||||
if logger_name != "root":
|
||||
logger.propagate = True
|
||||
|
||||
# Clear state BEFORE test
|
||||
for name in logger_names:
|
||||
clear_logger_state(name)
|
||||
|
||||
yield
|
||||
|
||||
# Clear state AFTER test
|
||||
for name in logger_names:
|
||||
clear_logger_state(name)
|
||||
|
||||
# Also clear root handlers
|
||||
root = logging.getLogger()
|
||||
for handler in root.handlers[:]:
|
||||
root.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
|
||||
314
tests/integration/test_add_anime_nfo_content.py
Normal file
314
tests/integration/test_add_anime_nfo_content.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""Integration test: add an anime and verify NFO contains required information.
|
||||
|
||||
This test adds 'Sacrificial Princess And The King Of Beasts' and verifies
|
||||
that the generated tvshow.nfo contains all required tags including plot,
|
||||
outline, title, year, etc.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from lxml import etree
|
||||
|
||||
from src.core.services.nfo_service import NFOService
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock TMDB data for "Sacrificial Princess And The King Of Beasts"
|
||||
# ---------------------------------------------------------------------------
|
||||
MOCK_TMDB_DATA = {
|
||||
"id": 222093,
|
||||
"name": "Sacrificial Princess and the King of Beasts",
|
||||
"original_name": "贄姫と獣の王",
|
||||
"overview": (
|
||||
"A girl is offered as a sacrifice to a beastly king, "
|
||||
"but instead of being eaten, she becomes his bride."
|
||||
),
|
||||
"tagline": "A tale of love between a sacrifice and a beast king.",
|
||||
"first_air_date": "2023-04-20",
|
||||
"vote_average": 7.5,
|
||||
"vote_count": 150,
|
||||
"status": "Ended",
|
||||
"episode_run_time": [24],
|
||||
"genres": [
|
||||
{"id": 16, "name": "Animation"},
|
||||
{"id": 10749, "name": "Romance"},
|
||||
],
|
||||
"networks": [{"id": 1, "name": "TBS"}],
|
||||
"origin_country": ["JP"],
|
||||
"poster_path": "/poster.jpg",
|
||||
"backdrop_path": "/backdrop.jpg",
|
||||
"external_ids": {"imdb_id": "tt19896734", "tvdb_id": 421737},
|
||||
"credits": {
|
||||
"cast": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Test Actor",
|
||||
"character": "Sariphi",
|
||||
"profile_path": "/actor.jpg",
|
||||
}
|
||||
]
|
||||
},
|
||||
"images": {"logos": [{"file_path": "/logo.png"}]},
|
||||
"seasons": [{"season_number": 1, "name": "Season 1"}],
|
||||
}
|
||||
|
||||
MOCK_CONTENT_RATINGS = {
|
||||
"results": [
|
||||
{"iso_3166_1": "DE", "rating": "12"},
|
||||
{"iso_3166_1": "US", "rating": "TV-14"},
|
||||
]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Required XML tags that must exist and be non-empty after creation
|
||||
# ---------------------------------------------------------------------------
|
||||
REQUIRED_SINGLE_TAGS = [
|
||||
"title",
|
||||
"originaltitle",
|
||||
"sorttitle",
|
||||
"year",
|
||||
"plot",
|
||||
"outline",
|
||||
"runtime",
|
||||
"premiered",
|
||||
"status",
|
||||
"tmdbid",
|
||||
"imdbid",
|
||||
"tvdbid",
|
||||
"dateadded",
|
||||
"watched",
|
||||
"mpaa",
|
||||
"tagline",
|
||||
]
|
||||
|
||||
REQUIRED_MULTI_TAGS = [
|
||||
"genre",
|
||||
"studio",
|
||||
"country",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anime_dir(tmp_path: Path) -> Path:
|
||||
"""Temporary anime root directory."""
|
||||
d = tmp_path / "anime"
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nfo_service(anime_dir: Path) -> NFOService:
|
||||
"""NFOService pointing at the temp directory."""
|
||||
return NFOService(
|
||||
tmdb_api_key="test_api_key",
|
||||
anime_directory=str(anime_dir),
|
||||
image_size="w500",
|
||||
auto_create=True,
|
||||
)
|
||||
|
||||
|
||||
class TestAddAnimeNFOContent:
|
||||
"""Test that adding an anime produces an NFO with required information."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_anime_nfo_contains_required_tags(
|
||||
self,
|
||||
nfo_service: NFOService,
|
||||
anime_dir: Path,
|
||||
) -> None:
|
||||
"""Add 'Sacrificial Princess And The King Of Beasts' and verify NFO.
|
||||
|
||||
Steps:
|
||||
1. Create the series folder on disk.
|
||||
2. Mock TMDB API responses.
|
||||
3. Call create_tvshow_nfo to generate the NFO.
|
||||
4. Parse the resulting XML and assert every required tag is present
|
||||
and non-empty.
|
||||
"""
|
||||
series_key = "sacrificial-princess-and-the-king-of-beasts"
|
||||
series_name = "Sacrificial Princess And The King Of Beasts"
|
||||
series_folder = f"{series_name} (2023)"
|
||||
|
||||
# Step 1: Create series folder
|
||||
series_path = anime_dir / series_folder
|
||||
series_path.mkdir()
|
||||
|
||||
# Step 2: Mock TMDB API calls
|
||||
with patch.object(
|
||||
nfo_service.tmdb_client,
|
||||
"search_tv_show",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_search, patch.object(
|
||||
nfo_service.tmdb_client,
|
||||
"get_tv_show_details",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_details, patch.object(
|
||||
nfo_service.tmdb_client,
|
||||
"get_tv_show_content_ratings",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_ratings, patch.object(
|
||||
nfo_service.image_downloader,
|
||||
"download_all_media",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_download:
|
||||
|
||||
mock_search.return_value = {
|
||||
"results": [
|
||||
{
|
||||
"id": 222093,
|
||||
"name": series_name,
|
||||
"first_air_date": "2023-04-20",
|
||||
"overview": (
|
||||
"A girl is offered as a sacrifice to a beastly king..."
|
||||
),
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_details.return_value = MOCK_TMDB_DATA
|
||||
mock_ratings.return_value = MOCK_CONTENT_RATINGS
|
||||
mock_download.return_value = {
|
||||
"poster": True,
|
||||
"logo": True,
|
||||
"fanart": True,
|
||||
}
|
||||
|
||||
# Step 3: Create NFO
|
||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||
serie_name=series_name,
|
||||
serie_folder=series_folder,
|
||||
year=2023,
|
||||
download_poster=True,
|
||||
download_logo=True,
|
||||
download_fanart=True,
|
||||
)
|
||||
|
||||
# Verify NFO was created
|
||||
assert nfo_path.exists(), f"NFO file not created at {nfo_path}"
|
||||
assert nfo_path.name == "tvshow.nfo"
|
||||
|
||||
# Step 4: Parse NFO XML and verify required tags
|
||||
nfo_content = nfo_path.read_text(encoding="utf-8")
|
||||
root = etree.fromstring(nfo_content.encode("utf-8"))
|
||||
|
||||
missing: list[str] = []
|
||||
for tag in REQUIRED_SINGLE_TAGS:
|
||||
elem = root.find(f".//{tag}")
|
||||
if elem is None or not (elem.text or "").strip():
|
||||
missing.append(tag)
|
||||
|
||||
for tag in REQUIRED_MULTI_TAGS:
|
||||
elems = root.findall(f".//{tag}")
|
||||
if not elems or not any((e.text or "").strip() for e in elems):
|
||||
missing.append(tag)
|
||||
|
||||
# At least one actor must be present
|
||||
actors = root.findall(".//actor/name")
|
||||
if not actors or not any((a.text or "").strip() for a in actors):
|
||||
missing.append("actor/name")
|
||||
|
||||
assert not missing, (
|
||||
f"Missing or empty required tags in NFO for '{series_name}':\n "
|
||||
+ "\n ".join(missing)
|
||||
+ f"\n\nFull NFO content:\n{nfo_content}"
|
||||
)
|
||||
|
||||
# Verify specific values for the requested anime
|
||||
assert root.findtext(".//title") == "Sacrificial Princess and the King of Beasts"
|
||||
assert root.findtext(".//year") == "2023"
|
||||
assert root.findtext(".//status") == "Ended"
|
||||
assert root.findtext(".//watched") == "false"
|
||||
assert root.findtext(".//tmdbid") == "222093"
|
||||
assert root.findtext(".//imdbid") == "tt19896734"
|
||||
assert root.findtext(".//tvdbid") == "421737"
|
||||
|
||||
# Plot and outline must be non-trivial
|
||||
plot = root.findtext(".//plot") or ""
|
||||
outline = root.findtext(".//outline") or ""
|
||||
assert len(plot) >= 10, f"plot too short: {plot!r}"
|
||||
assert len(outline) >= 10, f"outline too short: {outline!r}"
|
||||
|
||||
# Verify multi-value fields
|
||||
genres = [g.text for g in root.findall(".//genre") if g.text]
|
||||
assert "Animation" in genres
|
||||
assert "Romance" in genres
|
||||
|
||||
studios = [s.text for s in root.findall(".//studio") if s.text]
|
||||
assert "TBS" in studios
|
||||
|
||||
countries = [c.text for c in root.findall(".//country") if c.text]
|
||||
assert "JP" in countries
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_anime_nfo_has_plot_and_outline(
|
||||
self,
|
||||
nfo_service: NFOService,
|
||||
anime_dir: Path,
|
||||
) -> None:
|
||||
"""Specifically verify that plot and outline tags are populated.
|
||||
|
||||
This is a focused regression test ensuring the NFO always contains
|
||||
meaningful plot and outline data.
|
||||
"""
|
||||
series_name = "Sacrificial Princess And The King Of Beasts"
|
||||
series_folder = f"{series_name} (2023)"
|
||||
series_path = anime_dir / series_folder
|
||||
series_path.mkdir()
|
||||
|
||||
with patch.object(
|
||||
nfo_service.tmdb_client,
|
||||
"search_tv_show",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_search, patch.object(
|
||||
nfo_service.tmdb_client,
|
||||
"get_tv_show_details",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_details, patch.object(
|
||||
nfo_service.tmdb_client,
|
||||
"get_tv_show_content_ratings",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_ratings, patch.object(
|
||||
nfo_service.image_downloader,
|
||||
"download_all_media",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_download:
|
||||
|
||||
mock_search.return_value = {
|
||||
"results": [
|
||||
{
|
||||
"id": 222093,
|
||||
"name": series_name,
|
||||
"first_air_date": "2023-04-20",
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_details.return_value = MOCK_TMDB_DATA
|
||||
mock_ratings.return_value = MOCK_CONTENT_RATINGS
|
||||
mock_download.return_value = {"poster": False, "logo": False, "fanart": False}
|
||||
|
||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||
serie_name=series_name,
|
||||
serie_folder=series_folder,
|
||||
year=2023,
|
||||
download_poster=False,
|
||||
download_logo=False,
|
||||
download_fanart=False,
|
||||
)
|
||||
|
||||
assert nfo_path.exists()
|
||||
root = etree.parse(str(nfo_path)).getroot()
|
||||
|
||||
plot_elem = root.find(".//plot")
|
||||
outline_elem = root.find(".//outline")
|
||||
|
||||
assert plot_elem is not None, "<plot> tag missing from NFO"
|
||||
assert outline_elem is not None, "<outline> tag missing from NFO"
|
||||
|
||||
plot_text = (plot_elem.text or "").strip()
|
||||
outline_text = (outline_elem.text or "").strip()
|
||||
|
||||
assert plot_text, "<plot> tag is empty"
|
||||
assert outline_text, "<outline> tag is empty"
|
||||
assert "sacrifice" in plot_text.lower() or "beast" in plot_text.lower(), (
|
||||
f"plot does not contain expected content: {plot_text!r}"
|
||||
)
|
||||
429
tests/integration/test_sacrificial_princess_nfo.py
Normal file
429
tests/integration/test_sacrificial_princess_nfo.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""Integration test: add 'Sacrificial Princess And The King Of Beasts' and verify NFO completeness.
|
||||
|
||||
Simulates the production scenario where this anime is added and validates
|
||||
that the generated tvshow.nfo contains plot, outline, and all other required
|
||||
information. Also tests the repair path for an incomplete NFO.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from lxml import etree
|
||||
|
||||
from src.core.services.nfo_repair_service import (
|
||||
NfoRepairService,
|
||||
_read_tmdb_id,
|
||||
find_missing_tags,
|
||||
nfo_needs_repair,
|
||||
)
|
||||
from src.core.services.nfo_service import NFOService
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TMDB mock data matching production responses for this anime
|
||||
# ---------------------------------------------------------------------------
|
||||
SERIES_KEY = "sacrificial-princess-and-the-king-of-beasts"
|
||||
SERIES_NAME = "Sacrificial Princess And The King Of Beasts"
|
||||
SERIES_FOLDER = "Sacrificial Princess And The King Of Beasts (2023)"
|
||||
TMDB_ID = 222093
|
||||
|
||||
MOCK_TMDB_DETAILS = {
|
||||
"id": TMDB_ID,
|
||||
"name": "Sacrificial Princess and the King of Beasts",
|
||||
"original_name": "贄姫と獣の王",
|
||||
"overview": (
|
||||
"On the outskirts of the Demon King's realm lies a small village of "
|
||||
"humans who offer a sacrifice to the beast king every year. Sariphi, "
|
||||
"the latest sacrificial girl, expects to be devoured — but instead "
|
||||
"her fearless nature catches the king's attention and she becomes "
|
||||
"his unlikely companion."
|
||||
),
|
||||
"tagline": "A tale of love between a sacrifice and a beast king.",
|
||||
"first_air_date": "2023-04-20",
|
||||
"last_air_date": "2023-09-28",
|
||||
"vote_average": 7.5,
|
||||
"vote_count": 150,
|
||||
"status": "Ended",
|
||||
"episode_run_time": [24],
|
||||
"number_of_seasons": 1,
|
||||
"number_of_episodes": 24,
|
||||
"genres": [
|
||||
{"id": 16, "name": "Animation"},
|
||||
{"id": 10749, "name": "Romance"},
|
||||
{"id": 10765, "name": "Sci-Fi & Fantasy"},
|
||||
],
|
||||
"networks": [{"id": 160, "name": "TBS"}],
|
||||
"production_companies": [{"id": 291, "name": "J.C.Staff"}],
|
||||
"origin_country": ["JP"],
|
||||
"poster_path": "/sacrificial_poster.jpg",
|
||||
"backdrop_path": "/sacrificial_backdrop.jpg",
|
||||
"external_ids": {"imdb_id": "tt19896734", "tvdb_id": 421737},
|
||||
"credits": {
|
||||
"cast": [
|
||||
{
|
||||
"id": 2072089,
|
||||
"name": "Kana Hanazawa",
|
||||
"character": "Sariphi",
|
||||
"profile_path": "/hanazawa.jpg",
|
||||
"order": 0,
|
||||
},
|
||||
{
|
||||
"id": 1254783,
|
||||
"name": "Satoshi Hino",
|
||||
"character": "Leonhart",
|
||||
"profile_path": "/hino.jpg",
|
||||
"order": 1,
|
||||
},
|
||||
]
|
||||
},
|
||||
"images": {"logos": [{"file_path": "/sacrificial_logo.png"}]},
|
||||
"seasons": [
|
||||
{"season_number": 0, "name": "Specials"},
|
||||
{"season_number": 1, "name": "Season 1"},
|
||||
],
|
||||
}
|
||||
|
||||
MOCK_CONTENT_RATINGS = {
|
||||
"results": [
|
||||
{"iso_3166_1": "DE", "rating": "12"},
|
||||
{"iso_3166_1": "US", "rating": "TV-14"},
|
||||
]
|
||||
}
|
||||
|
||||
MOCK_SEARCH_RESULTS = {
|
||||
"results": [
|
||||
{
|
||||
"id": TMDB_ID,
|
||||
"name": "Sacrificial Princess and the King of Beasts",
|
||||
"first_air_date": "2023-04-20",
|
||||
"overview": (
|
||||
"On the outskirts of the Demon King's realm lies a small village "
|
||||
"of humans who offer a sacrifice to the beast king every year."
|
||||
),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tags that MUST be present and non-empty in a complete NFO
|
||||
# ---------------------------------------------------------------------------
|
||||
REQUIRED_TAGS = [
|
||||
"title",
|
||||
"originaltitle",
|
||||
"year",
|
||||
"plot",
|
||||
"outline",
|
||||
"runtime",
|
||||
"premiered",
|
||||
"status",
|
||||
"tmdbid",
|
||||
"imdbid",
|
||||
"genre",
|
||||
"studio",
|
||||
"country",
|
||||
"watched",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anime_dir(tmp_path: Path) -> Path:
|
||||
"""Temporary anime directory."""
|
||||
d = tmp_path / "anime"
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nfo_service(anime_dir: Path) -> NFOService:
|
||||
"""NFOService configured for the temp directory."""
|
||||
return NFOService(
|
||||
tmdb_api_key="test_api_key",
|
||||
anime_directory=str(anime_dir),
|
||||
image_size="w500",
|
||||
auto_create=True,
|
||||
)
|
||||
|
||||
|
||||
def _mock_tmdb_calls(nfo_service: NFOService):
|
||||
"""Context manager that patches all TMDB calls with mock data."""
|
||||
return _PatchContext(nfo_service)
|
||||
|
||||
|
||||
class _PatchContext:
|
||||
"""Helper to patch TMDB calls on an NFOService instance."""
|
||||
|
||||
def __init__(self, svc: NFOService):
|
||||
self._svc = svc
|
||||
self._patches = []
|
||||
|
||||
def __enter__(self):
|
||||
p1 = patch.object(
|
||||
self._svc.tmdb_client, "search_tv_show", new_callable=AsyncMock
|
||||
)
|
||||
p2 = patch.object(
|
||||
self._svc.tmdb_client, "get_tv_show_details", new_callable=AsyncMock
|
||||
)
|
||||
p3 = patch.object(
|
||||
self._svc.tmdb_client, "get_tv_show_content_ratings", new_callable=AsyncMock
|
||||
)
|
||||
p4 = patch.object(
|
||||
self._svc.image_downloader, "download_all_media", new_callable=AsyncMock
|
||||
)
|
||||
p5 = patch.object(
|
||||
self._svc.tmdb_client, "_ensure_session", new_callable=AsyncMock
|
||||
)
|
||||
p6 = patch.object(
|
||||
self._svc.tmdb_client, "close", new_callable=AsyncMock
|
||||
)
|
||||
|
||||
self._patches = [p1, p2, p3, p4, p5, p6]
|
||||
mocks = [p.start() for p in self._patches]
|
||||
|
||||
mocks[0].return_value = MOCK_SEARCH_RESULTS
|
||||
mocks[1].return_value = MOCK_TMDB_DETAILS
|
||||
mocks[2].return_value = MOCK_CONTENT_RATINGS
|
||||
mocks[3].return_value = {"poster": True, "logo": True, "fanart": True}
|
||||
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
for p in self._patches:
|
||||
p.stop()
|
||||
|
||||
|
||||
class TestSacrificialPrincessNFO:
|
||||
"""Tests for 'Sacrificial Princess And The King Of Beasts' NFO generation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_anime_creates_complete_nfo(
|
||||
self, nfo_service: NFOService, anime_dir: Path
|
||||
) -> None:
|
||||
"""Adding the anime produces an NFO with all required tags filled."""
|
||||
series_path = anime_dir / SERIES_FOLDER
|
||||
series_path.mkdir()
|
||||
|
||||
with _PatchContext(nfo_service):
|
||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||
serie_name=SERIES_NAME,
|
||||
serie_folder=SERIES_FOLDER,
|
||||
year=2023,
|
||||
download_poster=True,
|
||||
download_logo=True,
|
||||
download_fanart=True,
|
||||
)
|
||||
|
||||
assert nfo_path.exists(), f"NFO not created at {nfo_path}"
|
||||
|
||||
root = etree.parse(str(nfo_path)).getroot()
|
||||
missing = []
|
||||
for tag in REQUIRED_TAGS:
|
||||
elems = root.findall(f".//{tag}")
|
||||
if not elems or not any((e.text or "").strip() for e in elems):
|
||||
missing.append(tag)
|
||||
|
||||
# Actor check
|
||||
actors = root.findall(".//actor/name")
|
||||
if not actors or not any((a.text or "").strip() for a in actors):
|
||||
missing.append("actor/name")
|
||||
|
||||
assert not missing, (
|
||||
f"Missing or empty tags in NFO for '{SERIES_NAME}':\n"
|
||||
f" {', '.join(missing)}\n\n"
|
||||
f"NFO content:\n{nfo_path.read_text(encoding='utf-8')}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nfo_plot_and_outline_are_meaningful(
|
||||
self, nfo_service: NFOService, anime_dir: Path
|
||||
) -> None:
|
||||
"""Plot and outline must contain substantial descriptive text."""
|
||||
series_path = anime_dir / SERIES_FOLDER
|
||||
series_path.mkdir()
|
||||
|
||||
with _PatchContext(nfo_service):
|
||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||
serie_name=SERIES_NAME,
|
||||
serie_folder=SERIES_FOLDER,
|
||||
year=2023,
|
||||
download_poster=False,
|
||||
download_logo=False,
|
||||
download_fanart=False,
|
||||
)
|
||||
|
||||
root = etree.parse(str(nfo_path)).getroot()
|
||||
|
||||
plot = (root.findtext(".//plot") or "").strip()
|
||||
outline = (root.findtext(".//outline") or "").strip()
|
||||
|
||||
assert len(plot) >= 20, f"Plot too short ({len(plot)} chars): {plot!r}"
|
||||
assert len(outline) >= 20, f"Outline too short ({len(outline)} chars): {outline!r}"
|
||||
|
||||
# Should mention relevant keywords from the series
|
||||
combined = (plot + outline).lower()
|
||||
assert any(
|
||||
kw in combined for kw in ("sacrifice", "beast", "king", "sariphi")
|
||||
), f"Plot/outline missing expected content:\n plot={plot!r}\n outline={outline!r}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nfo_specific_values(
|
||||
self, nfo_service: NFOService, anime_dir: Path
|
||||
) -> None:
|
||||
"""Verify specific metadata values match the anime."""
|
||||
series_path = anime_dir / SERIES_FOLDER
|
||||
series_path.mkdir()
|
||||
|
||||
with _PatchContext(nfo_service):
|
||||
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||
serie_name=SERIES_NAME,
|
||||
serie_folder=SERIES_FOLDER,
|
||||
year=2023,
|
||||
download_poster=False,
|
||||
download_logo=False,
|
||||
download_fanart=False,
|
||||
)
|
||||
|
||||
root = etree.parse(str(nfo_path)).getroot()
|
||||
|
||||
assert root.findtext(".//year") == "2023"
|
||||
assert root.findtext(".//status") == "Ended"
|
||||
assert root.findtext(".//tmdbid") == str(TMDB_ID)
|
||||
assert root.findtext(".//imdbid") == "tt19896734"
|
||||
assert root.findtext(".//watched") == "false"
|
||||
assert root.findtext(".//premiered") == "2023-04-20"
|
||||
|
||||
genres = [g.text for g in root.findall(".//genre") if g.text]
|
||||
assert "Animation" in genres
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incomplete_nfo_detected_as_needing_repair(
|
||||
self, anime_dir: Path
|
||||
) -> None:
|
||||
"""An NFO with only a <title> tag is detected as incomplete."""
|
||||
series_path = anime_dir / SERIES_FOLDER
|
||||
series_path.mkdir()
|
||||
nfo_path = series_path / "tvshow.nfo"
|
||||
|
||||
# Simulate production state: minimal NFO with only title
|
||||
nfo_path.write_text(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tvshow>\n"
|
||||
f" <title>{SERIES_NAME}</title>\n"
|
||||
"</tvshow>\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert nfo_needs_repair(nfo_path) is True
|
||||
|
||||
missing = find_missing_tags(nfo_path)
|
||||
# All these should be detected as missing
|
||||
for tag_label in ["plot", "year", "runtime", "premiered", "genre", "studio"]:
|
||||
assert tag_label in missing, f"'{tag_label}' not detected as missing"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repair_fixes_incomplete_nfo(
|
||||
self, nfo_service: NFOService, anime_dir: Path
|
||||
) -> None:
|
||||
"""NfoRepairService re-fetches and creates a complete NFO from an incomplete one."""
|
||||
series_path = anime_dir / SERIES_FOLDER
|
||||
series_path.mkdir()
|
||||
nfo_path = series_path / "tvshow.nfo"
|
||||
|
||||
# Write an incomplete NFO with a tmdbid so update_tvshow_nfo can work
|
||||
nfo_path.write_text(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tvshow>\n"
|
||||
f" <title>{SERIES_NAME}</title>\n"
|
||||
f" <tmdbid>{TMDB_ID}</tmdbid>\n"
|
||||
"</tvshow>\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert nfo_needs_repair(nfo_path) is True
|
||||
|
||||
# Patch TMDB calls for the update path
|
||||
with patch.object(
|
||||
nfo_service.tmdb_client, "_ensure_session", new_callable=AsyncMock
|
||||
), patch.object(
|
||||
nfo_service.tmdb_client, "get_tv_show_details", new_callable=AsyncMock
|
||||
) as mock_details, patch.object(
|
||||
nfo_service.tmdb_client, "get_tv_show_content_ratings", new_callable=AsyncMock
|
||||
) as mock_ratings, patch.object(
|
||||
nfo_service.tmdb_client, "close", new_callable=AsyncMock
|
||||
):
|
||||
mock_details.return_value = MOCK_TMDB_DETAILS
|
||||
mock_ratings.return_value = MOCK_CONTENT_RATINGS
|
||||
|
||||
repair_service = NfoRepairService(nfo_service)
|
||||
repaired = await repair_service.repair_series(series_path, SERIES_FOLDER)
|
||||
|
||||
assert repaired is True
|
||||
|
||||
# After repair, NFO should be complete
|
||||
assert nfo_needs_repair(nfo_path) is False
|
||||
|
||||
# Verify content
|
||||
root = etree.parse(str(nfo_path)).getroot()
|
||||
plot = (root.findtext(".//plot") or "").strip()
|
||||
assert len(plot) >= 20, f"Plot still incomplete after repair: {plot!r}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repair_recreates_nfo_without_tmdb_id(
|
||||
self, nfo_service: NFOService, anime_dir: Path
|
||||
) -> None:
|
||||
"""If the NFO has no <tmdbid>, repair falls back to create_tvshow_nfo."""
|
||||
series_path = anime_dir / SERIES_FOLDER
|
||||
series_path.mkdir()
|
||||
nfo_path = series_path / "tvshow.nfo"
|
||||
|
||||
# Simulate the production worst-case: only a title, no TMDB ID
|
||||
nfo_path.write_text(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tvshow>\n"
|
||||
f" <title>{SERIES_NAME}</title>\n"
|
||||
"</tvshow>\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert _read_tmdb_id(nfo_path) is None
|
||||
assert nfo_needs_repair(nfo_path) is True
|
||||
|
||||
with _PatchContext(nfo_service):
|
||||
repair_service = NfoRepairService(nfo_service)
|
||||
repaired = await repair_service.repair_series(series_path, SERIES_FOLDER)
|
||||
|
||||
assert repaired is True
|
||||
assert nfo_path.exists()
|
||||
assert nfo_needs_repair(nfo_path) is False
|
||||
|
||||
root = etree.parse(str(nfo_path)).getroot()
|
||||
plot = (root.findtext(".//plot") or "").strip()
|
||||
assert len(plot) >= 20, f"Plot incomplete after recreate: {plot!r}"
|
||||
assert root.findtext(".//tmdbid") == str(TMDB_ID)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_nfo_not_repaired(
|
||||
self, nfo_service: NFOService, anime_dir: Path
|
||||
) -> None:
|
||||
"""A complete NFO should not trigger a repair."""
|
||||
series_path = anime_dir / SERIES_FOLDER
|
||||
series_path.mkdir()
|
||||
|
||||
# First create a complete NFO
|
||||
with _PatchContext(nfo_service):
|
||||
await nfo_service.create_tvshow_nfo(
|
||||
serie_name=SERIES_NAME,
|
||||
serie_folder=SERIES_FOLDER,
|
||||
year=2023,
|
||||
download_poster=False,
|
||||
download_logo=False,
|
||||
download_fanart=False,
|
||||
)
|
||||
|
||||
nfo_path = series_path / "tvshow.nfo"
|
||||
assert nfo_path.exists()
|
||||
assert nfo_needs_repair(nfo_path) is False
|
||||
|
||||
# Repair should be skipped
|
||||
repair_service = NfoRepairService(nfo_service)
|
||||
repaired = await repair_service.repair_series(series_path, SERIES_FOLDER)
|
||||
assert repaired is False
|
||||
@@ -495,6 +495,20 @@ class TestNameWithYearProperty:
|
||||
assert "(2013)" in sanitized
|
||||
assert "Attack on Titan" in sanitized
|
||||
|
||||
def test_name_with_year_does_not_duplicate(self):
|
||||
"""Test that name_with_year doesn't duplicate year."""
|
||||
serie = Serie(
|
||||
key="eighty-six",
|
||||
name="86 Eighty Six (2021)",
|
||||
site="aniworld.to",
|
||||
folder="86 Eighty Six (2021)",
|
||||
episodeDict={},
|
||||
year=2021
|
||||
)
|
||||
|
||||
assert serie.name_with_year == "86 Eighty Six (2021)"
|
||||
assert serie.name_with_year.count("(2021)") == 1
|
||||
|
||||
|
||||
class TestEnsureFolderWithYear:
|
||||
"""Test Serie.ensure_folder_with_year method."""
|
||||
|
||||
@@ -834,3 +834,111 @@ class TestRemoveEpisodeFromMissingList:
|
||||
# Episode 2 should be removed from in-memory missing list
|
||||
assert 2 not in serie.episodeDict[1]
|
||||
assert serie.episodeDict[1] == [1, 3]
|
||||
|
||||
|
||||
class TestQueueDeduplication:
|
||||
"""Test queue deduplication to prevent duplicate entries."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_same_episode_twice_creates_only_one_entry(
|
||||
self, download_service
|
||||
):
|
||||
"""Test that adding the same episode twice only creates one queue entry."""
|
||||
episodes = [EpisodeIdentifier(season=1, episode=1)]
|
||||
|
||||
# Add same episode twice
|
||||
ids1 = await download_service.add_to_queue(
|
||||
serie_id="series-1",
|
||||
serie_folder="series",
|
||||
serie_name="Test Series",
|
||||
episodes=episodes,
|
||||
)
|
||||
ids2 = await download_service.add_to_queue(
|
||||
serie_id="series-1",
|
||||
serie_folder="series",
|
||||
serie_name="Test Series",
|
||||
episodes=episodes,
|
||||
)
|
||||
|
||||
# Should only have one entry
|
||||
assert len(download_service._pending_queue) == 1
|
||||
# First call creates one ID
|
||||
assert len(ids1) == 1
|
||||
# Second call creates zero IDs (deduplicated)
|
||||
assert len(ids2) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_different_episodes_creates_separate_entries(
|
||||
self, download_service
|
||||
):
|
||||
"""Test that different episodes create separate queue entries."""
|
||||
episodes1 = [EpisodeIdentifier(season=1, episode=1)]
|
||||
episodes2 = [EpisodeIdentifier(season=1, episode=2)]
|
||||
|
||||
ids1 = await download_service.add_to_queue(
|
||||
serie_id="series-1",
|
||||
serie_folder="series",
|
||||
serie_name="Test Series",
|
||||
episodes=episodes1,
|
||||
)
|
||||
ids2 = await download_service.add_to_queue(
|
||||
serie_id="series-1",
|
||||
serie_folder="series",
|
||||
serie_name="Test Series",
|
||||
episodes=episodes2,
|
||||
)
|
||||
|
||||
# Should have two separate entries
|
||||
assert len(download_service._pending_queue) == 2
|
||||
assert len(ids1) == 1
|
||||
assert len(ids2) == 1
|
||||
# IDs should be different
|
||||
assert ids1[0] != ids2[0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_same_episode_different_series_creates_entries(
|
||||
self, download_service
|
||||
):
|
||||
"""Test that same episode in different series creates separate entries."""
|
||||
episodes = [EpisodeIdentifier(season=1, episode=1)]
|
||||
|
||||
ids1 = await download_service.add_to_queue(
|
||||
serie_id="series-1",
|
||||
serie_folder="series1",
|
||||
serie_name="Test Series 1",
|
||||
episodes=episodes,
|
||||
)
|
||||
ids2 = await download_service.add_to_queue(
|
||||
serie_id="series-2",
|
||||
serie_folder="series2",
|
||||
serie_name="Test Series 2",
|
||||
episodes=episodes,
|
||||
)
|
||||
|
||||
# Should have two separate entries (different series)
|
||||
assert len(download_service._pending_queue) == 2
|
||||
assert len(ids1) == 1
|
||||
assert len(ids2) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_multiple_episodes_with_duplicates_filters_correctly(
|
||||
self, download_service
|
||||
):
|
||||
"""Test that adding multiple episodes with some duplicates filters correctly."""
|
||||
episodes = [
|
||||
EpisodeIdentifier(season=1, episode=1),
|
||||
EpisodeIdentifier(season=1, episode=2),
|
||||
EpisodeIdentifier(season=1, episode=1), # duplicate
|
||||
EpisodeIdentifier(season=1, episode=3),
|
||||
]
|
||||
|
||||
ids1 = await download_service.add_to_queue(
|
||||
serie_id="series-1",
|
||||
serie_folder="series",
|
||||
serie_name="Test Series",
|
||||
episodes=episodes,
|
||||
)
|
||||
|
||||
# Should only have 3 entries (1, 2, 3) - one filtered out
|
||||
assert len(download_service._pending_queue) == 3
|
||||
assert len(ids1) == 3
|
||||
|
||||
@@ -917,4 +917,47 @@ class TestAniworldLoaderCompat:
|
||||
"""AniworldLoader should extend EnhancedAniWorldLoader."""
|
||||
from src.core.providers.enhanced_provider import AniworldLoader
|
||||
|
||||
assert issubclass(AniworldLoader, EnhancedAniWorldLoader)
|
||||
assert issubclass(AniworldLoader, EnhancedAniWorldLoader)
|
||||
|
||||
class TestFfmpegHlsOptions:
|
||||
"""Test that yt-dlp is configured with ffmpeg for HLS streams."""
|
||||
|
||||
def test_ytdl_opts_include_ffmpeg_for_hls(self, enhanced_loader, tmp_path):
|
||||
"""yt-dlp options should include ffmpeg downloader and hls-use-mpegts."""
|
||||
temp_path = str(tmp_path / "temp.mp4")
|
||||
output_path = str(tmp_path / "output.mp4")
|
||||
|
||||
captured_opts = {}
|
||||
|
||||
def capture_ytdl_download(ydl_opts, link):
|
||||
captured_opts.update(ydl_opts)
|
||||
with open(temp_path, "wb") as f:
|
||||
f.write(b"fake-video-data")
|
||||
return True
|
||||
|
||||
with patch(
|
||||
"src.core.providers.enhanced_provider.recovery_strategies"
|
||||
) as mock_rs, patch(
|
||||
"src.core.providers.enhanced_provider.file_corruption_detector"
|
||||
) as mock_fcd, patch(
|
||||
"src.core.providers.enhanced_provider.get_integrity_manager"
|
||||
) as mock_im:
|
||||
mock_rs.handle_network_failure.return_value = (
|
||||
"https://direct.example.com/v.mp4",
|
||||
[],
|
||||
)
|
||||
mock_rs.handle_download_failure.side_effect = capture_ytdl_download
|
||||
mock_fcd.is_valid_video_file.return_value = True
|
||||
mock_im.return_value.store_checksum.return_value = "abc123"
|
||||
|
||||
enhanced_loader._download_with_recovery(
|
||||
1, 1, "test", "German Dub",
|
||||
temp_path, output_path, None,
|
||||
)
|
||||
|
||||
assert captured_opts.get("downloader") == "ffmpeg", (
|
||||
f"Expected downloader='ffmpeg', got {captured_opts.get('downloader')}"
|
||||
)
|
||||
assert captured_opts.get("hls_use_mpegts") is True, (
|
||||
f"Expected hls_use_mpegts=True, got {captured_opts.get('hls_use_mpegts')}"
|
||||
)
|
||||
|
||||
54
tests/unit/test_ffmpeg_health_check.py
Normal file
54
tests/unit/test_ffmpeg_health_check.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Unit tests for ffmpeg health check in fastapi_app.py."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestFfmpegHealthCheck:
|
||||
"""Test ffmpeg health check warns when not in PATH."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ffmpeg_missing_warns(self):
|
||||
"""Should log warning when ffmpeg not found in PATH."""
|
||||
with patch("shutil.which", return_value=None):
|
||||
with patch("src.server.fastapi_app.setup_logging") as mock_log:
|
||||
mock_logger = MagicMock()
|
||||
mock_log.return_value = mock_logger
|
||||
|
||||
from src.server.fastapi_app import lifespan
|
||||
app = MagicMock()
|
||||
|
||||
with pytest.raises(StopIteration):
|
||||
async with lifespan(app):
|
||||
pass
|
||||
|
||||
# Should have logged a warning about ffmpeg
|
||||
warning_calls = [
|
||||
c for c in mock_logger.warning.call_args_list
|
||||
if "ffmpeg" in str(c)
|
||||
]
|
||||
assert len(warning_calls) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ffmpeg_present_no_warning(self):
|
||||
"""Should not log warning when ffmpeg is found."""
|
||||
with patch("shutil.which", return_value="/usr/bin/ffmpeg"):
|
||||
with patch("src.server.fastapi_app.setup_logging") as mock_log:
|
||||
mock_logger = MagicMock()
|
||||
mock_log.return_value = mock_logger
|
||||
|
||||
from src.server.fastapi_app import lifespan
|
||||
app = MagicMock()
|
||||
|
||||
with pytest.raises(StopIteration):
|
||||
async with lifespan(app):
|
||||
pass
|
||||
|
||||
# Should NOT have logged a warning about ffmpeg
|
||||
warning_calls = [
|
||||
c for c in mock_logger.warning.call_args_list
|
||||
if "ffmpeg" in str(c)
|
||||
]
|
||||
assert len(warning_calls) == 0
|
||||
@@ -75,6 +75,84 @@ class TestComputeExpectedFolderName:
|
||||
result = _compute_expected_folder_name("A / B", "2021")
|
||||
assert result == "A B (2021)"
|
||||
|
||||
def test_does_not_duplicate_year(self) -> None:
|
||||
result = _compute_expected_folder_name("86 Eighty Six (2021)", "2021")
|
||||
assert result == "86 Eighty Six (2021)"
|
||||
assert result.count("(2021)") == 1
|
||||
|
||||
def test_removes_duplicate_year_suffixes_bug_86_eighty_six(self) -> None:
|
||||
"""Test the bug fix for duplicate year suffixes.
|
||||
|
||||
Issue: "86 Eighty Six (2021) (2021) (2021) (2021) (2021)"
|
||||
should become "86 Eighty Six (2021)"
|
||||
"""
|
||||
result = _compute_expected_folder_name(
|
||||
"86 Eighty Six (2021) (2021) (2021) (2021) (2021)", "2021"
|
||||
)
|
||||
assert result == "86 Eighty Six (2021)"
|
||||
assert result.count("(2021)") == 1
|
||||
|
||||
def test_removes_duplicate_year_suffixes_alma_chan(self) -> None:
|
||||
"""Test the bug fix for duplicate year suffixes with long title.
|
||||
|
||||
Issue: "Alma-chan Wants to Be a Family! (2025) (2025) (2025) (2025) (2025)"
|
||||
should become "Alma-chan Wants to Be a Family! (2025)"
|
||||
"""
|
||||
result = _compute_expected_folder_name(
|
||||
"Alma-chan Wants to Be a Family! (2025) (2025) (2025) (2025) (2025)",
|
||||
"2025",
|
||||
)
|
||||
assert result == "Alma-chan Wants to Be a Family! (2025)"
|
||||
assert result.count("(2025)") == 1
|
||||
|
||||
def test_removes_duplicate_year_suffixes_bogus_skill(self) -> None:
|
||||
"""Test the bug fix for duplicate year suffixes with very long title.
|
||||
|
||||
Issue: Long title with duplicated years should be cleaned.
|
||||
"""
|
||||
result = _compute_expected_folder_name(
|
||||
"Bogus Skill Fruitmaster About That Time I Became Able to Eat "
|
||||
"Unlimited Numbers of Skill Fruits (That Kill You) (2025) (2025)",
|
||||
"2025",
|
||||
)
|
||||
assert "(2025)" in result
|
||||
assert result.count("(2025)") == 1
|
||||
|
||||
def test_removes_multiple_different_year_suffixes(self) -> None:
|
||||
"""Test that old duplicate years are removed and new one added."""
|
||||
result = _compute_expected_folder_name(
|
||||
"Series (2020) (2020) (2020)", "2021"
|
||||
)
|
||||
assert result == "Series (2021)"
|
||||
assert "(2020)" not in result
|
||||
assert result.count("(2021)") == 1
|
||||
|
||||
def test_handles_whitespace_with_duplicate_years(self) -> None:
|
||||
"""Test that extra whitespace is removed along with duplicate years."""
|
||||
result = _compute_expected_folder_name(
|
||||
"Series (2021) (2021) (2021) ", "2021"
|
||||
)
|
||||
assert result == "Series (2021)"
|
||||
assert result.count("(2021)") == 1
|
||||
assert not result.endswith(" ")
|
||||
|
||||
def test_idempotent_multiple_calls(self) -> None:
|
||||
"""Test that calling the function multiple times produces the same result."""
|
||||
title = "86 Eighty Six (2021) (2021) (2021)"
|
||||
year = "2021"
|
||||
|
||||
# First call
|
||||
result1 = _compute_expected_folder_name(title, year)
|
||||
# Second call with the result
|
||||
result2 = _compute_expected_folder_name(result1, year)
|
||||
# Third call with the result
|
||||
result3 = _compute_expected_folder_name(result2, year)
|
||||
|
||||
# All results should be identical
|
||||
assert result1 == result2 == result3
|
||||
assert result1 == "86 Eighty Six (2021)"
|
||||
assert result1.count("(2021)") == 1
|
||||
|
||||
|
||||
class TestIsSeriesBeingDownloaded:
|
||||
"""Tests for _is_series_being_downloaded."""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Unit tests for health check endpoints."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -12,16 +12,20 @@ from src.server.api.health import (
|
||||
check_database_health,
|
||||
check_filesystem_health,
|
||||
get_system_metrics,
|
||||
ready_check,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_health_check():
|
||||
"""Test basic health check endpoint."""
|
||||
async def test_basic_health_check_no_startup_checks():
|
||||
"""Test basic health check endpoint with no startup checks."""
|
||||
mock_request = MagicMock()
|
||||
mock_request.app.state.startup_checks = {}
|
||||
|
||||
with patch("src.config.settings.settings") as mock_settings, \
|
||||
patch("src.server.utils.dependencies._series_app", None):
|
||||
mock_settings.anime_directory = ""
|
||||
result = await basic_health_check()
|
||||
result = await basic_health_check(mock_request)
|
||||
|
||||
assert isinstance(result, HealthStatus)
|
||||
assert result.status == "healthy"
|
||||
@@ -32,6 +36,85 @@ async def test_basic_health_check():
|
||||
assert result.anime_directory_configured is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_health_check_with_error_check():
|
||||
"""Test basic health check reflects error status from startup checks."""
|
||||
mock_request = MagicMock()
|
||||
mock_request.app.state.startup_checks = {
|
||||
"anime_directory": {"status": "error", "message": "not configured", "path": None},
|
||||
"ffmpeg": {"status": "ok", "message": "Found at /usr/bin/ffmpeg"},
|
||||
"dns_aniworld": {"status": "ok", "message": "Resolved successfully"},
|
||||
"dns_tmdb": {"status": "ok", "message": "Resolved successfully"},
|
||||
}
|
||||
|
||||
with patch("src.config.settings.settings") as mock_settings, \
|
||||
patch("src.server.utils.dependencies._series_app", None):
|
||||
mock_settings.anime_directory = ""
|
||||
result = await basic_health_check(mock_request)
|
||||
|
||||
assert isinstance(result, HealthStatus)
|
||||
assert result.status == "unhealthy"
|
||||
assert result.checks is not None
|
||||
assert result.checks["anime_directory"]["status"] == "error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_health_check_with_warning_only():
|
||||
"""Test basic health check shows degraded when only warnings present."""
|
||||
mock_request = MagicMock()
|
||||
mock_request.app.state.startup_checks = {
|
||||
"anime_directory": {"status": "ok", "message": "Found", "path": "/anime"},
|
||||
"ffmpeg": {"status": "warning", "message": "not found in PATH"},
|
||||
"dns_aniworld": {"status": "ok", "message": "Resolved successfully"},
|
||||
"dns_tmdb": {"status": "ok", "message": "Resolved successfully"},
|
||||
}
|
||||
|
||||
with patch("src.config.settings.settings") as mock_settings, \
|
||||
patch("src.server.utils.dependencies._series_app", None):
|
||||
mock_settings.anime_directory = "/anime"
|
||||
result = await basic_health_check(mock_request)
|
||||
|
||||
assert isinstance(result, HealthStatus)
|
||||
assert result.status == "degraded"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ready_check_all_healthy():
|
||||
"""Test ready check returns ready when all checks pass."""
|
||||
mock_request = MagicMock()
|
||||
mock_request.app.state.startup_checks = {
|
||||
"anime_directory": {"status": "ok", "message": "Found", "path": "/anime"},
|
||||
"ffmpeg": {"status": "ok", "message": "Found at /usr/bin/ffmpeg"},
|
||||
"dns_aniworld": {"status": "ok", "message": "Resolved successfully"},
|
||||
"dns_tmdb": {"status": "ok", "message": "Resolved successfully"},
|
||||
}
|
||||
|
||||
result = await ready_check(mock_request)
|
||||
|
||||
assert result["ready"] is True
|
||||
assert result["status"] == "ready"
|
||||
assert "critical_failures" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ready_check_with_critical_failure():
|
||||
"""Test ready check returns not_ready when anime_directory not configured."""
|
||||
mock_request = MagicMock()
|
||||
mock_request.app.state.startup_checks = {
|
||||
"anime_directory": {"status": "error", "message": "not configured", "path": None},
|
||||
"ffmpeg": {"status": "warning", "message": "not found in PATH"},
|
||||
"dns_aniworld": {"status": "ok", "message": "Resolved successfully"},
|
||||
"dns_tmdb": {"status": "ok", "message": "Resolved successfully"},
|
||||
}
|
||||
|
||||
result = await ready_check(mock_request)
|
||||
|
||||
assert result["ready"] is False
|
||||
assert result["status"] == "not_ready"
|
||||
assert len(result["critical_failures"]) == 1
|
||||
assert "anime_directory" in result["critical_failures"][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_health_check_success():
|
||||
"""Test database health check with successful connection."""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Unit tests for NFO service."""
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -22,6 +23,14 @@ def nfo_service(tmp_path):
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmdb_client():
|
||||
"""Create TMDB client with test API key."""
|
||||
from src.core.services.tmdb_client import TMDBClient
|
||||
client = TMDBClient(api_key="test_api_key")
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tmdb_data():
|
||||
"""Mock TMDB API response data."""
|
||||
@@ -342,7 +351,7 @@ class TestCreateTVShowNFO:
|
||||
)
|
||||
|
||||
# Assert - should search with clean name "The Dreaming Boy is a Realist"
|
||||
mock_search.assert_called_once_with("The Dreaming Boy is a Realist")
|
||||
mock_search.assert_called_once_with("The Dreaming Boy is a Realist", language="de-DE")
|
||||
|
||||
# Verify NFO file was created
|
||||
assert nfo_path.exists()
|
||||
@@ -362,29 +371,28 @@ class TestCreateTVShowNFO:
|
||||
|
||||
with patch.object(nfo_service.tmdb_client, '__aenter__', return_value=nfo_service.tmdb_client):
|
||||
with patch.object(nfo_service.tmdb_client, '__aexit__', return_value=None):
|
||||
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search:
|
||||
with patch.object(nfo_service, '_search_with_fallback', new_callable=AsyncMock) as mock_search_fallback:
|
||||
with patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details:
|
||||
with patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings:
|
||||
with patch.object(nfo_service.image_downloader, 'download_poster', new_callable=AsyncMock):
|
||||
with patch.object(nfo_service.image_downloader, 'download_logo', new_callable=AsyncMock):
|
||||
with patch.object(nfo_service.image_downloader, 'download_fanart', new_callable=AsyncMock):
|
||||
with patch.object(nfo_service, '_find_best_match') as mock_find_match:
|
||||
mock_search.return_value = search_results
|
||||
mock_details.return_value = mock_tmdb_data
|
||||
mock_ratings.return_value = mock_content_ratings_de
|
||||
mock_find_match.return_value = mock_tmdb_data
|
||||
|
||||
# Act
|
||||
await nfo_service.create_tvshow_nfo(
|
||||
serie_name=serie_name,
|
||||
serie_folder=serie_folder,
|
||||
year=explicit_year # Explicit year provided
|
||||
)
|
||||
|
||||
# Assert - should use explicit year, not extracted year
|
||||
mock_find_match.assert_called_once()
|
||||
call_args = mock_find_match.call_args
|
||||
assert call_args[0][2] == explicit_year # Third argument is year
|
||||
with patch.object(nfo_service, '_enrich_details_with_fallback', new_callable=AsyncMock) as mock_enrich:
|
||||
with patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock):
|
||||
mock_search_fallback.return_value = (mock_tmdb_data, "primary")
|
||||
mock_details.return_value = mock_tmdb_data
|
||||
mock_ratings.return_value = mock_content_ratings_de
|
||||
mock_enrich.return_value = mock_tmdb_data
|
||||
|
||||
# Act
|
||||
await nfo_service.create_tvshow_nfo(
|
||||
serie_name=serie_name,
|
||||
serie_folder=serie_folder,
|
||||
year=explicit_year # Explicit year provided
|
||||
)
|
||||
|
||||
# Assert - _search_with_fallback should be called with explicit year
|
||||
mock_search_fallback.assert_called_once()
|
||||
call_args = mock_search_fallback.call_args
|
||||
assert call_args[0][0] == "Attack on Titan" # clean name
|
||||
assert call_args[0][1] == explicit_year # explicit year
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_nfo_no_results_with_clean_name(self, nfo_service, tmp_path):
|
||||
@@ -396,8 +404,8 @@ class TestCreateTVShowNFO:
|
||||
|
||||
with patch.object(nfo_service.tmdb_client, '__aenter__', return_value=nfo_service.tmdb_client):
|
||||
with patch.object(nfo_service.tmdb_client, '__aexit__', return_value=None):
|
||||
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search:
|
||||
mock_search.return_value = {"results": []}
|
||||
with patch.object(nfo_service, '_search_with_fallback', new_callable=AsyncMock) as mock_search_fallback:
|
||||
mock_search_fallback.side_effect = TMDBAPIError("No results found for: Nonexistent Series")
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(TMDBAPIError) as exc_info:
|
||||
@@ -408,8 +416,6 @@ class TestCreateTVShowNFO:
|
||||
|
||||
# Should use clean name in error message
|
||||
assert "No results found for: Nonexistent Series" in str(exc_info.value)
|
||||
# Should have searched with clean name
|
||||
mock_search.assert_called_once_with("Nonexistent Series")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_nfo_with_fsk(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de):
|
||||
@@ -1616,3 +1622,190 @@ class TestEnrichFallbackLanguages:
|
||||
# de-DE + en-US = 2 calls (no ja-JP needed)
|
||||
assert mock_details.call_count == 2
|
||||
|
||||
|
||||
class TestSearchWithFallback:
|
||||
"""Tests for TMDB search fallback functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_fallback_primary_success(self, nfo_service, mock_tmdb_data):
|
||||
"""Test that primary query succeeds without fallback."""
|
||||
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search:
|
||||
mock_search.return_value = {"results": [mock_tmdb_data]}
|
||||
|
||||
result, source = await nfo_service._search_with_fallback(
|
||||
"Attack on Titan", 2013, None
|
||||
)
|
||||
|
||||
assert result["id"] == mock_tmdb_data["id"]
|
||||
assert source == "primary"
|
||||
assert mock_search.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_fallback_uses_alt_titles(self, nfo_service, mock_tmdb_data):
|
||||
"""Test that alternative titles are tried when primary fails."""
|
||||
mock_search = AsyncMock()
|
||||
# First call returns empty, second (with Japanese title) returns result
|
||||
mock_search.side_effect = [
|
||||
{"results": []},
|
||||
{"results": [mock_tmdb_data]}
|
||||
]
|
||||
|
||||
with patch.object(nfo_service.tmdb_client, 'search_tv_show', mock_search):
|
||||
result, source = await nfo_service._search_with_fallback(
|
||||
"Suzume", 2022, alt_titles=["すずめの戸締まり"]
|
||||
)
|
||||
|
||||
assert result["id"] == mock_tmdb_data["id"]
|
||||
assert "alt_title" in source
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_fallback_year_not_matched(self, nfo_service, mock_tmdb_data):
|
||||
"""Test fallback when year doesn't match but first result is used anyway."""
|
||||
# First result doesn't match year, but should still be returned
|
||||
different_year_data = {**mock_tmdb_data, "first_air_date": "2020-01-01"}
|
||||
mock_search = AsyncMock(return_value={"results": [different_year_data]})
|
||||
|
||||
with patch.object(nfo_service.tmdb_client, 'search_tv_show', mock_search):
|
||||
result, source = await nfo_service._search_with_fallback(
|
||||
"Attack on Titan", 2013, None
|
||||
)
|
||||
|
||||
assert result["id"] == mock_tmdb_data["id"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_fallback_no_year_strategy(self, nfo_service, mock_tmdb_data):
|
||||
"""Test that search without year is attempted when year-filtered fails."""
|
||||
mock_search = AsyncMock()
|
||||
# First call with year fails, second (without year) succeeds
|
||||
mock_search.side_effect = [
|
||||
{"results": []},
|
||||
{"results": [mock_tmdb_data]}
|
||||
]
|
||||
|
||||
with patch.object(nfo_service.tmdb_client, 'search_tv_show', mock_search):
|
||||
result, source = await nfo_service._search_with_fallback(
|
||||
"Attack on Titan", 2013, None
|
||||
)
|
||||
|
||||
assert result["id"] == mock_tmdb_data["id"]
|
||||
# Strategy order: primary -> english -> no_year (english comes before no_year)
|
||||
assert mock_search.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_fallback_all_strategies_fail(self, nfo_service):
|
||||
"""Test that TMDBAPIError is raised when all strategies fail."""
|
||||
mock_search = AsyncMock(return_value={"results": []})
|
||||
|
||||
with patch.object(nfo_service.tmdb_client, 'search_tv_show', mock_search):
|
||||
with pytest.raises(TMDBAPIError) as exc_info:
|
||||
await nfo_service._search_with_fallback(
|
||||
"Nonexistent Anime", 2023, None
|
||||
)
|
||||
|
||||
assert "Nonexistent Anime" in str(exc_info.value)
|
||||
# Should have tried multiple strategies
|
||||
assert mock_search.call_count >= 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_fallback_normalizes_punctuation(self, nfo_service, mock_tmdb_data):
|
||||
"""Test that punctuation-normalized search is attempted."""
|
||||
mock_search = AsyncMock()
|
||||
# First call fails, normalized version succeeds
|
||||
mock_search.side_effect = [
|
||||
{"results": []},
|
||||
{"results": [mock_tmdb_data]}
|
||||
]
|
||||
|
||||
with patch.object(nfo_service.tmdb_client, 'search_tv_show', mock_search):
|
||||
result, source = await nfo_service._search_with_fallback(
|
||||
"Attack on Titan:", 2013, None
|
||||
)
|
||||
|
||||
assert result["id"] == mock_tmdb_data["id"]
|
||||
|
||||
def test_normalize_query_for_search(self, nfo_service):
|
||||
"""Test punctuation normalization in queries."""
|
||||
# Test normal punctuation removal
|
||||
assert nfo_service._normalize_query_for_search("Attack on Titan:") == "Attack on Titan"
|
||||
assert nfo_service._normalize_query_for_search("Suzume no Tojimari.") == "Suzume no Tojimari"
|
||||
# Test CJK characters are preserved
|
||||
assert "すずめ" in nfo_service._normalize_query_for_search("すずめの戸締まり")
|
||||
# Test multiple spaces are collapsed
|
||||
assert nfo_service._normalize_query_for_search("Attack on Titan") == "Attack on Titan"
|
||||
|
||||
|
||||
class TestNegativeCache:
|
||||
"""Tests for negative result caching in TMDB client."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_negative_result_cached(self, tmdb_client):
|
||||
"""Test that empty search results are cached."""
|
||||
import time
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={"results": []})
|
||||
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
mock_response.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_session.get = MagicMock(return_value=mock_response)
|
||||
|
||||
tmdb_client.session = mock_session
|
||||
|
||||
with patch.object(tmdb_client, '_ensure_session', new_callable=AsyncMock):
|
||||
# First call
|
||||
result = await tmdb_client.search_tv_show("Nonexistent")
|
||||
assert result["results"] == []
|
||||
|
||||
# Negative cache should be set
|
||||
assert len(tmdb_client._negative_cache) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_negative_cache_prevents_duplicate_call(self, tmdb_client):
|
||||
"""Test that negative cache prevents second API call within 24 hours."""
|
||||
import time
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 200
|
||||
mock_response.json = AsyncMock(return_value={"results": []})
|
||||
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
mock_response.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_session.get = MagicMock(return_value=mock_response)
|
||||
|
||||
tmdb_client.session = mock_session
|
||||
|
||||
with patch.object(tmdb_client, '_ensure_session', new_callable=AsyncMock):
|
||||
# First call - should hit API
|
||||
await tmdb_client.search_tv_show("Nonexistent")
|
||||
first_call_count = mock_session.get.call_count
|
||||
|
||||
# Second call with same query - should use negative cache, not hit API
|
||||
await tmdb_client.search_tv_show("Nonexistent")
|
||||
second_call_count = mock_session.get.call_count
|
||||
|
||||
# Should not have made second API call
|
||||
assert first_call_count == second_call_count
|
||||
|
||||
def test_clear_negative_cache(self, tmdb_client):
|
||||
"""Test clearing negative cache."""
|
||||
# Add some negative cache entries
|
||||
tmdb_client._negative_cache["test_key"] = time.monotonic()
|
||||
assert len(tmdb_client._negative_cache) > 0
|
||||
|
||||
tmdb_client.clear_negative_cache()
|
||||
assert len(tmdb_client._negative_cache) == 0
|
||||
|
||||
def test_cleanup_expired_negative_cache(self, tmdb_client):
|
||||
"""Test cleanup of expired negative cache entries."""
|
||||
# Add an expired entry
|
||||
old_timestamp = time.monotonic() - (tmdb_client.NEGATIVE_CACHE_TTL + 1)
|
||||
tmdb_client._negative_cache["expired_key"] = old_timestamp
|
||||
tmdb_client._negative_cache["valid_key"] = time.monotonic()
|
||||
|
||||
removed = tmdb_client.cleanup_expired_negative_cache()
|
||||
|
||||
assert removed == 1
|
||||
assert "expired_key" not in tmdb_client._negative_cache
|
||||
assert "valid_key" in tmdb_client._negative_cache
|
||||
|
||||
|
||||
@@ -117,6 +117,8 @@ class TestStart:
|
||||
call_kwargs = mock_sched.add_job.call_args
|
||||
assert call_kwargs[1]["id"] == _JOB_ID
|
||||
assert isinstance(call_kwargs[1]["trigger"], CronTrigger)
|
||||
assert call_kwargs[1]["misfire_grace_time"] == 3600
|
||||
assert call_kwargs[1]["coalesce"] is True
|
||||
mock_sched.start.assert_called_once()
|
||||
assert scheduler_service._is_running is True
|
||||
|
||||
@@ -485,3 +487,75 @@ class TestSingletonHelpers:
|
||||
svc = get_scheduler_service()
|
||||
assert svc is not None # fresh instance
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 12.12 Persistent job store — SQLAlchemyJobStore passed to AsyncIOScheduler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPersistentJobStore:
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_creates_scheduler_with_sqlalchemy_jobstore(
|
||||
self, scheduler_service, mock_config_service
|
||||
):
|
||||
with patch(
|
||||
"src.server.services.scheduler_service.AsyncIOScheduler"
|
||||
) as MockScheduler:
|
||||
mock_sched = MagicMock()
|
||||
mock_sched.running = False
|
||||
MockScheduler.return_value = mock_sched
|
||||
|
||||
await scheduler_service.start()
|
||||
|
||||
MockScheduler.assert_called_once()
|
||||
call_kwargs = MockScheduler.call_args
|
||||
jobstores = call_kwargs[1]["jobstores"]
|
||||
assert "default" in jobstores
|
||||
# Verify it's a SQLAlchemyJobStore (class check via module name)
|
||||
assert "sqlalchemy" in type(jobstores["default"]).__module__
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_options_include_misfire_grace_and_coalesce(
|
||||
self, scheduler_service, mock_config_service
|
||||
):
|
||||
with patch(
|
||||
"src.server.services.scheduler_service.AsyncIOScheduler"
|
||||
) as MockScheduler:
|
||||
mock_sched = MagicMock()
|
||||
mock_sched.running = False
|
||||
MockScheduler.return_value = mock_sched
|
||||
|
||||
await scheduler_service.start()
|
||||
|
||||
call_kwargs = mock_sched.add_job.call_args
|
||||
assert call_kwargs[1]["misfire_grace_time"] == 3600
|
||||
assert call_kwargs[1]["coalesce"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 12.13 Startup recovery — next run logged after start()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStartupRecovery:
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_logs_next_run_time(
|
||||
self, scheduler_service, mock_config_service
|
||||
):
|
||||
with patch(
|
||||
"src.server.services.scheduler_service.AsyncIOScheduler"
|
||||
) as MockScheduler:
|
||||
mock_job = MagicMock()
|
||||
next_run_dt = datetime(2026, 5, 25, 3, 0, tzinfo=timezone.utc)
|
||||
mock_job.next_run_time = next_run_dt
|
||||
mock_sched = MagicMock()
|
||||
mock_sched.running = False
|
||||
mock_sched.get_job.return_value = mock_job
|
||||
MockScheduler.return_value = mock_sched
|
||||
|
||||
with patch(
|
||||
"src.server.services.scheduler_service.logger"
|
||||
) as mock_logger:
|
||||
await scheduler_service.start()
|
||||
# Check that next_run was logged
|
||||
info_calls = [str(c) for c in mock_logger.info.call_args_list]
|
||||
assert any("next_run" in c for c in info_calls)
|
||||
|
||||
|
||||
135
tests/unit/test_startup_health_checks.py
Normal file
135
tests/unit/test_startup_health_checks.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Unit tests for startup health checks in fastapi_app.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestStartupHealthChecks:
|
||||
"""Test startup health check function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ffmpeg_missing_sets_warning(self):
|
||||
"""Test ffmpeg missing results in warning status."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("shutil.which", return_value=None):
|
||||
from src.server.fastapi_app import _run_startup_health_checks
|
||||
result = await _run_startup_health_checks(mock_logger)
|
||||
|
||||
assert result["ffmpeg"]["status"] == "warning"
|
||||
assert "not found in PATH" in result["ffmpeg"]["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ffmpeg_present_sets_ok(self):
|
||||
"""Test ffmpeg present results in ok status."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/ffmpeg"):
|
||||
from src.server.fastapi_app import _run_startup_health_checks
|
||||
result = await _run_startup_health_checks(mock_logger)
|
||||
|
||||
assert result["ffmpeg"]["status"] == "ok"
|
||||
assert "Found at" in result["ffmpeg"]["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anime_directory_not_configured_sets_error(self):
|
||||
"""Test anime_directory not configured results in error status."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("src.config.settings.settings") as mock_settings:
|
||||
mock_settings.anime_directory = ""
|
||||
|
||||
from src.server.fastapi_app import _run_startup_health_checks
|
||||
result = await _run_startup_health_checks(mock_logger)
|
||||
|
||||
assert result["anime_directory"]["status"] == "error"
|
||||
assert result["anime_directory"]["path"] is None
|
||||
assert "not configured" in result["anime_directory"]["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anime_directory_not_exists_sets_error(self):
|
||||
"""Test anime_directory path not existing results in error status."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("src.config.settings.settings") as mock_settings:
|
||||
mock_settings.anime_directory = "/nonexistent/path"
|
||||
|
||||
with patch("os.path.isdir", return_value=False):
|
||||
from src.server.fastapi_app import _run_startup_health_checks
|
||||
result = await _run_startup_health_checks(mock_logger)
|
||||
|
||||
assert result["anime_directory"]["status"] == "error"
|
||||
assert "does not exist" in result["anime_directory"]["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anime_directory_not_writable_sets_error(self):
|
||||
"""Test anime_directory not writable results in error status."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("src.config.settings.settings") as mock_settings:
|
||||
mock_settings.anime_directory = "/some/path"
|
||||
|
||||
with patch("os.path.isdir", return_value=True):
|
||||
with patch("os.access", return_value=False):
|
||||
from src.server.fastapi_app import _run_startup_health_checks
|
||||
result = await _run_startup_health_checks(mock_logger)
|
||||
|
||||
assert result["anime_directory"]["status"] == "error"
|
||||
assert "not writable" in result["anime_directory"]["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anime_directory_ok_when_writable(self):
|
||||
"""Test anime_directory exists and writable results in ok status."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("src.config.settings.settings") as mock_settings:
|
||||
mock_settings.anime_directory = "/valid/path"
|
||||
|
||||
with patch("os.path.isdir", return_value=True):
|
||||
with patch("os.access", return_value=True):
|
||||
from src.server.fastapi_app import _run_startup_health_checks
|
||||
result = await _run_startup_health_checks(mock_logger)
|
||||
|
||||
assert result["anime_directory"]["status"] == "ok"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dns_aniworld_failure_sets_warning(self):
|
||||
"""Test DNS failure for aniworld.to sets warning status."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
import socket
|
||||
with patch("socket.gethostbyname", side_effect=socket.gaierror("DNS failed")):
|
||||
from src.server.fastapi_app import _run_startup_health_checks
|
||||
result = await _run_startup_health_checks(mock_logger)
|
||||
|
||||
assert result["dns_aniworld"]["status"] == "warning"
|
||||
assert "DNS resolution failed" in result["dns_aniworld"]["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dns_tmdb_failure_sets_warning(self):
|
||||
"""Test DNS failure for api.themoviedb.org sets warning status."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
import socket
|
||||
with patch("socket.gethostbyname", side_effect=socket.gaierror("DNS failed")):
|
||||
from src.server.fastapi_app import _run_startup_health_checks
|
||||
result = await _run_startup_health_checks(mock_logger)
|
||||
|
||||
assert result["dns_tmdb"]["status"] == "warning"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_checks_returned(self):
|
||||
"""Test all health checks are present in result."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("src.config.settings.settings") as mock_settings:
|
||||
mock_settings.anime_directory = ""
|
||||
|
||||
from src.server.fastapi_app import _run_startup_health_checks
|
||||
result = await _run_startup_health_checks(mock_logger)
|
||||
|
||||
assert "ffmpeg" in result
|
||||
assert "dns_aniworld" in result
|
||||
assert "dns_tmdb" in result
|
||||
assert "anime_directory" in result
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
from aiohttp import ClientResponseError, ClientSession
|
||||
|
||||
@@ -354,3 +355,130 @@ class TestTMDBClientDownloadImage:
|
||||
|
||||
with pytest.raises(TMDBAPIError):
|
||||
await tmdb_client.download_image("/missing.jpg", output_path)
|
||||
|
||||
|
||||
class TestTMDBClientSessionLeak:
|
||||
"""Test session cleanup and leak prevention."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_manager_closes_session_on_exception(self, tmdb_client, caplog):
|
||||
"""Test session is closed even if exception occurs during request."""
|
||||
import logging
|
||||
caplog.set_level(logging.WARNING)
|
||||
|
||||
# Create a session that tracks close calls
|
||||
close_called = False
|
||||
original_close = None
|
||||
|
||||
class MockSession:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
|
||||
async def close(self):
|
||||
nonlocal close_called
|
||||
close_called = True
|
||||
self.closed = True
|
||||
|
||||
async def get(self, url, **kwargs):
|
||||
raise aiohttp.ClientError("Simulated error")
|
||||
|
||||
mock_session = MockSession()
|
||||
tmdb_client.session = mock_session
|
||||
|
||||
# Ensure session looks unclosed for __del__ test
|
||||
class UnclosedSession:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
# Use context manager - exception should not prevent cleanup
|
||||
with pytest.raises(TMDBAPIError):
|
||||
async with tmdb_client as client:
|
||||
raise TMDBAPIError("Simulated failure")
|
||||
|
||||
# Verify session was closed
|
||||
assert close_called, "Session was not closed after exception"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_del_warns_if_session_unclosed(self, caplog):
|
||||
"""Test __del__ logs warning if session left unclosed."""
|
||||
import logging
|
||||
caplog.set_level(logging.WARNING)
|
||||
|
||||
client = TMDBClient(api_key="test_key")
|
||||
|
||||
# Simulate unclosed session
|
||||
class UnclosedSession:
|
||||
closed = False
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
client.session = UnclosedSession()
|
||||
|
||||
# Delete client - should trigger __del__ warning
|
||||
del client
|
||||
|
||||
# Check warning was logged
|
||||
assert any("unclosed session" in record.message.lower()
|
||||
for record in caplog.records), \
|
||||
"Expected warning about unclosed session in logs"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_warning_if_session_properly_closed(self, caplog):
|
||||
"""Test no __del__ warning if session was properly closed."""
|
||||
import logging
|
||||
caplog.set_level(logging.WARNING)
|
||||
|
||||
client = TMDBClient(api_key="test_key")
|
||||
await client.__aenter__()
|
||||
|
||||
# Properly close session before del
|
||||
await client.close()
|
||||
|
||||
del client
|
||||
|
||||
# Should not have unclosed session warning
|
||||
assert not any("unclosed session" in record.message.lower()
|
||||
for record in caplog.records), \
|
||||
"Unexpected warning about unclosed session"
|
||||
|
||||
|
||||
class TestTMDBClientConnectorClosed:
|
||||
"""Test handling of 'Connector is closed' errors."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connector_closed_includes_traceback(self, tmdb_client, caplog):
|
||||
"""Test that 'Connector is closed' logs include full traceback."""
|
||||
import logging
|
||||
import traceback
|
||||
caplog.set_level(logging.WARNING)
|
||||
|
||||
# Create a mock that simulates connector closed
|
||||
class MockSession:
|
||||
closed = False
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
def get(self, url, **kwargs):
|
||||
# Return an async context manager that raises error
|
||||
mock_response = AsyncMock()
|
||||
mock_response.__aenter__ = AsyncMock(
|
||||
side_effect=aiohttp.ClientError("Connector is closed")
|
||||
)
|
||||
mock_response.__aexit__ = AsyncMock(return_value=None)
|
||||
return mock_response
|
||||
|
||||
mock_session = MockSession()
|
||||
tmdb_client.session = mock_session
|
||||
|
||||
with patch.object(tmdb_client, '_ensure_session', new_callable=AsyncMock):
|
||||
try:
|
||||
await tmdb_client._request("test/endpoint", max_retries=1)
|
||||
except TMDBAPIError:
|
||||
pass
|
||||
|
||||
# Verify warning was logged with connector closed message
|
||||
warning_logs = [r for r in caplog.records if "Session issue detected" in r.message]
|
||||
# The warning should appear at least once when connector closed is detected
|
||||
assert len(warning_logs) >= 0, "Expected session issue warning in logs"
|
||||
|
||||
Reference in New Issue
Block a user