Compare commits

...

9 Commits

56 changed files with 2922 additions and 2249 deletions

View File

@@ -18,11 +18,14 @@ Usage:
sudo python3 test_vpn.py
"""
import logging
import subprocess
import time
import unittest
import os
logger = logging.getLogger(__name__)
IMAGE_NAME = "vpn-wireguard-test"
CONTAINER_NAME = "vpn-test-container"
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "wg0.conf")
@@ -63,23 +66,26 @@ class TestVPNImage(unittest.TestCase):
)
# ── 1. Get host public IP before VPN ──
print("\n[setup] Fetching host public IP...")
logger.info("Fetching host public IP...")
cls.host_ip = get_host_ip()
print(f"[setup] Host public IP: {cls.host_ip}")
logger.info("Host public IP: %s", cls.host_ip)
assert cls.host_ip, "Could not determine host public IP"
# ── 2. Build the image ──
print(f"[setup] Building image '{IMAGE_NAME}'...")
logger.info("Building image '%s'...", IMAGE_NAME)
result = run(
["podman", "build", "-t", IMAGE_NAME, BUILD_DIR],
timeout=180,
)
print(result.stdout[-500:] if len(result.stdout) > 500 else result.stdout)
logger.debug(
"Build output: %s",
result.stdout[-500:] if len(result.stdout) > 500 else result.stdout,
)
assert result.returncode == 0, f"Build failed:\n{result.stderr}"
print("[setup] Image built successfully.")
logger.info("Image built successfully.")
# ── 3. Start the container ──
print(f"[setup] Starting container '{CONTAINER_NAME}'...")
logger.info("Starting container '%s'...", CONTAINER_NAME)
result = run(
[
"podman", "run", "-d",
@@ -96,7 +102,7 @@ class TestVPNImage(unittest.TestCase):
)
assert result.returncode == 0, f"Container failed to start:\n{result.stderr}"
cls.container_id = result.stdout.strip()
print(f"[setup] Container started: {cls.container_id[:12]}")
logger.info("Container started: %s", cls.container_id[:12])
# Verify it's running
inspect = run(
@@ -106,17 +112,17 @@ class TestVPNImage(unittest.TestCase):
assert inspect.stdout.strip() == "true", "Container is not running"
# ── 4. Wait for VPN to come up ──
print(f"[setup] Waiting up to {STARTUP_TIMEOUT}s for VPN tunnel...")
logger.info("Waiting up to %d seconds for VPN tunnel...", STARTUP_TIMEOUT)
vpn_up = cls._wait_for_vpn_cls(STARTUP_TIMEOUT)
assert vpn_up, f"VPN did not come up within {STARTUP_TIMEOUT}s"
print("[setup] VPN tunnel is up. Running tests.\n")
logger.info("VPN tunnel is up. Running tests.")
@classmethod
def tearDownClass(cls):
"""Stop and remove the container."""
print("\n[teardown] Cleaning up...")
logger.info("Cleaning up test container...")
subprocess.run(["podman", "rm", "-f", CONTAINER_NAME], capture_output=True, check=False)
print("[teardown] Done.")
logger.info("Cleanup complete.")
@classmethod
def _wait_for_vpn_cls(cls, timeout: int = STARTUP_TIMEOUT) -> bool:
@@ -143,8 +149,8 @@ class TestVPNImage(unittest.TestCase):
def test_01_ip_differs_from_host(self):
"""Public IP inside VPN is different from host IP."""
vpn_ip = self._get_vpn_ip()
print(f"\n[test] VPN public IP: {vpn_ip}")
print(f"[test] Host public IP: {self.host_ip}")
logger.info("VPN public IP: %s", vpn_ip)
logger.info("Host public IP: %s", self.host_ip)
self.assertTrue(vpn_ip, "Could not fetch IP from inside the container")
self.assertNotEqual(
@@ -178,7 +184,7 @@ class TestVPNImage(unittest.TestCase):
result.returncode, 0,
"Traffic went through even with WireGuard down — kill switch is NOT working!",
)
print("\n[test] Kill switch confirmed: traffic blocked with VPN down")
logger.info("Kill switch confirmed: traffic blocked with VPN down")
if __name__ == "__main__":

View File

@@ -203,14 +203,14 @@ List library series that have missing episodes.
| `page` | int | 1 | Page number (must be positive) |
| `per_page` | int | 20 | Items per page (max 1000) |
| `sort_by` | string | null | Sort field: `title`, `id`, `name`, `missing_episodes` |
| `filter` | string | null | Filter: `no_episodes` (shows only series with missing episodes - episodes in DB that haven't been downloaded yet) |
| `filter` | string | null | Filter: `missing_episodes` (shows series with any missing episodes), `no_episodes` (shows series with zero downloaded episodes) |
**Filter Details:**
- `no_episodes`: Returns series that have at least one episode in the database with `is_downloaded=False`
- `missing_episodes`: Returns series that have at least one missing episode recorded in the database (`is_downloaded=False`)
- `no_episodes`: Returns series that have missing episodes and no downloaded episodes (i.e., only missing episodes exist in the database)
- Episodes in the database represent MISSING episodes (from episodeDict during scanning)
- `is_downloaded=False` means the episode file was not found in the folder
- This effectively shows series where no video files were found for missing episodes
**Response (200 OK):**

View File

@@ -217,6 +217,7 @@ Source: [src/server/models/config.py](../src/server/models/config.py#L15-L24)
- `auto_create` creates NFO files during the download process
- `update_on_scan` refreshes metadata when scanning existing anime
- Image downloads require valid `tmdb_api_key`
- `TMDB_API_KEY` environment variable is optional when `nfo.tmdb_api_key` is configured in `data/config.json`
- Larger image sizes (`w780`, `original`) consume more storage space
Source: [src/server/models/config.py](../src/server/models/config.py#L109-L132)

View File

@@ -0,0 +1,94 @@
# Logging Instructions
This document describes how to write and refactor logging across the AniWorld codebase to make logs **human-readable**, **debug-friendly**, and **noise-free**.
> ✅ Goal: Logs should help a developer understand what happened, why it happened, and what to inspect next — without overwhelming them with duplicates or irrelevant details.
---
## 1. Principles for Great Logs
### 1.1 Use the Right Log Level
- `DEBUG`: Detailed internal state useful when debugging a specific issue (e.g., decision points, returned values, request/response payloads). Not for normal operation.
- `INFO`: High-level events that represent what the system is doing (e.g., "Import started", "New series added", "Config reloaded"). Use sparingly.
- `WARNING`: Something unexpected happened, but the system can continue (e.g., missing optional file, fallback behavior).
- `ERROR`: An operation failed and needs attention (e.g., exception caught, failed database write).
- `CRITICAL`: The system is in an unusable state (e.g., config corruption, failed startup).
### 1.2 Keep Logs Human-Readable
- Write messages in a clear, descriptive sentence-style format.
- Avoid cryptic codes or single-word log messages.
- Prefer `logger.debug("... %s", value)`-style formatting over f-strings to avoid unnecessary work when the log level is disabled.
### 1.3 Avoid Log Spam
- Dont log inside hot loops unless you explicitly aggregate and log a summary (e.g., "Processed 124 files, 3 failures").
- Avoid repeated/logging the same event at the same level (e.g., do not log "Retrying" 10 times at INFO; log once at INFO and then use DEBUG for each retry).
- Use rate limiting or debounce patterns for logs that can fire rapidly (e.g., external service health checks).
- Prefer a single higher-level log with context rather than many low-level logs that clutter output.
### 1.4 Log Objects Usefully
- When logging objects, log the minimal useful representation (e.g., ID, name, status) rather than the full object or its memory address.
- If an object has a `.dict()`, `.to_dict()`, or `.as_dict()` helper (common in Pydantic models), log that rather than relying on `repr()`.
- Add a `__repr__` or `__str__` implementation to domain models that returns a helpful, concise string with key identifiers.
- Use structured logging (e.g., `logger.info("Series added", extra={"series_id": series.id, "title": series.title})`) where supported.
- For exceptions, prefer `logger.exception("Failed to ...")` to capture stack traces.
---
## 2. Refactoring Existing Logs
When improving or refactoring existing log statements, aim to make them:
- **Actionable**: A developer reading the log should know what happened and what to check next.
- **Non-redundant**: Remove duplicates and ensure only one log records the same high-level event at a given level.
- **Context-rich**: Include identifiers (e.g., `series_id`, `file_path`, `user_id`) and key state that explains why a decision was made.
- **Level-appropriate**: Downgrade noisy INFO logs to DEBUG, and elevate critical failures to ERROR/CRITICAL.
### 2.1 Refactor Checklist
1. **Locate noisy logs**: Search for repeated messages (e.g., "Start", "Done") and determine whether they should be DEBUG or removed.
2. **Replace ad-hoc prints**: Remove `print()` statements or `print(obj)` and replace with `logger.*` calls.
3. **Use structured context**: If a function logs multiple related messages, include the same context in each (e.g., `extra={"series_id": series.id}`) or use a context manager that attaches it.
4. **Validate object output**: Ensure any logged object produces a useful representation (add methods or translate to dict). If not, log the key fields explicitly.
5. **Batch repetitive events**: If a loop logs per item, consider collecting stats and logging a summary at the end.
## 3. Adding New Logs
When adding logs to new code paths:
- Log **important state transitions** (e.g., "Queue started", "Download completed", "Config reloaded").
- For error paths, include what failed and why (e.g., "Could not load config from X: {exc}").
- Prefer logging at the boundaries of operations, not deep inside utility functions unless it aids debugging.
- Write logs in full sentences, with a clear subject, verb, and object.
---
## 4. Example Patterns
```python
logger.info("Import completed", extra={"series_id": series.id, "count": len(imported)})
logger.debug(
"Fetched feed items",
extra={"feed_url": feed.url, "item_count": len(items)},
)
try:
result = download_episode(episode)
except Exception:
logger.exception("Failed to download episode %s", episode.id)
```
> 💡 When in doubt, favor **fewer, richer logs** over many noisy logs.
---
## 5. Logging Audit Task List
For a guided checklist of files and logging improvements, see **`docs/tasks.md`**. This is where we track which files have been reviewed and which logging items still need attention.
> ✅ After applying the guidelines above, update `docs/tasks.md` to indicate which tasks are complete.

10
docs/bla Normal file
View File

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

View File

@@ -59,6 +59,10 @@ The application now features a comprehensive configuration system that allows us
## Anime Management
- **Anime Library Page**: Display list of anime series with missing episodes
- **Library Filters**:
- "Missing Episodes Only" (shows only series with missing episodes, including series that currently have no downloaded episodes)
- "No Episodes" (shows series that are present in the library but have zero downloaded episodes)
- "Show All Series" (overrides other filters to show every series)
- **Database-Backed Series Storage**: All series metadata and missing episodes stored in SQLite database
- **Automatic Database Synchronization**: Series loaded from database on startup, stays in sync with filesystem
- **Series Selection**: Select individual anime series and add episodes to download queue

View File

@@ -117,4 +117,3 @@ For each task completed:
---
## TODO List:

174
docs/tasks.md Normal file
View File

@@ -0,0 +1,174 @@
# Tasks — NFO Plot Missing Bug
These tasks fix the root causes of `<plot>` being empty in `tvshow.nfo` after adding a series via the web UI.
The bug does **not** appear after a server restart because the repair scan uses a different, correctly isolated code path.
---
## Task 1 — Replace shared NFOService in BackgroundLoaderService with per-task instances
- [x] Completed
### Where
`src/server/services/background_loader_service.py` — method `_load_nfo_and_images` (~line 555)
```python
nfo_path = await self.series_app.nfo_service.create_tvshow_nfo(
serie_name=task.name,
serie_folder=task.folder,
year=task.year,
...
)
```
### Goal
Create a fresh, isolated `NFOService` (with its own `TMDBClient` and `aiohttp` session) for every background loading task, exactly the same way `_repair_one_series` in `initialization_service.py` already does it.
Each task must own its client so that closing the session at the end of one task never kills an in-flight request inside another task.
### How it should look
```python
from src.core.services.nfo_factory import NFOServiceFactory
factory = NFOServiceFactory()
nfo_service = factory.create()
nfo_path = await nfo_service.create_tvshow_nfo(
serie_name=task.name,
serie_folder=task.folder,
year=task.year,
...
)
```
### Possible traps and issues
- `NFOServiceFactory.create()` raises `ValueError` if no TMDB API key is available. Wrap in try/except and fall back gracefully (same behaviour as now when `nfo_service` is `None`).
- The factory reads the API key from `settings` first, then from `config.json`. Do not pass the key explicitly so the fallback chain stays intact.
- Each new `NFOService` opens its own `aiohttp` connector. Make sure to call `await nfo_service.close()` in a `finally` block to avoid connector leaks.
### Docs changes needed
None — this is an internal implementation detail.
### Why this is needed
Up to 5 background workers share one `NFOService`/`TMDBClient` instance. The `async with self.tmdb_client:` context manager inside `create_tvshow_nfo` calls `close()` on `__aexit__`, setting `session = None`. When Worker B exits its context while Worker A is still inside `_enrich_details_with_fallback` trying the `en-US` fallback request, that request throws "Connector is closed". The exception is silently swallowed, both `en-US` and `ja-JP` fallbacks fail, `details["overview"]` stays empty, and `plot` is written as an empty element.
---
## Task 2 — Guard NFOService init in SeriesApp on factory fallback, not just env var
- [x] Completed
### Where
`src/core/SeriesApp.py``__init__` method (~line 175)
```python
self.nfo_service: Optional[NFOService] = None
if settings.tmdb_api_key: # ← checks env var ONLY
factory = get_nfo_factory()
self.nfo_service = factory.create()
```
### Goal
The guard condition should be equivalent to what `NFOServiceFactory.create()` itself checks: whether the key is available from *any* source (env var or `config.json`). Replace the guard with a try/create pattern so that `nfo_service` is initialised whenever the factory would succeed.
### How it should look
```python
self.nfo_service: Optional[NFOService] = None
try:
from src.core.services.nfo_factory import get_nfo_factory
factory = get_nfo_factory()
self.nfo_service = factory.create()
logger.info("NFO service initialized successfully")
except ValueError:
logger.info("NFO service not available — TMDB API key not configured")
except Exception as e:
logger.warning("Failed to initialize NFO service: %s", e)
```
### Possible traps and issues
- This changes the condition from "env var set" to "factory can produce a service". The factory already has a safe fallback and raises `ValueError` when no key exists — so the `except ValueError` path is the normal "not configured" case, not an error.
- `SeriesApp` is used in tests with `settings.tmdb_api_key = None`. Those tests must not be affected; the `except ValueError` branch keeps behaviour identical.
- `series_app.nfo_service` is still `None` when not configured — downstream code that checks `if self.series_app.nfo_service:` remains correct.
### Docs changes needed
`docs/CONFIGURATION.md` — note that `TMDB_API_KEY` env var is not required if `nfo.tmdb_api_key` is set in `config.json`.
### Why this is needed
If the TMDB API key is configured only via `config.json` (not the `TMDB_API_KEY` env var), `settings.tmdb_api_key` is `None` and the guard prevents `nfo_service` from ever being created. The background loader then skips NFO creation completely (`nfo_service` is `None`). The repair scan at startup uses `NFOServiceFactory` directly (reads config.json) so it does create the NFO — which is exactly why restart works but add does not.
---
## Task 3 — Remove non-reentrant `async with self.tmdb_client:` from NFOService public methods
- [x] Completed
### Where
`src/core/services/nfo_service.py``create_tvshow_nfo` (~line 151) and `update_tvshow_nfo` (~line 265)
```python
async with self.tmdb_client:
details = await self.tmdb_client.get_tv_show_details(...)
...
```
### Goal
The `TMDBClient.__aenter__` / `__aexit__` open and **close** the session, making any concurrent call to the same client instance fail. Because Task 1 creates a fresh instance per call, this context manager becomes redundant. Change both methods to use `_ensure_session()` at the start and `close()` in a `finally` block, or simply call `await self.tmdb_client._ensure_session()` once and close after all requests. This makes the lifetime explicit and prevents double-close if the caller already manages it.
### How it should look
```python
async def create_tvshow_nfo(self, ...) -> Path:
try:
await self.tmdb_client._ensure_session()
search_results = await self.tmdb_client.search_tv_show(search_name)
...
finally:
await self.tmdb_client.close()
```
### Possible traps and issues
- `TMDBClient.close()` is idempotent (checks `session.closed` before closing), so calling it in `finally` is safe even if the try block never opened a session.
- After Task 1 every `NFOService` is short-lived (one call), so `finally: close()` effectively replaces the context manager with no behaviour change.
- Do not remove the `__aenter__`/`__aexit__` from `TMDBClient` itself — other callers (e.g. tests, CLI) may still use it as a context manager.
- `update_tvshow_nfo` has the same pattern; fix both methods.
### Docs changes needed
None — internal implementation detail.
### Why this is needed
Even after Task 1 fixes the shared-instance problem, the `async with self.tmdb_client:` pattern is fragile by design: `__aexit__` calls `close()`, which would break any hypothetical future reuse. Removing the implicit close makes the session lifetime explicit and eliminates the root mechanism that caused the original bug.
---
## Task 4 — Add `en-US` search fallback so `search_overview` is never empty
### Where
`src/core/services/nfo_service.py``create_tvshow_nfo` (~line 178) and `_enrich_details_with_fallback` (~line 395)
```python
search_overview = tv_show.get("overview") or None # always None for anime — de-DE search returns ""
```
### Goal
When the German `search_tv_show` result has an empty `overview`, perform a second search in `en-US` to obtain a non-empty overview as the last-resort fallback text. Store this as `search_overview` so `_enrich_details_with_fallback` can use it even if all language-specific detail requests fail.
### How it should look
```python
search_overview = tv_show.get("overview") or None
if not search_overview:
try:
en_results = await self.tmdb_client.search_tv_show(search_name, language="en-US")
en_match = self._find_best_match(en_results.get("results", []), search_name, year)
search_overview = en_match.get("overview") or None
except Exception:
pass # best-effort only
```
### Possible traps and issues
- This adds one extra TMDB request per series when the German overview is empty. It is best-effort and must be wrapped in a broad `except` so it never blocks NFO creation.
- The TMDB search endpoint rate-limit is generous; one extra request per add is negligible.
- `_find_best_match` can raise `TMDBAPIError` if the result list is empty — catch both `TMDBAPIError` and `Exception`.
- `update_tvshow_nfo` calls `_enrich_details_with_fallback` without `search_overview`. This is acceptable because the detail request with `en-US` fallback covers it; the search overview is only a last resort for the create path.
### Docs changes needed
None — transparent improvement.
### Why this is needed
Most anime have no German translation on TMDB. The `de-DE` search result returns `overview: ""`. The current code stores this as `search_overview = None` so the last-resort fallback in `_enrich_details_with_fallback` never fires. Combined with session contention (Task 1), the detail-level `en-US` fallback also fails, leaving `plot` empty. This task ensures that at least the search-level `en-US` overview is available as a safety net.

View File

@@ -5,6 +5,7 @@ and checking NFO metadata files.
"""
import asyncio
import logging
import sys
from pathlib import Path
@@ -14,48 +15,50 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.config.settings import settings
from src.core.services.series_manager_service import SeriesManagerService
logger = logging.getLogger(__name__)
async def scan_and_create_nfo():
"""Scan all series and create missing NFO files."""
print("=" * 70)
print("NFO Auto-Creation Tool")
print("=" * 70)
logger.info("%s", "=" * 70)
logger.info("NFO Auto-Creation Tool")
logger.info("%s", "=" * 70)
if not settings.tmdb_api_key:
print("\n❌ Error: TMDB_API_KEY not configured")
print(" Set TMDB_API_KEY in .env file or environment")
print(" Get API key from: https://www.themoviedb.org/settings/api")
logger.error("TMDB_API_KEY not configured")
logger.error("Set TMDB_API_KEY in .env file or environment")
logger.error("Get API key from: https://www.themoviedb.org/settings/api")
return 1
if not settings.anime_directory:
print("\n❌ Error: ANIME_DIRECTORY not configured")
logger.error("ANIME_DIRECTORY not configured")
return 1
print(f"\nAnime Directory: {settings.anime_directory}")
print(f"Auto-create NFO: {settings.nfo_auto_create}")
print(f"Update on scan: {settings.nfo_update_on_scan}")
print(f"Download poster: {settings.nfo_download_poster}")
print(f"Download logo: {settings.nfo_download_logo}")
print(f"Download fanart: {settings.nfo_download_fanart}")
logger.info("Anime Directory: %s", settings.anime_directory)
logger.info("Auto-create NFO: %s", settings.nfo_auto_create)
logger.info("Update on scan: %s", settings.nfo_update_on_scan)
logger.info("Download poster: %s", settings.nfo_download_poster)
logger.info("Download logo: %s", settings.nfo_download_logo)
logger.info("Download fanart: %s", settings.nfo_download_fanart)
if not settings.nfo_auto_create:
print("\n⚠️ Warning: NFO_AUTO_CREATE is set to False")
print(" Enable it in .env to auto-create NFO files")
print("\n Continuing anyway to demonstrate functionality...")
logger.warning("NFO_AUTO_CREATE is set to False")
logger.warning("Enable it in .env to auto-create NFO files")
logger.info("Continuing anyway to demonstrate functionality...")
# Override for demonstration
settings.nfo_auto_create = True
print("\nInitializing series manager...")
logger.info("Initializing series manager...")
manager = SeriesManagerService.from_settings()
# Get series list first
serie_list = manager.get_serie_list()
all_series = serie_list.get_all()
print(f"Found {len(all_series)} series in directory")
logger.info("Found %d series in directory", len(all_series))
if not all_series:
print("\n⚠️ No series found. Add some anime series first.")
logger.warning("No series found. Add some anime series first.")
return 0
# Show series without NFO
@@ -65,25 +68,25 @@ async def scan_and_create_nfo():
series_without_nfo.append(serie)
if series_without_nfo:
print(f"\nSeries without NFO: {len(series_without_nfo)}")
logger.info("Series without NFO: %d", len(series_without_nfo))
for serie in series_without_nfo[:5]: # Show first 5
print(f" - {serie.name} ({serie.folder})")
logger.debug("Missing NFO: %s (%s)", serie.name, serie.folder)
if len(series_without_nfo) > 5:
print(f" ... and {len(series_without_nfo) - 5} more")
logger.info("... and %d more", len(series_without_nfo) - 5)
else:
print("\nAll series already have NFO files!")
logger.info("All series already have NFO files")
if not settings.nfo_update_on_scan:
print("\nNothing to do. Enable NFO_UPDATE_ON_SCAN to update existing NFOs.")
logger.info("Nothing to do. Enable NFO_UPDATE_ON_SCAN to update existing NFOs.")
return 0
print("\nProcessing NFO files...")
print("(This may take a while depending on the number of series)")
logger.info("Processing NFO files...")
logger.info("This may take a while depending on the number of series")
try:
await manager.scan_and_process_nfo()
print("\nNFO processing complete!")
logger.info("NFO processing complete")
# Show updated stats
serie_list.load_series() # Reload to get updated stats
all_series = serie_list.get_all()
@@ -91,17 +94,17 @@ async def scan_and_create_nfo():
series_with_poster = [s for s in all_series if s.has_poster()]
series_with_logo = [s for s in all_series if s.has_logo()]
series_with_fanart = [s for s in all_series if s.has_fanart()]
print("\nFinal Statistics:")
print(f" Series with NFO: {len(series_with_nfo)}/{len(all_series)}")
print(f" Series with poster: {len(series_with_poster)}/{len(all_series)}")
print(f" Series with logo: {len(series_with_logo)}/{len(all_series)}")
print(f" Series with fanart: {len(series_with_fanart)}/{len(all_series)}")
except Exception as e:
print(f"\n❌ Error: {e}")
import traceback
traceback.print_exc()
logger.info("Final statistics", extra={
"total_series": len(all_series),
"with_nfo": len(series_with_nfo),
"with_poster": len(series_with_poster),
"with_logo": len(series_with_logo),
"with_fanart": len(series_with_fanart),
})
except Exception:
logger.exception("Failed to process NFO files")
return 1
finally:
await manager.close()
@@ -111,78 +114,92 @@ async def scan_and_create_nfo():
async def check_nfo_status():
"""Check NFO status for all series."""
print("=" * 70)
print("NFO Status Check")
print("=" * 70)
logger.info("%s", "=" * 70)
logger.info("NFO Status Check")
logger.info("%s", "=" * 70)
if not settings.anime_directory:
print("\n❌ Error: ANIME_DIRECTORY not configured")
logger.error("ANIME_DIRECTORY not configured")
return 1
print(f"\nAnime Directory: {settings.anime_directory}")
logger.info("Anime Directory: %s", settings.anime_directory)
# Create series list (no NFO service needed for status check)
from src.core.entities.SerieList import SerieList
serie_list = SerieList(settings.anime_directory)
all_series = serie_list.get_all()
if not all_series:
print("\n⚠️ No series found")
logger.warning("No series found")
return 0
print(f"\nTotal series: {len(all_series)}")
logger.info("Total series: %d", len(all_series))
# Categorize series
with_nfo = []
without_nfo = []
for serie in all_series:
if serie.has_nfo():
with_nfo.append(serie)
else:
without_nfo.append(serie)
print(f"\nWith NFO: {len(with_nfo)} ({len(with_nfo) * 100 // len(all_series)}%)")
print(f"Without NFO: {len(without_nfo)} ({len(without_nfo) * 100 // len(all_series)}%)")
logger.info(
"Series NFO coverage",
extra={
"with_nfo": len(with_nfo),
"without_nfo": len(without_nfo),
"total": len(all_series),
},
)
if without_nfo:
print("\nSeries missing NFO:")
logger.info("Series missing NFO: %d", len(without_nfo))
for serie in without_nfo[:10]:
print(f"{serie.name} ({serie.folder})")
logger.debug("Missing NFO: %s (%s)", serie.name, serie.folder)
if len(without_nfo) > 10:
print(f" ... and {len(without_nfo) - 10} more")
logger.info("... and %d more", len(without_nfo) - 10)
# Media file statistics
with_poster = sum(1 for s in all_series if s.has_poster())
with_logo = sum(1 for s in all_series if s.has_logo())
with_fanart = sum(1 for s in all_series if s.has_fanart())
print("\nMedia Files:")
print(f" Posters: {with_poster}/{len(all_series)} ({with_poster * 100 // len(all_series)}%)")
print(f" Logos: {with_logo}/{len(all_series)} ({with_logo * 100 // len(all_series)}%)")
print(f" Fanart: {with_fanart}/{len(all_series)} ({with_fanart * 100 // len(all_series)}%)")
logger.info(
"Media file coverage",
extra={
"posters": with_poster,
"logos": with_logo,
"fanart": with_fanart,
"total": len(all_series),
},
)
return 0
async def update_nfo_files():
"""Update existing NFO files with fresh data from TMDB."""
print("=" * 70)
print("NFO Update Tool")
print("=" * 70)
logger.info("%s", "=" * 70)
logger.info("NFO Update Tool")
logger.info("%s", "=" * 70)
if not settings.tmdb_api_key:
print("\n❌ Error: TMDB_API_KEY not configured")
print(" Set TMDB_API_KEY in .env file or environment")
print(" Get API key from: https://www.themoviedb.org/settings/api")
logger.error("TMDB_API_KEY not configured")
logger.error("Set TMDB_API_KEY in .env file or environment")
logger.error("Get API key from: https://www.themoviedb.org/settings/api")
return 1
if not settings.anime_directory:
print("\n❌ Error: ANIME_DIRECTORY not configured")
logger.error("ANIME_DIRECTORY not configured")
return 1
print(f"\nAnime Directory: {settings.anime_directory}")
print(f"Download media: {settings.nfo_download_poster or settings.nfo_download_logo or settings.nfo_download_fanart}")
logger.info("Anime Directory: %s", settings.anime_directory)
logger.info(
"Download media: %s",
settings.nfo_download_poster or settings.nfo_download_logo or settings.nfo_download_fanart,
)
# Get series with NFO
from src.core.entities.SerieList import SerieList
@@ -191,57 +208,55 @@ async def update_nfo_files():
series_with_nfo = [s for s in all_series if s.has_nfo()]
if not series_with_nfo:
print("\n⚠️ No series with NFO files found")
print(" Run 'scan' command first to create NFO files")
logger.warning("No series with NFO files found")
logger.info("Run 'scan' command first to create NFO files")
return 0
print(f"\nFound {len(series_with_nfo)} series with NFO files")
print("Updating NFO files with fresh data from TMDB...")
print("(This may take a while)")
logger.info("Found %d series with NFO files", len(series_with_nfo))
logger.info("Updating NFO files with fresh data from TMDB...")
logger.info("This may take a while")
# Initialize NFO service using factory
from src.core.services.nfo_factory import create_nfo_service
try:
nfo_service = create_nfo_service()
except ValueError as e:
print(f"\nError: {e}")
logger.error("Error creating NFO service: %s", e)
return 1
success_count = 0
error_count = 0
try:
for i, serie in enumerate(series_with_nfo, 1):
print(f"\n[{i}/{len(series_with_nfo)}] Updating: {serie.name}")
logger.info("[%d/%d] Updating: %s", i, len(series_with_nfo), serie.name)
try:
await nfo_service.update_tvshow_nfo(
serie_folder=serie.folder,
download_media=(
settings.nfo_download_poster or
settings.nfo_download_logo or
settings.nfo_download_poster or
settings.nfo_download_logo or
settings.nfo_download_fanart
)
),
)
print(f"Updated successfully")
logger.info("Updated successfully: %s", serie.name)
success_count += 1
# Small delay to respect API rate limits
await asyncio.sleep(0.5)
except Exception as e:
print(f" ❌ Error: {e}")
logger.exception("Failed to update NFO for %s", serie.name)
error_count += 1
print("\n" + "=" * 70)
print(f"Update complete!")
print(f" Success: {success_count}")
print(f" Errors: {error_count}")
except Exception as e:
print(f"\nFatal error: {e}")
import traceback
traceback.print_exc()
logger.info("%s", "=" * 70)
logger.info("Update complete")
logger.info("Success: %d", success_count)
logger.info("Errors: %d", error_count)
except Exception:
logger.exception("Fatal error during NFO update")
return 1
finally:
await nfo_service.close()
@@ -251,20 +266,22 @@ async def update_nfo_files():
def main():
"""Main CLI entry point."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
if len(sys.argv) < 2:
print("NFO Management Tool")
print("\nUsage:")
print(" python -m src.cli.nfo_cli scan # Scan and create missing NFO files")
print(" python -m src.cli.nfo_cli status # Check NFO status for all series")
print(" python -m src.cli.nfo_cli update # Update existing NFO files with fresh data")
print("\nConfiguration:")
print(" Set TMDB_API_KEY in .env file")
print(" Set NFO_AUTO_CREATE=true to enable auto-creation")
print(" Set NFO_UPDATE_ON_SCAN=true to update existing NFOs during scan")
logger.info("NFO Management Tool")
logger.info("\nUsage:")
logger.info(" python -m src.cli.nfo_cli scan # Scan and create missing NFO files")
logger.info(" python -m src.cli.nfo_cli status # Check NFO status for all series")
logger.info(" python -m src.cli.nfo_cli update # Update existing NFO files with fresh data")
logger.info("\nConfiguration:")
logger.info(" Set TMDB_API_KEY in .env file")
logger.info(" Set NFO_AUTO_CREATE=true to enable auto-creation")
logger.info(" Set NFO_UPDATE_ON_SCAN=true to update existing NFOs during scan")
return 1
command = sys.argv[1].lower()
if command == "scan":
return asyncio.run(scan_and_create_nfo())
elif command == "status":
@@ -272,8 +289,8 @@ def main():
elif command == "update":
return asyncio.run(update_nfo_files())
else:
print(f"Unknown command: {command}")
print("Use 'scan', 'status', or 'update'")
logger.error("Unknown command: %s", command)
logger.info("Use 'scan', 'status', or 'update'")
return 1

View File

@@ -171,23 +171,26 @@ class SeriesApp:
# Initialize empty list - series loaded later via load_series_from_list()
# No need to call _init_list_sync() anymore
# Initialize NFO service if TMDB API key is configured
# Initialize NFO service if a TMDB API key is configured
self.nfo_service: Optional[NFOService] = None
if settings.tmdb_api_key:
try:
from src.core.services.nfo_factory import get_nfo_factory
factory = get_nfo_factory()
self.nfo_service = factory.create()
logger.info("NFO service initialized successfully")
except (ValueError, Exception) as e: # pylint: disable=broad-except
logger.warning(
"Failed to initialize NFO service: %s", str(e)
)
self.nfo_service = None
try:
from src.core.services.nfo_factory import get_nfo_factory
factory = get_nfo_factory()
self.nfo_service = factory.create()
logger.info("NFO service initialized successfully")
except ValueError:
logger.info(
"NFO service not available — TMDB API key not configured"
)
self.nfo_service = None
except Exception as e: # pylint: disable=broad-except
logger.warning("Failed to initialize NFO service: %s", str(e))
self.nfo_service = None
logger.info(
"SeriesApp initialized for directory: %s",
directory_to_search
directory_to_search,
)
@property

View File

@@ -121,11 +121,11 @@ class SerieList:
def load_series(self) -> None:
"""Populate the in-memory map with metadata discovered on disk."""
logging.info("Scanning anime folders in %s", self.directory)
logger.info("Scanning anime folders in %s", self.directory)
try:
entries: Iterable[str] = os.listdir(self.directory)
except OSError as error:
logging.error(
logger.error(
"Unable to scan directory %s: %s",
self.directory,
error,
@@ -145,7 +145,7 @@ class SerieList:
for anime_folder in entries:
anime_path = os.path.join(self.directory, anime_folder, "data")
if os.path.isfile(anime_path):
logging.debug("Found data file for folder %s", anime_folder)
logger.debug("Found data file for folder %s", anime_folder)
serie = self._load_data(anime_folder, anime_path)
if serie:
@@ -159,7 +159,7 @@ class SerieList:
nfo_stats["with_nfo"] += 1
else:
nfo_stats["without_nfo"] += 1
logging.debug(
logger.debug(
"Series '%s' (key: %s) is missing tvshow.nfo",
serie.name,
serie.key
@@ -173,7 +173,7 @@ class SerieList:
media_stats["with_poster"] += 1
else:
media_stats["without_poster"] += 1
logging.debug(
logger.debug(
"Series '%s' (key: %s) is missing poster.jpg",
serie.name,
serie.key
@@ -184,7 +184,7 @@ class SerieList:
media_stats["with_logo"] += 1
else:
media_stats["without_logo"] += 1
logging.debug(
logger.debug(
"Series '%s' (key: %s) is missing logo.png",
serie.name,
serie.key
@@ -195,7 +195,7 @@ class SerieList:
media_stats["with_fanart"] += 1
else:
media_stats["without_fanart"] += 1
logging.debug(
logger.debug(
"Series '%s' (key: %s) is missing fanart.jpg",
serie.name,
serie.key
@@ -203,20 +203,20 @@ class SerieList:
continue
logging.warning(
logger.warning(
"Skipping folder %s because no metadata file was found",
anime_folder,
)
# Log summary statistics
if nfo_stats["total"] > 0:
logging.info(
logger.info(
"NFO scan complete: %d series total, %d with NFO, %d without NFO",
nfo_stats["total"],
nfo_stats["with_nfo"],
nfo_stats["without_nfo"]
)
logging.info(
logger.info(
"Media scan complete: Poster (%d/%d), Logo (%d/%d), Fanart (%d/%d)",
media_stats["with_poster"],
nfo_stats["total"],
@@ -241,14 +241,14 @@ class SerieList:
serie = Serie.load_from_file(data_path)
# Store by key, not folder
self.keyDict[serie.key] = serie
logging.debug(
logger.debug(
"Successfully loaded metadata for %s (key: %s)",
anime_folder,
serie.key
)
return serie
except (OSError, JSONDecodeError, KeyError, ValueError) as error:
logging.error(
logger.error(
"Failed to load metadata for folder %s from %s: %s",
anime_folder,
data_path,

View File

@@ -64,6 +64,16 @@ class Serie:
f"episodeDict={self.episodeDict}{year_str})"
)
def __repr__(self):
"""Concise developer representation of Serie object."""
season_count = len(self.episodeDict)
episode_count = sum(len(eps) for eps in self.episodeDict.values())
year_str = f", year={self.year}" if self.year else ""
return (
f"Serie(key={self.key!r}, name={self.name!r}"
f"{year_str}, seasons={season_count}, episodes={episode_count})"
)
@property
def key(self) -> str:
"""

View File

@@ -55,7 +55,8 @@ class RecoveryStrategies:
if attempt == max_retries - 1:
raise
logger.warning(
f"Network error on attempt {attempt + 1}, retrying..."
"Network error on attempt %d, retrying...",
attempt + 1,
)
continue
@@ -72,7 +73,8 @@ class RecoveryStrategies:
if attempt == max_retries - 1:
raise
logger.warning(
f"Download error on attempt {attempt + 1}, retrying..."
"Download error on attempt %d, retrying...",
attempt + 1,
)
continue
@@ -92,7 +94,7 @@ class FileCorruptionDetector:
# Video files should be at least 1MB
return file_size > 1024 * 1024
except Exception as e:
logger.error(f"Error checking file validity: {e}")
logger.error("Error checking file validity: %s", e)
return False
@@ -123,13 +125,18 @@ def with_error_recovery(
last_error = e
if attempt < max_retries - 1:
logger.warning(
f"Error in {context} (attempt {attempt + 1}/"
f"{max_retries}): {e}, retrying..."
"Error in %s (attempt %d/%d): %s, retrying...",
context,
attempt + 1,
max_retries,
e,
)
else:
logger.error(
f"Error in {context} failed after {max_retries} "
f"attempts: {e}"
"Error in %s failed after %d attempts: %s",
context,
max_retries,
e,
)
if last_error:

View File

@@ -12,6 +12,8 @@ from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
class OperationType(str, Enum):
"""Types of operations that can report progress."""
@@ -313,7 +315,7 @@ class CallbackManager:
callback.on_progress(context)
except Exception as e:
# Log but don't let callback errors break the operation
logging.error(
logger.error(
"Error in progress callback %s: %s",
callback,
e,
@@ -332,7 +334,7 @@ class CallbackManager:
callback.on_error(context)
except Exception as e:
# Log but don't let callback errors break the operation
logging.error(
logger.error(
"Error in error callback %s: %s",
callback,
e,
@@ -351,7 +353,7 @@ class CallbackManager:
callback.on_completion(context)
except Exception as e:
# Log but don't let callback errors break the operation
logging.error(
logger.error(
"Error in completion callback %s: %s",
callback,
e,

File diff suppressed because it is too large Load Diff

View File

@@ -87,7 +87,7 @@ class ProviderConfigManager:
settings: Provider settings to apply.
"""
self._provider_settings[provider_name] = settings
logger.info(f"Updated settings for provider: {provider_name}")
logger.info("Updated settings for provider: %s", provider_name)
def update_provider_settings(
self, provider_name: str, **kwargs
@@ -106,7 +106,7 @@ class ProviderConfigManager:
self._provider_settings[provider_name] = ProviderSettings(
name=provider_name, **kwargs
)
logger.info(f"Created new settings for provider: {provider_name}") # noqa: E501
logger.info("Created new settings for provider: %s", provider_name) # noqa: E501
return True
settings = self._provider_settings[provider_name]
@@ -152,7 +152,7 @@ class ProviderConfigManager:
"""
if provider_name in self._provider_settings:
self._provider_settings[provider_name].enabled = True
logger.info(f"Enabled provider: {provider_name}")
logger.info("Enabled provider: %s", provider_name)
return True
return False
@@ -167,7 +167,7 @@ class ProviderConfigManager:
"""
if provider_name in self._provider_settings:
self._provider_settings[provider_name].enabled = False
logger.info(f"Disabled provider: {provider_name}")
logger.info("Disabled provider: %s", provider_name)
return True
return False
@@ -224,7 +224,7 @@ class ProviderConfigManager:
value: Setting value.
"""
self._global_settings[key] = value
logger.info(f"Updated global setting {key}: {value}")
logger.info("Updated global setting %s: %s", key, value)
def get_all_global_settings(self) -> Dict[str, Any]:
"""Get all global settings.
@@ -307,7 +307,7 @@ class ProviderConfigManager:
with open(config_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
logger.info(f"Saved configuration to {config_path}")
logger.info("Saved configuration to %s", config_path)
return True
except Exception as e:

File diff suppressed because it is too large Load Diff

View File

@@ -207,7 +207,7 @@ class ProviderFailover:
"""
if provider_name not in self._providers:
self._providers.append(provider_name)
logger.info(f"Added provider to failover chain: {provider_name}")
logger.info("Added provider to failover chain: %s", provider_name)
def remove_provider(self, provider_name: str) -> bool:
"""Remove a provider from the failover chain.

View File

@@ -151,7 +151,7 @@ class ProviderHealthMonitor:
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in health check loop: {e}", exc_info=True)
logger.exception("Error in health check loop: %s", e)
await asyncio.sleep(self._health_check_interval)
async def _perform_health_checks(self) -> None:
@@ -314,7 +314,7 @@ class ProviderHealthMonitor:
)
best_provider = available[0][0]
logger.debug(f"Best provider selected: {best_provider}")
logger.debug("Best provider selected: %s", best_provider)
return best_provider
def _get_recent_metrics(
@@ -355,7 +355,7 @@ class ProviderHealthMonitor:
provider_name=provider_name
)
self._request_history[provider_name].clear()
logger.info(f"Reset metrics for provider: {provider_name}")
logger.info("Reset metrics for provider: %s", provider_name)
return True
def get_health_summary(self) -> Dict[str, Any]:

View File

@@ -134,48 +134,76 @@ class NFOService:
clean_name, extracted_year = self._extract_year_from_name(serie_name)
if year is None and extracted_year is not None:
year = extracted_year
logger.info(f"Extracted year {year} from series name")
logger.info("Extracted year %s from series name", year)
# Use clean name for search
search_name = clean_name
logger.info(f"Creating NFO for {search_name} (year: {year})")
logger.info("Creating NFO for %s (year: %s)", search_name, year)
folder_path = self.anime_directory / serie_folder
if not folder_path.exists():
logger.info(f"Creating series folder: {folder_path}")
logger.info("Creating series folder: %s", folder_path)
folder_path.mkdir(parents=True, exist_ok=True)
async with self.tmdb_client:
try:
await self.tmdb_client._ensure_session()
# Search for TV show with clean name (without year)
logger.debug(f"Searching TMDB for: {search_name}")
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)
tv_id = tv_show["id"]
logger.info(f"Found match: {tv_show['name']} (ID: {tv_id})")
logger.info("Found match: %s (ID: %s)", tv_show['name'], tv_id)
# Get detailed information with multi-language image support
details = await self.tmdb_client.get_tv_show_details(
tv_id,
append_to_response="credits,external_ids,images"
)
# Get content ratings for FSK
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tv_id)
# Enrich with fallback languages for empty overview/tagline
# Pass search result overview as last resort fallback
search_overview = tv_show.get("overview") or None
if not search_overview:
try:
logger.debug(
"No overview in German search result, trying en-US search fallback for: %s",
search_name,
)
en_search_results = await self.tmdb_client.search_tv_show(
search_name,
language="en-US",
)
if en_search_results.get("results"):
en_match = self._find_best_match(
en_search_results["results"], search_name, year
)
search_overview = en_match.get("overview") or None
if search_overview:
logger.info(
"Using en-US search overview fallback for %s",
search_name,
)
except (TMDBAPIError, Exception) as exc:
logger.warning(
"Failed en-US search fallback for overview: %s",
exc,
)
details = await self._enrich_details_with_fallback(
details, search_overview=search_overview
)
# Convert TMDB data to TVShowNFO model
nfo_model = tmdb_to_nfo_model(
details,
@@ -183,15 +211,15 @@ class NFOService:
self.tmdb_client.get_image_url,
self.image_size,
)
# Generate XML
nfo_xml = generate_tvshow_nfo(nfo_model)
# Save NFO file
nfo_path = folder_path / "tvshow.nfo"
nfo_path.write_text(nfo_xml, encoding="utf-8")
logger.info(f"Created NFO: {nfo_path}")
logger.info("Created NFO: %s", nfo_path)
# Download media files
await self._download_media_files(
details,
@@ -200,8 +228,10 @@ class NFOService:
download_logo=download_logo,
download_fanart=download_fanart
)
return nfo_path
finally:
await self.tmdb_client.close()
async def update_tvshow_nfo(
self,
@@ -227,7 +257,7 @@ class NFOService:
if not nfo_path.exists():
raise FileNotFoundError(f"NFO file not found: {nfo_path}")
logger.info(f"Updating NFO for {serie_folder}")
logger.info("Updating NFO for %s", serie_folder)
# Parse existing NFO to extract TMDB ID
try:
@@ -253,26 +283,26 @@ class NFOService:
f"Delete the NFO and create a new one instead."
)
logger.debug(f"Found TMDB ID: {tmdb_id}")
logger.debug("Found TMDB ID: %s", tmdb_id)
except etree.XMLSyntaxError as e:
raise TMDBAPIError(f"Invalid XML in NFO file: {e}")
except ValueError as e:
raise TMDBAPIError(f"Invalid TMDB ID format in NFO: {e}")
# Fetch fresh data from TMDB
async with self.tmdb_client:
logger.debug(f"Fetching fresh data for TMDB ID: {tmdb_id}")
try:
await self.tmdb_client._ensure_session()
logger.debug("Fetching fresh data for TMDB ID: %s", tmdb_id)
details = await self.tmdb_client.get_tv_show_details(
tmdb_id,
append_to_response="credits,external_ids,images"
)
# Get content ratings for FSK
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tmdb_id)
# Enrich with fallback languages for empty overview/tagline
details = await self._enrich_details_with_fallback(details)
details = await self._enrich_details_with_fallback(details)
# Convert TMDB data to TVShowNFO model
nfo_model = tmdb_to_nfo_model(
details,
@@ -280,14 +310,14 @@ class NFOService:
self.tmdb_client.get_image_url,
self.image_size,
)
# Generate XML
nfo_xml = generate_tvshow_nfo(nfo_model)
# Save updated NFO file
nfo_path.write_text(nfo_xml, encoding="utf-8")
logger.info(f"Updated NFO: {nfo_path}")
logger.info("Updated NFO: %s", nfo_path)
# Re-download media files if requested
if download_media:
await self._download_media_files(
@@ -297,8 +327,10 @@ class NFOService:
download_logo=True,
download_fanart=True
)
return nfo_path
finally:
await self.tmdb_client.close()
def parse_nfo_ids(self, nfo_path: Path) -> Dict[str, Optional[int]]:
"""Parse TMDB ID and TVDB ID from an existing NFO file.
@@ -318,7 +350,7 @@ class NFOService:
result = {"tmdb_id": None, "tvdb_id": None}
if not nfo_path.exists():
logger.debug(f"NFO file not found: {nfo_path}")
logger.debug("NFO file not found: %s", nfo_path)
return result
try:
@@ -375,9 +407,9 @@ class NFOService:
)
except etree.XMLSyntaxError as e:
logger.error(f"Invalid XML in NFO file {nfo_path}: {e}")
logger.error("Invalid XML in NFO file %s: %s", nfo_path, e)
except Exception as e: # pylint: disable=broad-except
logger.error(f"Error parsing NFO file {nfo_path}: {e}")
logger.error("Error parsing NFO file %s: %s", nfo_path, e)
return result
@@ -480,7 +512,7 @@ class NFOService:
for result in results:
first_air_date = result.get("first_air_date", "")
if first_air_date.startswith(str(year)):
logger.debug(f"Found year match: {result['name']} ({first_air_date})")
logger.debug("Found year match: %s (%s)", result['name'], first_air_date)
return result
# Return first result (usually best match)
@@ -545,7 +577,7 @@ class NFOService:
skip_existing=True
)
logger.info(f"Media download results: {results}")
logger.info("Media download results: %s", results)
return results

View File

@@ -136,7 +136,7 @@ class SeriesManagerService:
# If NFO exists, parse IDs and update database
if nfo_exists:
logger.debug(f"Parsing IDs from existing NFO for '{serie_name}'")
logger.debug("Parsing IDs from existing NFO for '%s'", serie_name)
ids = self.nfo_service.parse_nfo_ids(nfo_path)
if ids["tmdb_id"] or ids["tvdb_id"]:
@@ -203,14 +203,14 @@ class SeriesManagerService:
download_logo=self.download_logo,
download_fanart=self.download_fanart
)
logger.info(f"Successfully created NFO for '{serie_name}'")
logger.info("Successfully created NFO for '%s'", serie_name)
elif nfo_exists:
logger.debug(
f"NFO exists for '{serie_name}', skipping download"
)
except TMDBAPIError as e:
logger.error(f"TMDB API error processing '{serie_name}': {e}")
logger.error("TMDB API error processing '%s': %s", serie_name, e)
except Exception as e:
logger.error(
f"Unexpected error processing NFO for '{serie_name}': {e}",
@@ -246,7 +246,7 @@ class SeriesManagerService:
logger.info("No series found in database to process")
return
logger.info(f"Processing NFO for {len(anime_series_list)} series...")
logger.info("Processing NFO for %s series...", len(anime_series_list))
# Create tasks for concurrent processing
# Each task creates its own database session

View File

@@ -107,7 +107,7 @@ class TMDBClient:
# Cache key for deduplication
cache_key = f"{endpoint}:{str(sorted(params.items()))}"
if cache_key in self._cache:
logger.debug(f"Cache hit for {endpoint}")
logger.debug("Cache hit for %s", endpoint)
return self._cache[cache_key]
delay = 1
@@ -121,7 +121,7 @@ class TMDBClient:
if self.session is None:
raise TMDBAPIError("Session is not available")
logger.debug(f"TMDB API request: {endpoint} (attempt {attempt + 1})")
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")
@@ -130,7 +130,7 @@ class TMDBClient:
elif resp.status == 429:
# Rate limit - wait longer
retry_after = int(resp.headers.get('Retry-After', delay * 2))
logger.warning(f"Rate limited, waiting {retry_after}s")
logger.warning("Rate limited, waiting %ss", retry_after)
await asyncio.sleep(retry_after)
continue
@@ -142,26 +142,26 @@ class TMDBClient:
except asyncio.TimeoutError as e:
last_error = e
if attempt < max_retries - 1:
logger.warning(f"Request timeout (attempt {attempt + 1}), retrying in {delay}s")
logger.warning("Request timeout (attempt %s), retrying in %ss", attempt + 1, delay)
await asyncio.sleep(delay)
delay *= 2
else:
logger.error(f"Request timed out after {max_retries} attempts")
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(f"Session issue detected, recreating session: {e}")
logger.warning("Session issue detected, recreating session: %s", e)
self.session = None
await self._ensure_session()
if attempt < max_retries - 1:
logger.warning(f"Request failed (attempt {attempt + 1}): {e}, retrying in {delay}s")
logger.warning("Request failed (attempt %s): %s, retrying in %ss", attempt + 1, e, delay)
await asyncio.sleep(delay)
delay *= 2
else:
logger.error(f"Request failed after {max_retries} attempts: {e}")
logger.error("Request failed after %s attempts: %s", max_retries, e)
raise TMDBAPIError(f"Request failed after {max_retries} attempts: {last_error}")
@@ -275,7 +275,7 @@ class TMDBClient:
url = f"{self.image_base_url}/{size}{image_path}"
try:
logger.debug(f"Downloading image from {url}")
logger.debug("Downloading image from %s", url)
async with self.session.get(url, timeout=aiohttp.ClientTimeout(total=60)) as resp:
resp.raise_for_status()
@@ -286,7 +286,7 @@ class TMDBClient:
with open(local_path, "wb") as f:
f.write(await resp.read())
logger.info(f"Downloaded image to {local_path}")
logger.info("Downloaded image to %s", local_path)
except aiohttp.ClientError as e:
raise TMDBAPIError(f"Failed to download image: {e}")

View File

@@ -125,7 +125,7 @@ class ImageDownloader:
# Check if file already exists
if skip_existing and local_path.exists():
if local_path.stat().st_size >= self.min_file_size:
logger.debug(f"Image already exists: {local_path}")
logger.debug("Image already exists: %s", local_path)
return True
# Ensure parent directory exists
@@ -137,15 +137,16 @@ class ImageDownloader:
for attempt in range(self.max_retries):
try:
logger.debug(
f"Downloading image from {url} "
f"(attempt {attempt + 1})"
"Downloading image from %s (attempt %d)",
url,
attempt + 1,
)
# Use persistent session
session = self._get_session()
async with session.get(url) as resp:
if resp.status == 404:
logger.warning(f"Image not found: {url}")
logger.warning("Image not found: %s", url)
return False
resp.raise_for_status()
@@ -168,21 +169,25 @@ class ImageDownloader:
local_path.unlink(missing_ok=True)
raise ImageDownloadError("Image validation failed")
logger.info(f"Downloaded image to {local_path}")
logger.info("Downloaded image to %s", local_path)
return True
except (aiohttp.ClientError, IOError, ImageDownloadError) as e:
last_error = e
if attempt < self.max_retries - 1:
logger.warning(
f"Download failed (attempt {attempt + 1}): {e}, "
f"retrying in {delay}s"
"Download failed (attempt %d): %s, retrying in %s",
attempt + 1,
e,
delay,
)
await asyncio.sleep(delay)
delay *= 2
else:
logger.error(
f"Download failed after {self.max_retries} attempts: {e}"
"Download failed after %d attempts: %s",
self.max_retries,
e,
)
raise ImageDownloadError(
@@ -211,7 +216,7 @@ class ImageDownloader:
try:
return await self.download_image(url, local_path, skip_existing)
except ImageDownloadError as e:
logger.warning(f"Failed to download poster: {e}")
logger.warning("Failed to download poster: %s", e)
return False
async def download_logo(
@@ -236,7 +241,7 @@ class ImageDownloader:
try:
return await self.download_image(url, local_path, skip_existing)
except ImageDownloadError as e:
logger.warning(f"Failed to download logo: {e}")
logger.warning("Failed to download logo: %s", e)
return False
async def download_fanart(
@@ -261,7 +266,7 @@ class ImageDownloader:
try:
return await self.download_image(url, local_path, skip_existing)
except ImageDownloadError as e:
logger.warning(f"Failed to download fanart: {e}")
logger.warning("Failed to download fanart: %s", e)
return False
def validate_image(self, image_path: Path) -> bool:
@@ -280,13 +285,13 @@ class ImageDownloader:
# Check file size
if image_path.stat().st_size < self.min_file_size:
logger.warning(f"Image file too small: {image_path}")
logger.warning("Image file too small: %s", image_path)
return False
return True
except Exception as e:
logger.warning(f"Image validation failed for {image_path}: {e}")
logger.warning("Image validation failed for %s: %s", image_path, e)
return False
async def download_all_media(
@@ -341,7 +346,7 @@ class ImageDownloader:
for (media_type, _), result in zip(tasks, task_results):
if isinstance(result, Exception):
logger.error(f"Error downloading {media_type}: {result}")
logger.error("Error downloading %s: %s", media_type, result)
results[media_type] = False
else:
results[media_type] = result

View File

@@ -209,5 +209,5 @@ def validate_nfo_xml(xml_string: str) -> bool:
etree.fromstring(xml_string.encode('utf-8'))
return True
except etree.XMLSyntaxError as e:
logger.error(f"Invalid NFO XML: {e}")
logger.error("Invalid NFO XML: %s", e)
return False

View File

@@ -36,10 +36,10 @@ class ConfigEncryption:
def _ensure_key_exists(self) -> None:
"""Ensure encryption key exists or create one."""
if not self.key_file.exists():
logger.info(f"Creating new encryption key at {self.key_file}")
logger.info("Creating new encryption key at %s", self.key_file)
self._generate_new_key()
else:
logger.info(f"Using existing encryption key from {self.key_file}")
logger.info("Using existing encryption key from %s", self.key_file)
def _generate_new_key(self) -> None:
"""Generate and store a new encryption key."""
@@ -56,7 +56,7 @@ class ConfigEncryption:
logger.info("Generated new encryption key")
except IOError as e:
logger.error(f"Failed to generate encryption key: {e}")
logger.error("Failed to generate encryption key: %s", e)
raise
def _load_key(self) -> bytes:
@@ -77,7 +77,7 @@ class ConfigEncryption:
key = self.key_file.read_bytes()
return key
except IOError as e:
logger.error(f"Failed to load encryption key: {e}")
logger.error("Failed to load encryption key: %s", e)
raise
def _get_cipher(self) -> Fernet:
@@ -117,7 +117,7 @@ class ConfigEncryption:
return encrypted_str
except Exception as e:
logger.error(f"Failed to encrypt value: {e}")
logger.error("Failed to encrypt value: %s", e)
raise
def decrypt_value(self, encrypted_value: str) -> str:
@@ -149,7 +149,7 @@ class ConfigEncryption:
return decrypted_str
except Exception as e:
logger.error(f"Failed to decrypt value: {e}")
logger.error("Failed to decrypt value: %s", e)
raise
def encrypt_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
@@ -191,9 +191,9 @@ class ConfigEncryption:
'encrypted': True,
'value': self.encrypt_value(value)
}
logger.debug(f"Encrypted config field: {key}")
logger.debug("Encrypted config field: %s", key)
except Exception as e:
logger.warning(f"Failed to encrypt {key}: {e}")
logger.warning("Failed to encrypt %s: %s", key, e)
encrypted_config[key] = value
else:
encrypted_config[key] = value
@@ -222,9 +222,9 @@ class ConfigEncryption:
decrypted_config[key] = self.decrypt_value(
value['value']
)
logger.debug(f"Decrypted config field: {key}")
logger.debug("Decrypted config field: %s", key)
except Exception as e:
logger.error(f"Failed to decrypt {key}: {e}")
logger.error("Failed to decrypt %s: %s", key, e)
decrypted_config[key] = None
else:
decrypted_config[key] = value
@@ -248,7 +248,7 @@ class ConfigEncryption:
if self.key_file.exists():
backup_path = self.key_file.with_suffix('.key.bak')
self.key_file.rename(backup_path)
logger.info(f"Backed up old key to {backup_path}")
logger.info("Backed up old key to %s", backup_path)
# Generate new key
if new_key_file:

View File

@@ -276,13 +276,13 @@ class DatabaseIntegrityChecker:
removed += 1
self.session.commit()
logger.info(f"Removed {removed} orphaned records")
logger.info("Removed %s orphaned records", removed)
return removed
except Exception as e:
self.session.rollback()
logger.error(f"Error removing orphaned records: {e}")
logger.error("Error removing orphaned records: %s", e)
raise

View File

@@ -39,13 +39,15 @@ class FileIntegrityManager:
self.checksums = json.load(f)
count = len(self.checksums)
logger.info(
f"Loaded {count} checksums from {self.checksum_file}"
"Loaded %d checksums from %s",
count,
self.checksum_file,
)
except (json.JSONDecodeError, IOError) as e:
logger.error(f"Failed to load checksums: {e}")
logger.error("Failed to load checksums: %s", e)
self.checksums = {}
else:
logger.info(f"Checksum file does not exist: {self.checksum_file}")
logger.info("Checksum file does not exist: %s", self.checksum_file)
self.checksums = {}
def _save_checksums(self) -> None:
@@ -56,10 +58,12 @@ class FileIntegrityManager:
json.dump(self.checksums, f, indent=2)
count = len(self.checksums)
logger.debug(
f"Saved {count} checksums to {self.checksum_file}"
"Saved %d checksums to %s",
count,
self.checksum_file,
)
except IOError as e:
logger.error(f"Failed to save checksums: {e}")
logger.error("Failed to save checksums: %s", e)
def calculate_checksum(
self, file_path: Path, algorithm: str = "sha256"
@@ -94,12 +98,15 @@ class FileIntegrityManager:
checksum = hash_obj.hexdigest()
filename = file_path.name
logger.debug(
f"Calculated {algorithm} checksum for {filename}: {checksum}"
"Calculated %s checksum for %s: %s",
algorithm,
filename,
checksum,
)
return checksum
except IOError as e:
logger.error(f"Failed to read file {file_path}: {e}")
logger.error("Failed to read file %s: %s", file_path, e)
raise
def store_checksum(
@@ -126,7 +133,7 @@ class FileIntegrityManager:
self.checksums[key] = checksum
self._save_checksums()
logger.info(f"Stored checksum for {file_path.name}")
logger.info("Stored checksum for %s", file_path.name)
return checksum
def verify_checksum(
@@ -197,10 +204,10 @@ class FileIntegrityManager:
if key in self.checksums:
del self.checksums[key]
self._save_checksums()
logger.info(f"Removed checksum for {file_path.name}")
logger.info("Removed checksum for %s", file_path.name)
return True
else:
logger.debug(f"No checksum found to remove for {file_path.name}")
logger.debug("No checksum found to remove for %s", file_path.name)
return False
def has_checksum(self, file_path: Path) -> bool:

View File

@@ -236,8 +236,8 @@ async def list_anime(
sort_by: Optional sorting parameter. Allowed: title, id, name,
missing_episodes
filter: Optional filter parameter. Allowed values:
- "no_episodes": Show only series with no downloaded
episodes in folder
- "missing_episodes": Show only series that have any missing episodes
- "no_episodes": Show only series that have no downloaded episodes
_auth: Ensures the caller is authenticated (value unused)
anime_service: AnimeService instance provided via dependency
@@ -298,7 +298,7 @@ async def list_anime(
# Validate filter parameter
if filter:
try:
allowed_filters = ["no_episodes"]
allowed_filters = ["missing_episodes", "no_episodes"]
validate_filter_value(filter, allowed_filters)
except ValueError as e:
raise ValidationError(message=str(e))
@@ -724,9 +724,9 @@ async def add_series(
if series_app and hasattr(series_app, 'loader'):
try:
year = series_app.loader.get_year(key)
logger.info(f"Fetched year for {key}: {year}")
logger.info("Fetched year for %s: %s", key, year)
except Exception as e:
logger.warning(f"Could not fetch year for {key}: {e}")
logger.warning("Could not fetch year for %s: %s", key, e)
# Create folder name with year if available
if year:

View File

@@ -91,7 +91,7 @@ async def check_database_health(db: AsyncSession) -> DatabaseHealth:
message="Database connection successful",
)
except Exception as e:
logger.error(f"Database health check failed: {e}")
logger.error("Database health check failed: %s", e)
return DatabaseHealth(
status="unhealthy",
connection_time_ms=0,
@@ -121,7 +121,7 @@ async def check_filesystem_health() -> Dict[str, Any]:
"message": "Filesystem check completed",
}
except Exception as e:
logger.error(f"Filesystem health check failed: {e}")
logger.error("Filesystem health check failed: %s", e)
return {
"status": "unhealthy",
"message": f"Filesystem check failed: {str(e)}",
@@ -164,7 +164,7 @@ def get_system_metrics() -> SystemMetrics:
uptime_seconds=uptime_seconds,
)
except Exception as e:
logger.error(f"System metrics collection failed: {e}")
logger.error("System metrics collection failed: %s", e)
raise HTTPException(
status_code=500, detail=f"Failed to collect system metrics: {str(e)}"
)
@@ -236,7 +236,7 @@ async def detailed_health_check(
startup_time=startup_time,
)
except Exception as e:
logger.error(f"Detailed health check failed: {e}")
logger.error("Detailed health check failed: %s", e)
raise HTTPException(status_code=500, detail="Health check failed")

View File

@@ -243,7 +243,7 @@ async def get_missing_nfo(
)
except Exception as e:
logger.error(f"Error getting missing NFOs: {e}", exc_info=True)
logger.exception("Error getting missing NFOs: %s", e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get missing NFOs: {str(e)}"
@@ -334,7 +334,7 @@ async def check_nfo(
except HTTPException:
raise
except Exception as e:
logger.error(f"Error checking NFO for {serie_id}: {e}", exc_info=True)
logger.exception("Error checking NFO for %s: %s", serie_id, e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to check NFO: {str(e)}"
@@ -429,7 +429,7 @@ async def create_nfo(
except HTTPException:
raise
except TMDBAPIError as e:
logger.warning(f"TMDB API error creating NFO for {serie_id}: {e}")
logger.warning("TMDB API error creating NFO for %s: %s", serie_id, e)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"TMDB API error: {str(e)}"
@@ -524,7 +524,7 @@ async def update_nfo(
except HTTPException:
raise
except TMDBAPIError as e:
logger.warning(f"TMDB API error updating NFO for {serie_id}: {e}")
logger.warning("TMDB API error updating NFO for %s: %s", serie_id, e)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"TMDB API error: {str(e)}"

View File

@@ -95,10 +95,10 @@ def setup_logging() -> Dict[str, logging.Logger]:
# Log initial setup
root_logger.info("=" * 80)
root_logger.info("FastAPI Server Logging Initialized")
root_logger.info(f"Log Level: {settings.log_level.upper()}")
root_logger.info(f"Server Log: {server_log_file.absolute()}")
root_logger.info(f"Error Log: {error_log_file.absolute()}")
root_logger.info(f"Access Log: {access_log_file.absolute()}")
root_logger.info("Log Level: %s", settings.log_level.upper())
root_logger.info("Server Log: %s", server_log_file.absolute())
root_logger.info("Error Log: %s", error_log_file.absolute())
root_logger.info("Access Log: %s", access_log_file.absolute())
root_logger.info("=" * 80)
return {

View File

@@ -88,7 +88,7 @@ async def init_db() -> None:
try:
# Get database URL
db_url = _get_database_url()
logger.info(f"Initializing database: {db_url}")
logger.info("Initializing database: %s", db_url)
# Build engine kwargs based on database type
is_sqlite = "sqlite" in db_url
@@ -143,7 +143,7 @@ async def init_db() -> None:
logger.info("Database initialization complete")
except Exception as e:
logger.error(f"Failed to initialize database: {e}")
logger.error("Failed to initialize database: %s", e)
raise
@@ -171,7 +171,7 @@ async def close_db() -> None:
conn.commit()
logger.info("SQLite WAL checkpoint completed")
except Exception as e:
logger.warning(f"WAL checkpoint failed (non-critical): {e}")
logger.warning("WAL checkpoint failed (non-critical): %s", e)
if _engine:
logger.info("Closing async database engine...")
@@ -188,7 +188,7 @@ async def close_db() -> None:
logger.info("Database connections closed")
except Exception as e:
logger.error(f"Error closing database: {e}")
logger.error("Error closing database: %s", e)
def get_engine() -> AsyncEngine:

View File

@@ -98,7 +98,7 @@ async def initialize_database(
seed_data=True
)
if result["success"]:
logger.info(f"Database initialized: {result['schema_version']}")
logger.info("Database initialized: %s", result['schema_version'])
"""
if engine is None:
engine = get_engine()
@@ -117,7 +117,7 @@ async def initialize_database(
if create_schema:
tables = await create_database_schema(engine)
result["tables_created"] = tables
logger.info(f"Created {len(tables)} tables")
logger.info("Created %s tables", len(tables))
# Validate schema if requested
if validate_schema:
@@ -148,7 +148,7 @@ async def initialize_database(
return result
except Exception as e:
logger.error(f"Database initialization failed: {e}", exc_info=True)
logger.exception("Database initialization failed: %s", e)
raise RuntimeError(f"Failed to initialize database: {e}") from e
@@ -194,14 +194,14 @@ async def create_database_schema(
created_tables = [t for t in new_tables if t not in existing_tables]
if created_tables:
logger.info(f"Created tables: {', '.join(created_tables)}")
logger.info("Created tables: %s", ', '.join(created_tables))
else:
logger.info("All tables already exist")
return new_tables
except Exception as e:
logger.error(f"Failed to create schema: {e}", exc_info=True)
logger.exception("Failed to create schema: %s", e)
raise RuntimeError(f"Schema creation failed: {e}") from e
@@ -295,7 +295,7 @@ async def validate_database_schema(
return result
except Exception as e:
logger.error(f"Schema validation failed: {e}", exc_info=True)
logger.exception("Schema validation failed: %s", e)
return {
"valid": False,
"missing_tables": [],
@@ -342,7 +342,7 @@ async def get_schema_version(engine: Optional[AsyncEngine] = None) -> str:
return "unknown"
except Exception as e:
logger.error(f"Failed to get schema version: {e}")
logger.error("Failed to get schema version: %s", e)
return "error"
@@ -409,7 +409,7 @@ async def seed_initial_data(engine: Optional[AsyncEngine] = None) -> None:
logger.info("Data will be populated via normal application usage")
except Exception as e:
logger.error(f"Failed to seed initial data: {e}", exc_info=True)
logger.exception("Failed to seed initial data: %s", e)
raise
@@ -484,12 +484,12 @@ async def check_database_health(
f"(connectivity: {result['connectivity_ms']}ms)"
)
else:
logger.warning(f"Database health issues: {result['issues']}")
logger.warning("Database health issues: %s", result['issues'])
return result
except Exception as e:
logger.error(f"Database health check failed: {e}")
logger.error("Database health check failed: %s", e)
return {
"healthy": False,
"accessible": False,
@@ -547,13 +547,13 @@ async def create_database_backup(
backup_path = backup_dir / f"aniworld_{timestamp}.db"
try:
logger.info(f"Creating database backup: {backup_path}")
logger.info("Creating database backup: %s", backup_path)
shutil.copy2(db_path, backup_path)
logger.info(f"Backup created successfully: {backup_path}")
logger.info("Backup created successfully: %s", backup_path)
return backup_path
except Exception as e:
logger.error(f"Failed to create backup: {e}", exc_info=True)
logger.exception("Failed to create backup: %s", e)
raise RuntimeError(f"Backup creation failed: {e}") from e

View File

@@ -107,7 +107,7 @@ class AnimeSeriesService:
db.add(series)
await db.flush()
await db.refresh(series)
logger.info(f"Created anime series: {series.name} (key={series.key}, year={year})")
logger.info("Created anime series: %s (key=%s, year=%s)", series.name, series.key, year)
return series
@staticmethod
@@ -205,7 +205,7 @@ class AnimeSeriesService:
await db.flush()
await db.refresh(series)
logger.info(f"Updated anime series: {series.name} (id={series_id})")
logger.info("Updated anime series: %s (id=%s)", series.name, series_id)
return series
@staticmethod
@@ -226,7 +226,7 @@ class AnimeSeriesService:
)
deleted = result.rowcount > 0
if deleted:
logger.info(f"Deleted anime series with id={series_id}")
logger.info("Deleted anime series with id=%s", series_id)
return deleted
@staticmethod
@@ -253,48 +253,92 @@ class AnimeSeriesService:
return list(result.scalars().all())
@staticmethod
async def get_series_with_no_episodes(
async def get_series_with_missing_episodes(
db: AsyncSession,
limit: Optional[int] = None,
offset: int = 0,
) -> List[AnimeSeries]:
"""Get anime series that have no episodes found in folder.
Since episodes in the database represent MISSING episodes
(from episodeDict), this returns series that have episodes
in the DB with is_downloaded=False, meaning they have missing
episodes and no files were found in the folder for those episodes.
Returns series where:
- At least one episode exists in database with is_downloaded=False
"""Get anime series that currently have missing episodes.
Episodes in the database represent missing episodes (from episodeDict).
This returns series that have at least one missing episode recorded in
the database (is_downloaded=False).
Args:
db: Database session
limit: Optional limit for results
offset: Offset for pagination
Returns:
List of AnimeSeries with missing episodes (not in folder)
List of AnimeSeries that have missing episodes.
"""
# Subquery to find series IDs with at least one undownloaded episode
undownloaded_series_ids = (
# Subquery to find series IDs with at least one missing episode
missing_series_ids = (
select(Episode.series_id)
.where(Episode.is_downloaded == False)
.distinct()
.subquery()
)
# Select series that have undownloaded episodes
query = (
select(AnimeSeries)
.where(AnimeSeries.id.in_(select(undownloaded_series_ids.c.series_id)))
.where(AnimeSeries.id.in_(select(missing_series_ids.c.series_id)))
.order_by(AnimeSeries.name)
.offset(offset)
)
if limit:
query = query.limit(limit)
result = await db.execute(query)
return list(result.scalars().all())
@staticmethod
async def get_series_with_no_episodes(
db: AsyncSession,
limit: Optional[int] = None,
offset: int = 0,
) -> List[AnimeSeries]:
"""Get anime series that have no downloaded episodes.
A series has "no episodes" if it has at least one missing episode
(is_downloaded=False) and no downloaded episodes (is_downloaded=True).
Args:
db: Database session
limit: Optional limit for results
offset: Offset for pagination
Returns:
List of AnimeSeries where no episodes are downloaded.
"""
# Series with missing episodes
missing_series_ids = (
select(Episode.series_id)
.where(Episode.is_downloaded == False)
.distinct()
.subquery()
)
# Series with any downloaded episodes
downloaded_series_ids = (
select(Episode.series_id)
.where(Episode.is_downloaded == True)
.distinct()
.subquery()
)
query = (
select(AnimeSeries)
.where(AnimeSeries.id.in_(select(missing_series_ids.c.series_id)))
.where(~AnimeSeries.id.in_(select(downloaded_series_ids.c.series_id)))
.order_by(AnimeSeries.name)
.offset(offset)
)
if limit:
query = query.limit(limit)
result = await db.execute(query)
return list(result.scalars().all())
@@ -657,7 +701,7 @@ class EpisodeService:
updated_count += 1
await db.flush()
logger.info(f"Bulk marked {updated_count} episodes as downloaded")
logger.info("Bulk marked %s episodes as downloaded", updated_count)
return updated_count
@@ -806,7 +850,7 @@ class DownloadQueueService:
await db.flush()
await db.refresh(item)
logger.debug(f"Set error on download queue item {item_id}")
logger.debug("Set error on download queue item %s", item_id)
return item
@staticmethod
@@ -825,7 +869,7 @@ class DownloadQueueService:
)
deleted = result.rowcount > 0
if deleted:
logger.info(f"Deleted download queue item with id={item_id}")
logger.info("Deleted download queue item with id=%s", item_id)
return deleted
@staticmethod
@@ -887,7 +931,7 @@ class DownloadQueueService:
)
count = result.rowcount
logger.info(f"Bulk deleted {count} download queue items")
logger.info("Bulk deleted %s download queue items", count)
return count
@@ -908,7 +952,7 @@ class DownloadQueueService:
"""
result = await db.execute(delete(DownloadQueueItem))
count = result.rowcount
logger.info(f"Cleared all {count} download queue items")
logger.info("Cleared all %s download queue items", count)
return count
@@ -962,7 +1006,7 @@ class UserSessionService:
db.add(session)
await db.flush()
await db.refresh(session)
logger.info(f"Created user session: {session_id}")
logger.info("Created user session: %s", session_id)
return session
@staticmethod
@@ -1049,7 +1093,7 @@ class UserSessionService:
session.revoke()
await db.flush()
logger.info(f"Revoked user session: {session_id}")
logger.info("Revoked user session: %s", session_id)
return True
@staticmethod
@@ -1071,7 +1115,7 @@ class UserSessionService:
)
)
count = result.rowcount
logger.info(f"Cleaned up {count} expired sessions")
logger.info("Cleaned up %s expired sessions", count)
return count
@staticmethod

View File

@@ -6,6 +6,7 @@ configuration, middleware setup, static file serving, and Jinja2 template
integration.
"""
import asyncio
import logging
from contextlib import asynccontextmanager
from pathlib import Path
@@ -51,7 +52,7 @@ async def _check_incomplete_series_on_startup(background_loader) -> None:
Args:
background_loader: BackgroundLoaderService instance
"""
logger = setup_logging(log_level="INFO")
logger = logging.getLogger("aniworld")
try:
from src.server.database.connection import get_db_session
@@ -96,11 +97,11 @@ async def _check_incomplete_series_on_startup(background_loader) -> None:
else:
logger.info("All series data is complete. No background loading needed.")
except Exception as e:
logger.error(f"Error checking incomplete series: {e}", exc_info=True)
except Exception:
logger.exception("Error checking incomplete series")
except Exception as e:
logger.error(f"Failed to check incomplete series on startup: {e}", exc_info=True)
except Exception:
logger.exception("Failed to check incomplete series on startup")
@asynccontextmanager

View File

@@ -74,7 +74,8 @@ def register_exception_handlers(app: FastAPI) -> None:
) -> JSONResponse:
"""Handle authentication errors (401)."""
logger.warning(
f"Authentication error: {exc.message}",
"Authentication error: %s",
exc.message,
extra={"details": exc.details, "path": str(request.url.path)},
)
return JSONResponse(
@@ -94,7 +95,8 @@ def register_exception_handlers(app: FastAPI) -> None:
) -> JSONResponse:
"""Handle authorization errors (403)."""
logger.warning(
f"Authorization error: {exc.message}",
"Authorization error: %s",
exc.message,
extra={"details": exc.details, "path": str(request.url.path)},
)
return JSONResponse(
@@ -114,7 +116,8 @@ def register_exception_handlers(app: FastAPI) -> None:
) -> JSONResponse:
"""Handle validation errors (422)."""
logger.info(
f"Validation error: {exc.message}",
"Validation error: %s",
exc.message,
extra={"details": exc.details, "path": str(request.url.path)},
)
return JSONResponse(
@@ -134,7 +137,8 @@ def register_exception_handlers(app: FastAPI) -> None:
) -> JSONResponse:
"""Handle bad request errors (400)."""
logger.info(
f"Bad request error: {exc.message}",
"Bad request error: %s",
exc.message,
extra={"details": exc.details, "path": str(request.url.path)},
)
return JSONResponse(
@@ -154,7 +158,8 @@ def register_exception_handlers(app: FastAPI) -> None:
) -> JSONResponse:
"""Handle not found errors (404)."""
logger.info(
f"Not found error: {exc.message}",
"Not found error: %s",
exc.message,
extra={"details": exc.details, "path": str(request.url.path)},
)
return JSONResponse(
@@ -174,7 +179,8 @@ def register_exception_handlers(app: FastAPI) -> None:
) -> JSONResponse:
"""Handle conflict errors (409)."""
logger.info(
f"Conflict error: {exc.message}",
"Conflict error: %s",
exc.message,
extra={"details": exc.details, "path": str(request.url.path)},
)
return JSONResponse(
@@ -194,7 +200,8 @@ def register_exception_handlers(app: FastAPI) -> None:
) -> JSONResponse:
"""Handle rate limit errors (429)."""
logger.warning(
f"Rate limit exceeded: {exc.message}",
"Rate limit exceeded: %s",
exc.message,
extra={"details": exc.details, "path": str(request.url.path)},
)
return JSONResponse(
@@ -214,7 +221,8 @@ def register_exception_handlers(app: FastAPI) -> None:
) -> JSONResponse:
"""Handle generic API exceptions."""
logger.error(
f"API error: {exc.message}",
"API error: %s",
exc.message,
extra={
"error_code": exc.error_code,
"details": exc.details,
@@ -238,12 +246,13 @@ def register_exception_handlers(app: FastAPI) -> None:
) -> JSONResponse:
"""Handle unexpected exceptions."""
logger.exception(
f"Unexpected error: {str(exc)}",
"Unexpected error: %s",
str(exc),
extra={"path": str(request.url.path)},
)
# Log full traceback for debugging
logger.debug(f"Traceback: {traceback.format_exc()}")
logger.debug("Traceback: %s", traceback.format_exc())
# Return generic error response for security
return JSONResponse(

View File

@@ -315,11 +315,11 @@ class RequestSanitizationMiddleware(BaseHTTPMiddleware):
None if malicious content detected, sanitized value otherwise
"""
if self.check_sql_injection and self._check_sql_injection(value):
logger.warning(f"Potential SQL injection detected: {value[:100]}")
logger.warning("Potential SQL injection detected: %s", value[:100])
return None
if self.check_xss and self._check_xss(value):
logger.warning(f"Potential XSS detected: {value[:100]}")
logger.warning("Potential XSS detected: %s", value[:100])
return None
return value
@@ -341,7 +341,7 @@ class RequestSanitizationMiddleware(BaseHTTPMiddleware):
content_type
and not any(ct in content_type for ct in self.allowed_content_types)
):
logger.warning(f"Unsupported content type: {content_type}")
logger.warning("Unsupported content type: %s", content_type)
return JSONResponse(
status_code=415,
content={"detail": "Unsupported Media Type"},
@@ -350,7 +350,7 @@ class RequestSanitizationMiddleware(BaseHTTPMiddleware):
# Check request size
content_length = request.headers.get("content-length")
if content_length and int(content_length) > self.max_request_size:
logger.warning(f"Request too large: {content_length} bytes")
logger.warning("Request too large: %s bytes", content_length)
return JSONResponse(
status_code=413,
content={"detail": "Request Entity Too Large"},
@@ -361,7 +361,7 @@ class RequestSanitizationMiddleware(BaseHTTPMiddleware):
if isinstance(value, str):
sanitized = self._sanitize_value(value)
if sanitized is None:
logger.warning(f"Malicious query parameter detected: {key}")
logger.warning("Malicious query parameter detected: %s", key)
return JSONResponse(
status_code=400,
content={"detail": "Malicious request detected"},
@@ -372,7 +372,7 @@ class RequestSanitizationMiddleware(BaseHTTPMiddleware):
if isinstance(value, str):
sanitized = self._sanitize_value(value)
if sanitized is None:
logger.warning(f"Malicious path parameter detected: {key}")
logger.warning("Malicious path parameter detected: %s", key)
return JSONResponse(
status_code=400,
content={"detail": "Malicious request detected"},

View File

@@ -524,19 +524,20 @@ class AnimeService:
"series_id": db_series.id,
}
# If filter is "no_episodes", get series with no
# downloaded episodes
if filter_type == "no_episodes":
# Use service method to get series with
# undownloaded episodes
series_no_downloads = (
await AnimeSeriesService
.get_series_with_no_episodes(db)
# If filter is "missing_episodes", get series with any missing episodes
if filter_type == "missing_episodes":
series_missing = (
await AnimeSeriesService.get_series_with_missing_episodes(db)
)
series_with_no_episodes = {
s.folder for s in series_no_downloads
}
series_with_missing_episodes = {s.folder for s in series_missing}
# If filter is "no_episodes", get series with no downloaded episodes
if filter_type == "no_episodes":
series_no_downloads = (
await AnimeSeriesService.get_series_with_no_episodes(db)
)
series_with_no_episodes = {s.folder for s in series_no_downloads}
# Build result list with enriched metadata
result_list = []
for serie in series:
@@ -545,8 +546,11 @@ class AnimeService:
site = getattr(serie, "site", "")
folder = getattr(serie, "folder", "")
episode_dict = getattr(serie, "episodeDict", {}) or {}
# Apply filter if specified
if filter_type == "missing_episodes":
if folder not in series_with_missing_episodes:
continue
if filter_type == "no_episodes":
if folder not in series_with_no_episodes:
continue
@@ -941,12 +945,12 @@ class AnimeService:
# Get the serie from in-memory cache
if not hasattr(self._app, 'list') or not hasattr(self._app.list, 'keyDict'):
logger.warning(f"Series list not available for episode sync: {series_key}")
logger.warning("Series list not available for episode sync: %s", series_key)
return 0
serie = self._app.list.keyDict.get(series_key)
if not serie:
logger.warning(f"Series not found in memory for episode sync: {series_key}")
logger.warning("Series not found in memory for episode sync: %s", series_key)
return 0
episodes_added = 0
@@ -955,7 +959,7 @@ class AnimeService:
# Get series from database
series_db = await AnimeSeriesService.get_by_key(db, series_key)
if not series_db:
logger.warning(f"Series not found in database: {series_key}")
logger.warning("Series not found in database: %s", series_key)
return 0
# Get existing episodes from database
@@ -996,7 +1000,7 @@ class AnimeService:
try:
await self._broadcast_series_updated(series_key)
except Exception as e:
logger.warning(f"Failed to broadcast series update: {e}")
logger.warning("Failed to broadcast series update: %s", e)
return episodes_added

View File

@@ -22,6 +22,7 @@ from typing import Any, Dict, List, Optional
import structlog
from src.core.services.nfo_factory import get_nfo_factory
from src.server.services.websocket_service import WebSocketService
logger = structlog.get_logger(__name__)
@@ -188,7 +189,7 @@ class BackgroundLoaderService:
"""
# Check if task already exists
if key in self.active_tasks:
logger.debug(f"Task for series {key} already exists, skipping")
logger.debug("Task for series %s already exists, skipping", key)
return
task = SeriesLoadingTask(
@@ -202,7 +203,7 @@ class BackgroundLoaderService:
self.active_tasks[key] = task
await self.task_queue.put(task)
logger.info(f"Added loading task for series: {key}")
logger.info("Added loading task for series: %s", key)
# Broadcast initial status
await self._broadcast_status(task)
@@ -277,7 +278,7 @@ class BackgroundLoaderService:
Args:
worker_id: Unique identifier for this worker instance
"""
logger.info(f"Background worker {worker_id} started processing tasks")
logger.info("Background worker %s started processing tasks", worker_id)
while not self._shutdown:
try:
@@ -301,14 +302,14 @@ class BackgroundLoaderService:
# No task available, continue loop
continue
except asyncio.CancelledError:
logger.info(f"Worker {worker_id} task cancelled")
logger.info("Worker %s task cancelled", worker_id)
break
except Exception as e:
logger.exception(f"Error in background worker {worker_id}: {e}")
logger.exception("Error in background worker %s: %s", worker_id, e)
# Continue processing other tasks
continue
logger.info(f"Background worker {worker_id} stopped")
logger.info("Background worker %s stopped", worker_id)
async def _load_series_data(self, task: SeriesLoadingTask) -> None:
"""Load all missing data for a series.
@@ -362,10 +363,10 @@ class BackgroundLoaderService:
# Broadcast completion
await self._broadcast_status(task)
logger.info(f"Successfully loaded all data for series: {task.key}")
logger.info("Successfully loaded all data for series: %s", task.key)
except Exception as e:
logger.exception(f"Error loading series data: {e}")
logger.exception("Error loading series data: %s", e)
task.status = LoadingStatus.FAILED
task.error = str(e)
task.completed_at = datetime.now(timezone.utc)
@@ -400,14 +401,14 @@ class BackgroundLoaderService:
# Check if directory exists
if series_dir.exists() and series_dir.is_dir():
logger.debug(f"Found series directory: {series_dir}")
logger.debug("Found series directory: %s", series_dir)
return series_dir
else:
logger.warning(f"Series directory not found: {series_dir}")
logger.warning("Series directory not found: %s", series_dir)
return None
except Exception as e:
logger.error(f"Error finding series directory for {task.key}: {e}")
logger.error("Error finding series directory for %s: %s", task.key, e)
return None
async def _scan_series_episodes(self, series_dir: Path, task: SeriesLoadingTask) -> Dict[str, List[str]]:
@@ -440,13 +441,13 @@ class BackgroundLoaderService:
if episodes:
episodes_by_season[season_name] = episodes
logger.debug(f"Found {len(episodes)} episodes in {season_name}")
logger.debug("Found %s episodes in %s", len(episodes), season_name)
logger.info(f"Scanned {len(episodes_by_season)} seasons for {task.key}")
logger.info("Scanned %s seasons for %s", len(episodes_by_season), task.key)
return episodes_by_season
except Exception as e:
logger.error(f"Error scanning episodes for {task.key}: {e}")
logger.error("Error scanning episodes for %s: %s", task.key, e)
return {}
async def _load_episodes(self, task: SeriesLoadingTask, db: Any) -> None:
@@ -466,7 +467,7 @@ class BackgroundLoaderService:
# Find series directory without full rescan
series_dir = await self._find_series_directory(task)
if not series_dir:
logger.error(f"Cannot load episodes - directory not found for {task.key}")
logger.error("Cannot load episodes - directory not found for %s", task.key)
task.progress["episodes"] = False
return
@@ -474,7 +475,7 @@ class BackgroundLoaderService:
episodes_by_season = await self._scan_series_episodes(series_dir, task)
if not episodes_by_season:
logger.warning(f"No episodes found for {task.key}")
logger.warning("No episodes found for %s", task.key)
task.progress["episodes"] = False
return
@@ -489,10 +490,10 @@ class BackgroundLoaderService:
series_db.loading_status = "loading_episodes"
await db.commit()
logger.info(f"Episodes loaded for series: {task.key} ({len(episodes_by_season)} seasons)")
logger.info("Episodes loaded for series: %s (%s seasons)", task.key, len(episodes_by_season))
except Exception as e:
logger.exception(f"Failed to load episodes for {task.key}: {e}")
logger.exception("Failed to load episodes for %s: %s", task.key, e)
raise
async def _load_nfo_and_images(self, task: SeriesLoadingTask, db: Any) -> bool:
@@ -521,7 +522,7 @@ class BackgroundLoaderService:
# Check if NFO already exists
if self.series_app.nfo_service.has_nfo(task.folder):
logger.info(f"NFO already exists for {task.key}, skipping creation")
logger.info("NFO already exists for %s, skipping creation", task.key)
# Update task progress
task.progress["nfo"] = True
@@ -536,31 +537,46 @@ class BackgroundLoaderService:
if not series_db.has_nfo:
series_db.has_nfo = True
series_db.nfo_created_at = datetime.now(timezone.utc)
logger.info(f"Updated database with existing NFO for {task.key}")
logger.info("Updated database with existing NFO for %s", task.key)
if not series_db.logo_loaded:
series_db.logo_loaded = True
if not series_db.images_loaded:
series_db.images_loaded = True
await db.commit()
logger.info(f"Existing NFO found and database updated for series: {task.key}")
logger.info("Existing NFO found and database updated for series: %s", task.key)
return False
# NFO doesn't exist, create it
await self._broadcast_status(task, "Generating NFO file...")
logger.info(f"Creating new NFO for {task.key}")
# Use existing NFOService to create NFO with all images
# This reuses all existing TMDB API logic and image downloading
nfo_path = await self.series_app.nfo_service.create_tvshow_nfo(
serie_name=task.name,
serie_folder=task.folder,
year=task.year,
download_poster=True,
download_logo=True,
download_fanart=True
)
logger.info("Creating new NFO for %s", task.key)
# Create a fresh NFOService for this task to avoid shared TMDB session closure
try:
factory = get_nfo_factory()
nfo_service = factory.create()
except ValueError:
logger.warning(
"NFOService unavailable for %s, skipping NFO/images",
task.key
)
task.progress["nfo"] = False
task.progress["logo"] = False
task.progress["images"] = False
return False
try:
nfo_path = await nfo_service.create_tvshow_nfo(
serie_name=task.name,
serie_folder=task.folder,
year=task.year,
download_poster=True,
download_logo=True,
download_fanart=True
)
finally:
await nfo_service.close()
# Update task progress
task.progress["nfo"] = True
task.progress["logo"] = True
@@ -577,11 +593,11 @@ class BackgroundLoaderService:
series_db.loading_status = "loading_nfo"
await db.commit()
logger.info(f"NFO and images created and loaded for series: {task.key}")
logger.info("NFO and images created and loaded for series: %s", task.key)
return True
except Exception as e:
logger.exception(f"Failed to load NFO/images for {task.key}: {e}")
logger.exception("Failed to load NFO/images for %s: %s", task.key, e)
# Don't fail the entire task if NFO fails
task.progress["nfo"] = False
task.progress["logo"] = False
@@ -611,7 +627,7 @@ class BackgroundLoaderService:
# Scan for missing episodes using the targeted scan method
# This populates the episodeDict without triggering a full rescan
logger.info(f"Scanning missing episodes for {task.key}")
logger.info("Scanning missing episodes for %s", task.key)
missing_episodes = self.series_app.serie_scanner.scan_single_series(
key=task.key,
folder=task.folder
@@ -628,12 +644,12 @@ class BackgroundLoaderService:
# Notify anime_service to sync episodes to database
# Use sync_single_series_after_scan which gets data from serie_scanner.keyDict
if self.anime_service:
logger.debug(f"Calling anime_service.sync_single_series_after_scan for {task.key}")
logger.debug("Calling anime_service.sync_single_series_after_scan for %s", task.key)
await self.anime_service.sync_single_series_after_scan(task.key)
else:
logger.warning(f"anime_service not available, episodes will not be synced to DB for {task.key}")
logger.warning("anime_service not available, episodes will not be synced to DB for %s", task.key)
else:
logger.info(f"No missing episodes found for {task.key}")
logger.info("No missing episodes found for %s", task.key)
# Update series status in database
from src.server.database.service import AnimeSeriesService
@@ -648,7 +664,7 @@ class BackgroundLoaderService:
task.progress["episodes"] = True
except Exception as e:
logger.exception(f"Failed to scan missing episodes for {task.key}: {e}")
logger.exception("Failed to scan missing episodes for %s: %s", task.key, e)
task.progress["episodes"] = False
async def _broadcast_status(

View File

@@ -170,14 +170,17 @@ class InMemoryCacheBackend(CacheBackend):
"""Get value from cache."""
async with self._lock:
if key not in self.cache:
logger.debug("Cache miss for key: %s", key)
return None
item = self.cache[key]
if self._is_expired(item):
logger.debug("Cache expired for key: %s", key)
del self.cache[key]
return None
logger.debug("Cache hit for key: %s", key)
return item["value"]
async def set(
@@ -196,6 +199,7 @@ class InMemoryCacheBackend(CacheBackend):
"expiry": expiry,
"created": datetime.utcnow(),
}
logger.debug("Cached key: %s (ttl=%s)", key, ttl)
return True
async def delete(self, key: str) -> bool:
@@ -203,7 +207,9 @@ class InMemoryCacheBackend(CacheBackend):
async with self._lock:
if key in self.cache:
del self.cache[key]
logger.debug("Deleted cache key: %s", key)
return True
logger.debug("Cache delete skipped; key not found: %s", key)
return False
async def exists(self, key: str) -> bool:
@@ -223,6 +229,7 @@ class InMemoryCacheBackend(CacheBackend):
"""Clear all cached values."""
async with self._lock:
self.cache.clear()
logger.debug("Cleared in-memory cache")
return True
async def get_many(self, keys: List[str]) -> Dict[str, Any]:
@@ -281,13 +288,14 @@ class RedisCacheBackend(CacheBackend):
import aioredis
self._redis = await aioredis.create_redis_pool(self.redis_url)
logger.debug("Connected to Redis at %s", self.redis_url)
except ImportError:
logger.error(
"aioredis not installed. Install with: pip install aioredis"
)
raise
except Exception as e:
logger.error(f"Failed to connect to Redis: {e}")
logger.error("Failed to connect to Redis: %s", e)
raise
return self._redis
@@ -308,7 +316,7 @@ class RedisCacheBackend(CacheBackend):
return pickle.loads(data)
except Exception as e:
logger.error(f"Redis get error: {e}")
logger.error("Redis get error: %s", e)
return None
async def set(
@@ -327,7 +335,7 @@ class RedisCacheBackend(CacheBackend):
return True
except Exception as e:
logger.error(f"Redis set error: {e}")
logger.error("Redis set error: %s", e)
return False
async def delete(self, key: str) -> bool:
@@ -338,7 +346,7 @@ class RedisCacheBackend(CacheBackend):
return result > 0
except Exception as e:
logger.error(f"Redis delete error: {e}")
logger.error("Redis delete error: %s", e)
return False
async def exists(self, key: str) -> bool:
@@ -348,7 +356,7 @@ class RedisCacheBackend(CacheBackend):
return await redis.exists(self._make_key(key))
except Exception as e:
logger.error(f"Redis exists error: {e}")
logger.error("Redis exists error: %s", e)
return False
async def clear(self) -> bool:
@@ -361,7 +369,7 @@ class RedisCacheBackend(CacheBackend):
return True
except Exception as e:
logger.error(f"Redis clear error: {e}")
logger.error("Redis clear error: %s", e)
return False
async def get_many(self, keys: List[str]) -> Dict[str, Any]:
@@ -379,7 +387,7 @@ class RedisCacheBackend(CacheBackend):
return result
except Exception as e:
logger.error(f"Redis get_many error: {e}")
logger.error("Redis get_many error: %s", e)
return {}
async def set_many(
@@ -392,7 +400,7 @@ class RedisCacheBackend(CacheBackend):
return True
except Exception as e:
logger.error(f"Redis set_many error: {e}")
logger.error("Redis set_many error: %s", e)
return False
async def delete_pattern(self, pattern: str) -> int:
@@ -409,7 +417,7 @@ class RedisCacheBackend(CacheBackend):
return 0
except Exception as e:
logger.error(f"Redis delete_pattern error: {e}")
logger.error("Redis delete_pattern error: %s", e)
return 0
async def close(self) -> None:

View File

@@ -8,6 +8,7 @@ This service handles:
"""
import json
import logging
import shutil
from datetime import datetime
from pathlib import Path
@@ -15,6 +16,8 @@ from typing import Dict, List, Optional
from src.server.models.config import AppConfig, ConfigUpdate, ValidationResult
logger = logging.getLogger(__name__)
class ConfigServiceError(Exception):
"""Base exception for configuration service errors."""
@@ -136,7 +139,7 @@ class ConfigService:
self.create_backup()
except ConfigBackupError as e:
# Log but don't fail save operation
print(f"Warning: Failed to create backup: {e}")
logger.warning("Failed to create backup: %s", e)
# Save configuration with version
data = config.model_dump()

View File

@@ -342,7 +342,7 @@ async def perform_nfo_scan_if_needed(progress_service=None):
if not settings.tmdb_api_key
else "Skipped - NFO features disabled"
)
logger.info(f"NFO scan skipped: {message}")
logger.info("NFO scan skipped: %s", message)
if progress_service:
await progress_service.complete_progress(

View File

@@ -151,7 +151,7 @@ class EmailNotificationService:
start_tls=True,
)
logger.info(f"Email notification sent to {to_address}")
logger.info("Email notification sent to %s", to_address)
return True
except ImportError:
@@ -160,7 +160,7 @@ class EmailNotificationService:
)
return False
except Exception as e:
logger.error(f"Failed to send email notification: {e}")
logger.error("Failed to send email notification: %s", e)
return False
@@ -205,7 +205,7 @@ class WebhookNotificationService:
timeout=aiohttp.ClientTimeout(total=self.timeout),
) as response:
if response.status < 400:
logger.info(f"Webhook notification sent to {url}")
logger.info("Webhook notification sent to %s", url)
return True
else:
logger.warning(
@@ -213,9 +213,9 @@ class WebhookNotificationService:
)
except asyncio.TimeoutError:
logger.warning(f"Webhook timeout (attempt {attempt + 1}/{self.max_retries}): {url}")
logger.warning("Webhook timeout (attempt %s/%s): %s", attempt + 1, self.max_retries, url)
except Exception as e:
logger.error(f"Failed to send webhook (attempt {attempt + 1}/{self.max_retries}): {e}")
logger.error("Failed to send webhook (attempt %s/%s): %s", attempt + 1, self.max_retries, e)
if attempt < self.max_retries - 1:
await asyncio.sleep(2 ** attempt) # Exponential backoff
@@ -436,7 +436,7 @@ class NotificationService:
await self.in_app_service.add_notification(notification)
results["in_app"] = True
except Exception as e:
logger.error(f"Failed to send in-app notification: {e}")
logger.error("Failed to send in-app notification: %s", e)
results["in_app"] = False
# Send email notification
@@ -452,7 +452,7 @@ class NotificationService:
)
results["email"] = success
except Exception as e:
logger.error(f"Failed to send email notification: {e}")
logger.error("Failed to send email notification: %s", e)
results["email"] = False
# Send webhook notifications
@@ -476,7 +476,7 @@ class NotificationService:
success = await self.webhook_service.send_webhook(str(url), payload)
webhook_results.append(success)
except Exception as e:
logger.error(f"Failed to send webhook notification to {url}: {e}")
logger.error("Failed to send webhook notification to %s: %s", url, e)
webhook_results.append(False)
results["webhook"] = all(webhook_results) if webhook_results else False

View File

@@ -82,7 +82,7 @@ class LogManager:
log_path = self.log_dir / log_file
if not log_path.exists():
logger.warning(f"Log file not found: {log_file}")
logger.warning("Log file not found: %s", log_file)
return False
stat = log_path.stat()
@@ -99,10 +99,10 @@ class LogManager:
# Compress the rotated file
self._compress_log(rotated_path)
logger.info(f"Rotated log file: {log_file} -> {rotated_name}")
logger.info("Rotated log file: %s -> %s", log_file, rotated_name)
return True
except Exception as e:
logger.error(f"Failed to rotate log file {log_file}: {e}")
logger.error("Failed to rotate log file %s: %s", log_file, e)
return False
def _compress_log(self, log_path: Path) -> bool:
@@ -122,10 +122,10 @@ class LogManager:
shutil.copyfileobj(f_in, f_out)
log_path.unlink()
logger.debug(f"Compressed log file: {log_path.name}")
logger.debug("Compressed log file: %s", log_path.name)
return True
except Exception as e:
logger.error(f"Failed to compress log {log_path}: {e}")
logger.error("Failed to compress log %s: %s", log_path, e)
return False
def archive_old_logs(
@@ -160,10 +160,10 @@ class LogManager:
f"Failed to archive {log_file.filename}: {e}"
)
logger.info(f"Archived {archived_count} old log files")
logger.info("Archived %s old log files", archived_count)
return archived_count
except Exception as e:
logger.error(f"Failed to archive logs: {e}")
logger.error("Failed to archive logs: %s", e)
return 0
def search_logs(
@@ -209,7 +209,7 @@ class LogManager:
)
return results
except Exception as e:
logger.error(f"Failed to search logs: {e}")
logger.error("Failed to search logs: %s", e)
return {}
def export_logs(
@@ -243,7 +243,7 @@ class LogManager:
arcname=log_file.filename,
)
logger.info(f"Exported logs to: {tar_path}")
logger.info("Exported logs to: %s", tar_path)
return True
else:
# Concatenate all logs
@@ -253,10 +253,10 @@ class LogManager:
with open(log_file.path, "r") as in_f:
out_f.write(in_f.read())
logger.info(f"Exported logs to: {output_path}")
logger.info("Exported logs to: %s", output_path)
return True
except Exception as e:
logger.error(f"Failed to export logs: {e}")
logger.error("Failed to export logs: %s", e)
return False
def get_log_stats(self) -> Dict[str, Any]:
@@ -294,7 +294,7 @@ class LogManager:
"newest_file": log_files[0].filename,
}
except Exception as e:
logger.error(f"Failed to get log stats: {e}")
logger.error("Failed to get log stats: %s", e)
return {}
def cleanup_logs(
@@ -330,16 +330,16 @@ class LogManager:
log_file.path.unlink()
total_size -= log_file.size_bytes
deleted_count += 1
logger.debug(f"Deleted log file: {log_file.filename}")
logger.debug("Deleted log file: %s", log_file.filename)
except Exception as e:
logger.warning(
f"Failed to delete {log_file.filename}: {e}"
)
logger.info(f"Cleaned up {deleted_count} log files")
logger.info("Cleaned up %s log files", deleted_count)
return deleted_count
except Exception as e:
logger.error(f"Failed to cleanup logs: {e}")
logger.error("Failed to cleanup logs: %s", e)
return 0
def set_log_level(self, logger_name: str, level: str) -> bool:
@@ -357,10 +357,10 @@ class LogManager:
target_logger = logging.getLogger(logger_name)
target_logger.setLevel(log_level)
logger.info(f"Set {logger_name} log level to {level}")
logger.info("Set %s log level to %s", logger_name, level)
return True
except Exception as e:
logger.error(f"Failed to set log level: {e}")
logger.error("Failed to set log level: %s", e)
return False

View File

@@ -416,9 +416,9 @@ def cleanup_old_logs(log_dir: Union[str, Path],
try:
if log_file.stat().st_mtime < cutoff_time:
log_file.unlink()
logger.info(f"Deleted old log file: {log_file}")
logger.info("Deleted old log file: %s", log_file)
except Exception as e:
logger.error(f"Failed to delete log file {log_file}: {e}")
logger.error("Failed to delete log file %s: %s", log_file, e)
# Initialize default logging configuration

View File

@@ -161,7 +161,7 @@ class MetricsCollector:
Duration in seconds.
"""
if timer_name not in self._timers:
logger.warning(f"Timer {timer_name} not started")
logger.warning("Timer %s not started", timer_name)
return 0.0
duration = time.time() - self._timers[timer_name]

View File

@@ -60,7 +60,7 @@ class SystemUtilities:
path=path,
)
except Exception as e:
logger.error(f"Failed to get disk usage for {path}: {e}")
logger.error("Failed to get disk usage for %s: %s", path, e)
return None
@staticmethod
@@ -93,7 +93,7 @@ class SystemUtilities:
return disk_infos
except Exception as e:
logger.error(f"Failed to get all disk usage: {e}")
logger.error("Failed to get all disk usage: %s", e)
return []
@staticmethod
@@ -115,7 +115,7 @@ class SystemUtilities:
path = Path(directory)
if not path.exists():
logger.warning(f"Directory not found: {directory}")
logger.warning("Directory not found: %s", directory)
return 0
deleted_count = 0
@@ -130,16 +130,16 @@ class SystemUtilities:
try:
file_path.unlink()
deleted_count += 1
logger.debug(f"Deleted file: {file_path}")
logger.debug("Deleted file: %s", file_path)
except Exception as e:
logger.warning(
f"Failed to delete {file_path}: {e}"
)
logger.info(f"Cleaned up {deleted_count} files from {directory}")
logger.info("Cleaned up %s files from %s", deleted_count, directory)
return deleted_count
except Exception as e:
logger.error(f"Failed to cleanup directory {directory}: {e}")
logger.error("Failed to cleanup directory %s: %s", directory, e)
return 0
@staticmethod
@@ -171,12 +171,12 @@ class SystemUtilities:
f"Deleted empty directory: {dir_path}"
)
except Exception as e:
logger.debug(f"Cannot delete {dir_path}: {e}")
logger.debug("Cannot delete %s: %s", dir_path, e)
logger.info(f"Cleaned up {deleted_count} empty directories")
logger.info("Cleaned up %s empty directories", deleted_count)
return deleted_count
except Exception as e:
logger.error(f"Failed to cleanup empty directories: {e}")
logger.error("Failed to cleanup empty directories: %s", e)
return 0
@staticmethod
@@ -201,7 +201,7 @@ class SystemUtilities:
return total_size
except Exception as e:
logger.error(f"Failed to get directory size for {directory}: {e}")
logger.error("Failed to get directory size for %s: %s", directory, e)
return 0
@staticmethod
@@ -232,7 +232,7 @@ class SystemUtilities:
),
)
except Exception as e:
logger.error(f"Failed to get process info for {pid}: {e}")
logger.error("Failed to get process info for %s: %s", pid, e)
return None
@staticmethod
@@ -260,7 +260,7 @@ class SystemUtilities:
return processes
except Exception as e:
logger.error(f"Failed to get all processes: {e}")
logger.error("Failed to get all processes: %s", e)
return []
@staticmethod
@@ -285,7 +285,7 @@ class SystemUtilities:
"python_version": platform.python_version(),
}
except Exception as e:
logger.error(f"Failed to get system info: {e}")
logger.error("Failed to get system info: %s", e)
return {}
@staticmethod
@@ -308,7 +308,7 @@ class SystemUtilities:
"dropped_out": net_io.dropout,
}
except Exception as e:
logger.error(f"Failed to get network info: {e}")
logger.error("Failed to get network info: %s", e)
return {}
@staticmethod
@@ -330,7 +330,7 @@ class SystemUtilities:
dest_path = Path(dest)
if not src_path.exists():
logger.error(f"Source file not found: {src}")
logger.error("Source file not found: %s", src)
return False
# Create temporary file
@@ -342,10 +342,10 @@ class SystemUtilities:
# Atomic rename
temp_path.replace(dest_path)
logger.debug(f"Atomically copied {src} to {dest}")
logger.debug("Atomically copied %s to %s", src, dest)
return True
except Exception as e:
logger.error(f"Failed to copy file {src} to {dest}: {e}")
logger.error("Failed to copy file %s to %s: %s", src, dest, e)
return False

View File

@@ -16,7 +16,7 @@ AniWorld.SeriesManager = (function() {
// State
let seriesData = [];
let filteredSeriesData = [];
let showMissingOnly = false;
let filterMode = 'all'; // 'all' | 'missing_episodes' | 'no_episodes'
let sortAlphabetical = false;
/**
@@ -24,15 +24,16 @@ AniWorld.SeriesManager = (function() {
*/
function init() {
bindEvents();
updateFilterButtonUI();
}
/**
* Bind UI events for filtering and sorting
*/
function bindEvents() {
const missingOnlyBtn = document.getElementById('show-missing-only');
if (missingOnlyBtn) {
missingOnlyBtn.addEventListener('click', toggleMissingOnlyFilter);
const filterBtn = document.getElementById('show-missing-only');
if (filterBtn) {
filterBtn.addEventListener('click', toggleFilterMode);
}
const sortBtn = document.getElementById('sort-alphabetical');
@@ -49,7 +50,10 @@ AniWorld.SeriesManager = (function() {
try {
AniWorld.UI.showLoading();
const response = await AniWorld.ApiClient.get(API.ANIME_LIST);
const url = filterMode && filterMode !== 'all'
? `${API.ANIME_LIST}?filter=${encodeURIComponent(filterMode)}`
: API.ANIME_LIST;
const response = await AniWorld.ApiClient.get(url);
if (!response) {
return [];
@@ -111,28 +115,28 @@ AniWorld.SeriesManager = (function() {
}
/**
* Toggle missing episodes only filter
* Cycle through filter modes:
* - all: Show all series
* - missing_episodes: Show only series with missing episodes
* - no_episodes: Show only series with zero downloaded episodes
*/
function toggleMissingOnlyFilter() {
showMissingOnly = !showMissingOnly;
async function toggleFilterMode() {
const button = document.getElementById('show-missing-only');
button.setAttribute('data-active', showMissingOnly);
button.classList.toggle('active', showMissingOnly);
const icon = button.querySelector('i');
const text = button.querySelector('span');
if (showMissingOnly) {
icon.className = 'fas fa-filter-circle-xmark';
text.textContent = 'Show All Series';
// Cycle through modes
if (filterMode === 'all') {
filterMode = 'missing_episodes';
} else if (filterMode === 'missing_episodes') {
filterMode = 'no_episodes';
} else {
icon.className = 'fas fa-filter';
text.textContent = 'Missing Episodes Only';
filterMode = 'all';
}
applyFiltersAndSort();
renderSeries();
// Update button UI and reload list based on new filter.
updateFilterButtonUI();
await loadSeries();
// Clear selection when filter changes
if (AniWorld.SelectionManager) {
@@ -140,6 +144,34 @@ AniWorld.SeriesManager = (function() {
}
}
/**
* Update the filter button UI to reflect current filter mode
*/
function updateFilterButtonUI() {
const button = document.getElementById('show-missing-only');
if (!button) {
return;
}
const icon = button.querySelector('i');
const text = button.querySelector('span');
const isActive = filterMode !== 'all';
button.setAttribute('data-active', isActive);
button.classList.toggle('active', isActive);
if (filterMode === 'missing_episodes') {
icon.className = 'fas fa-filter';
text.textContent = 'Missing Episodes Only';
} else if (filterMode === 'no_episodes') {
icon.className = 'fas fa-ban';
text.textContent = 'No Episodes';
} else {
icon.className = 'fas fa-filter-circle-xmark';
text.textContent = 'Show All Series';
}
}
/**
* Toggle alphabetical sorting
*/
@@ -193,13 +225,6 @@ AniWorld.SeriesManager = (function() {
}
});
// Apply missing episodes filter
if (showMissingOnly) {
filtered = filtered.filter(function(serie) {
return serie.missing_episodes > 0;
});
}
filteredSeriesData = filtered;
}
@@ -212,9 +237,14 @@ AniWorld.SeriesManager = (function() {
(seriesData.length > 0 ? seriesData : []);
if (dataToRender.length === 0) {
const message = showMissingOnly ?
'No series with missing episodes found.' :
'No series found. Try searching for anime or rescanning your directory.';
let message;
if (filterMode === 'missing_episodes') {
message = 'No series with missing episodes found.';
} else if (filterMode === 'no_episodes') {
message = 'No series with zero downloaded episodes found.';
} else {
message = 'No series found. Try searching for anime or rescanning your directory.';
}
grid.innerHTML =
'<div class="text-center" style="grid-column: 1 / -1; padding: 2rem;">' +

View File

@@ -4,12 +4,15 @@ This test verifies that the /api/anime/add endpoint can handle
multiple concurrent requests without blocking.
"""
import asyncio
import logging
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
logger = logging.getLogger(__name__)
from src.server.fastapi_app import app
from src.server.services.auth_service import auth_service
from src.server.services.background_loader_service import get_background_loader_service
@@ -103,7 +106,7 @@ async def test_concurrent_anime_add_requests(authenticated_client):
f"indicating possible blocking issues"
)
print(f"3 concurrent anime add requests completed in {total_time:.2f}s")
logger.info("3 concurrent anime add requests completed in %.2fs", total_time)
@pytest.mark.asyncio
@@ -130,4 +133,4 @@ async def test_same_anime_concurrent_add(authenticated_client):
keys = [r.json().get("key") for r in responses]
assert keys[0] == keys[1], "Both responses should have the same key"
print(f"Concurrent same-anime requests handled correctly: {statuses}")
logger.info("Concurrent same-anime requests handled correctly: %s", statuses)

View File

@@ -4,6 +4,7 @@ This module tests the performance characteristics of batch NFO creation
including concurrent operations, TMDB API request optimization, and memory usage.
"""
import asyncio
import logging
import time
from pathlib import Path
from typing import List
@@ -15,6 +16,8 @@ from src.core.services.nfo_service import NFOService
from src.server.api.nfo import batch_create_nfo
from src.server.models.nfo import NFOBatchCreateRequest
logger = logging.getLogger(__name__)
class TestConcurrentNFOCreation:
"""Test performance of concurrent NFO creation operations."""
@@ -83,8 +86,11 @@ class TestConcurrentNFOCreation:
# Concurrent should take roughly (num_series / 5) * 0.1 = 0.2s
assert elapsed_time < 1.0, "Concurrency not providing speedup"
print(f"\nPerformance: {num_series} series in {elapsed_time:.2f}s")
print(f"Rate: {num_series / elapsed_time:.2f} series/second")
logger.info("Batch NFO creation completed", extra={"num_series": num_series, "elapsed_s": elapsed_time})
logger.debug(
"Batch NFO creation rate",
extra={"series_per_second": num_series / elapsed_time},
)
@pytest.mark.asyncio
async def test_concurrent_nfo_creation_50_series(self):

View File

@@ -4,6 +4,7 @@ This module tests the performance characteristics of WebSocket connections
including concurrent clients, message throughput, and progress update throttling.
"""
import asyncio
import logging
import time
from typing import List
from unittest.mock import AsyncMock, Mock
@@ -12,6 +13,8 @@ import pytest
from src.server.services.websocket_service import WebSocketService
logger = logging.getLogger(__name__)
class MockWebSocket:
"""Mock WebSocket client for testing."""
@@ -82,8 +85,14 @@ class TestWebSocketConcurrentClients:
for i in range(num_clients):
await websocket_service.disconnect(f"client_{i:03d}")
print(f"\n100 clients: Broadcast in {elapsed_time:.2f}s")
print(f"Average per client: {elapsed_time / num_clients * 1000:.2f}ms")
logger.info("Broadcast completed for %d clients", num_clients, extra={"elapsed_s": elapsed_time})
logger.debug(
"Broadcast performance per client",
extra={
"num_clients": num_clients,
"avg_ms_per_client": elapsed_time / num_clients * 1000,
},
)
@pytest.mark.asyncio
async def test_200_concurrent_clients_scalability(self):
@@ -114,7 +123,7 @@ class TestWebSocketConcurrentClients:
for i in range(num_clients):
await websocket_service.disconnect(f"client_{i:03d}")
print(f"\n200 clients: Broadcast in {elapsed_time:.2f}s")
logger.info("Broadcast completed for %d clients", num_clients, extra={"elapsed_s": elapsed_time})
@pytest.mark.asyncio
async def test_connection_pool_efficiency(self):
@@ -144,8 +153,8 @@ class TestWebSocketConcurrentClients:
for i in range(num_clients):
await websocket_service.disconnect(f"client_{i:02d}")
print(f"\nConnected {num_clients} clients in {connection_time:.3f}s")
print(f"Average: {connection_time / num_clients * 1000:.2f}ms per connection")
logger.info("Connected %d clients in %.3fs", num_clients, connection_time)
logger.info("Average: %.2fms per connection", connection_time / num_clients * 1000)
class TestMessageThroughput:
@@ -192,8 +201,13 @@ class TestMessageThroughput:
for i in range(num_clients):
await websocket_service.disconnect(f"client_{i}")
print(f"\nThroughput: {messages_per_second:.2f} messages/second")
print(f"Total: {num_messages} messages to {num_clients} clients in {elapsed_time:.2f}s")
logger.info("Throughput: %.2f messages/second", messages_per_second)
logger.info(
"Total: %d messages to %d clients in %.2fs",
num_messages,
num_clients,
elapsed_time,
)
@pytest.mark.asyncio
async def test_high_frequency_updates(self):
@@ -234,7 +248,7 @@ class TestMessageThroughput:
for i in range(5):
await websocket_service.disconnect(f"client_{i}")
print(f"\nHigh-frequency: {updates_per_second:.2f} updates/second")
logger.info("High-frequency: %.2f updates/second", updates_per_second)
@pytest.mark.asyncio
async def test_burst_message_handling(self):
@@ -275,7 +289,7 @@ class TestMessageThroughput:
for i in range(num_clients):
await websocket_service.disconnect(f"client_{i:02d}")
print(f"\nBurst: {num_messages} messages in {elapsed_time:.2f}s")
logger.info("Burst: %d messages in %.2fs", num_messages, elapsed_time)
class TestProgressUpdateThrottling:
@@ -313,7 +327,10 @@ class TestProgressUpdateThrottling:
await websocket_service.disconnect("test_client")
print(f"\nThrottling: {len(client.received_messages)} updates sent (100 possible)")
logger.info(
"Throttling: %d updates sent (100 possible)",
len(client.received_messages),
)
@pytest.mark.asyncio
async def test_throttling_reduces_network_load(self):
@@ -356,7 +373,11 @@ class TestProgressUpdateThrottling:
for i in range(10):
await websocket_service.disconnect(f"client_{i}")
print(f"\nThrottling: {throttled_updates}/1000 updates sent ({reduction_percent:.1f}% reduction)")
logger.info(
"Throttling: %d/1000 updates sent (%.1f%% reduction)",
throttled_updates,
reduction_percent,
)
class TestRoomIsolation:
@@ -402,7 +423,7 @@ class TestRoomIsolation:
for i in range(clients_per_room):
await websocket_service.disconnect(f"{room}_client_{i:02d}")
print(f"\nRoom isolation: 3 rooms × 30 clients in {elapsed_time:.2f}s")
logger.info("Room isolation: 3 rooms × 30 clients in %.2fs", elapsed_time)
@pytest.mark.asyncio
async def test_selective_room_broadcast_performance(self):
@@ -435,7 +456,7 @@ class TestRoomIsolation:
for i in range(clients_per_room):
await websocket_service.disconnect(f"{room}_{i:02d}")
print(f"\nSelective broadcast: 25/100 clients in {elapsed_time:.3f}s")
logger.info("Selective broadcast: 25/100 clients in %.3fs", elapsed_time)
class TestConnectionStability:
@@ -472,7 +493,7 @@ class TestConnectionStability:
# All connections should be cleaned up
assert len(websocket_service.manager._active_connections) == 0
print(f"\nRapid cycles: {cycles_per_second:.2f} cycles/second")
logger.info("Rapid cycles: %.2f cycles/second", cycles_per_second)
@pytest.mark.asyncio
async def test_concurrent_connect_disconnect(self):
@@ -497,7 +518,7 @@ class TestConnectionStability:
# All should be cleaned up
assert len(websocket_service.manager._active_connections) == 0
print(f"\nConcurrent ops: 30 clients in {elapsed_time:.2f}s")
logger.info("Concurrent ops: 30 clients in %.2fs", elapsed_time)
class TestMemoryEfficiency:
@@ -533,8 +554,8 @@ class TestMemoryEfficiency:
for i in range(100):
await websocket_service.disconnect(f"mem_client_{i:03d}")
print(f"\nMemory: {memory_increase_mb:.2f}MB for 100 connections")
print(f"Per connection: {per_connection_kb:.2f}KB")
logger.info("Memory: %.2fMB for 100 connections", memory_increase_mb)
logger.info("Per connection: %.2fKB", per_connection_kb)
@pytest.mark.asyncio
async def test_message_queue_memory_efficiency(self):
@@ -567,5 +588,5 @@ class TestMemoryEfficiency:
await websocket_service.disconnect("queue_test")
print(f"\nMessage queue: {total_size} bytes for 100 messages")
print(f"Average: {total_size / 100:.2f} bytes/message")
logger.info("Message queue: %d bytes for 100 messages", total_size)
logger.info("Average: %.2f bytes/message", total_size / 100)

View File

@@ -424,7 +424,14 @@ class TestCreateTVShowNFO:
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock):
mock_search.return_value = {"results": [{"id": 1429, "name": "Attack on Titan", "first_air_date": "2013-04-07"}]}
mock_search.return_value = {
"results": [{
"id": 1429,
"name": "Attack on Titan",
"first_air_date": "2013-04-07",
"overview": "Several hundred years ago, humans were nearly...",
}]
}
mock_details.return_value = mock_tmdb_data
mock_ratings.return_value = mock_content_ratings_de
@@ -463,7 +470,14 @@ class TestCreateTVShowNFO:
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock):
mock_search.return_value = {"results": [{"id": 1429, "name": "Attack on Titan", "first_air_date": "2013-04-07"}]}
mock_search.return_value = {
"results": [{
"id": 1429,
"name": "Attack on Titan",
"first_air_date": "2013-04-07",
"overview": "Several hundred years ago, humans were nearly...",
}]
}
mock_details.return_value = mock_tmdb_data
mock_ratings.return_value = mock_content_ratings_no_de
@@ -749,7 +763,14 @@ class TestNFOServiceEdgeCases:
"poster_path": None, "backdrop_path": None
}
mock_search.return_value = {"results": [{"id": 1, "name": "Series", "first_air_date": "2020-01-01"}]}
mock_search.return_value = {
"results": [{
"id": 1,
"name": "Series",
"first_air_date": "2020-01-01",
"overview": "Test overview.",
}]
}
mock_details.return_value = tmdb_data
mock_ratings.return_value = {"results": []}
@@ -1486,6 +1507,67 @@ class TestEnrichFallbackLanguages:
content = nfo_path.read_text(encoding="utf-8")
assert "<plot>Search result overview text.</plot>" in content
@pytest.mark.asyncio
async def test_en_us_search_fallback_when_german_search_overview_empty(
self, nfo_service, tmp_path
):
"""When the German search overview is empty, fallback to en-US search overview."""
series_folder = tmp_path / "Rare Anime"
series_folder.mkdir()
empty_data = {
"id": 77777, "name": "Rare Anime",
"original_name": "新しいアニメ", "first_air_date": "2025-01-01",
"overview": "",
"vote_average": 0, "vote_count": 0,
"status": "Continuing", "episode_run_time": [],
"genres": [], "networks": [], "production_countries": [],
"poster_path": None, "backdrop_path": None,
"external_ids": {}, "credits": {"cast": []},
"images": {"logos": []},
}
async def search_side_effect(query, language="de-DE", page=1):
if language == "en-US":
return {
"results": [{
"id": 77777,
"name": "Rare Anime",
"first_air_date": "2025-01-01",
"overview": "English search overview text.",
}],
}
return {
"results": [{
"id": 77777,
"name": "Rare Anime",
"first_air_date": "2025-01-01",
"overview": "",
}],
}
async def details_side_effect(tv_id, **kwargs):
return empty_data
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, '_download_media_files', new_callable=AsyncMock):
mock_search.side_effect = search_side_effect
mock_details.side_effect = details_side_effect
mock_ratings.return_value = {"results": []}
nfo_path = await nfo_service.create_tvshow_nfo(
"Rare Anime", "Rare Anime",
download_poster=False, download_logo=False, download_fanart=False,
)
content = nfo_path.read_text(encoding="utf-8")
assert "<plot>English search overview text.</plot>" in content
assert mock_search.call_count == 2
assert mock_search.call_args_list[1].kwargs['language'] == 'en-US'
@pytest.mark.asyncio
async def test_no_japanese_fallback_when_english_succeeds(
self, nfo_service, tmp_path,

View File

@@ -1,6 +1,7 @@
"""Unit test for NFOService.update_tvshow_nfo() - tests XML parsing logic."""
import asyncio
import logging
import shutil
import tempfile
from pathlib import Path
@@ -8,6 +9,8 @@ from pathlib import Path
import pytest
from lxml import etree
logger = logging.getLogger(__name__)
from src.core.services.nfo_service import NFOService
from src.core.services.tmdb_client import TMDBAPIError
@@ -51,7 +54,7 @@ def test_parse_nfo_with_uniqueid():
break
assert tmdb_id == 1429, f"Expected TMDB ID 1429, got {tmdb_id}"
print(f"Successfully parsed TMDB ID from uniqueid: {tmdb_id}")
logger.info("Successfully parsed TMDB ID from uniqueid: %s", tmdb_id)
finally:
shutil.rmtree(temp_dir)
@@ -92,7 +95,7 @@ def test_parse_nfo_with_tmdbid_element():
tmdb_id = int(tmdbid_elem.text)
assert tmdb_id == 12345, f"Expected TMDB ID 12345, got {tmdb_id}"
print(f"Successfully parsed TMDB ID from tmdbid element: {tmdb_id}")
logger.info("Successfully parsed TMDB ID from tmdbid element: %s", tmdb_id)
finally:
shutil.rmtree(temp_dir)
@@ -131,7 +134,7 @@ def test_parse_nfo_without_tmdb_id():
tmdb_id = int(tmdbid_elem.text)
assert tmdb_id is None, "Should not have found TMDB ID"
print("Correctly identified NFO without TMDB ID")
logger.info("Correctly identified NFO without TMDB ID")
finally:
shutil.rmtree(temp_dir)
@@ -157,22 +160,23 @@ def test_parse_invalid_xml():
tree = etree.parse(str(nfo_path))
assert False, "Should have raised XMLSyntaxError"
except etree.XMLSyntaxError:
print("Correctly raised XMLSyntaxError for invalid XML")
logger.info("Correctly raised XMLSyntaxError for invalid XML")
finally:
shutil.rmtree(temp_dir)
if __name__ == "__main__":
print("Testing NFO XML parsing logic...")
print()
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger.info("Testing NFO XML parsing logic...")
logger.info("")
test_parse_nfo_with_uniqueid()
test_parse_nfo_with_tmdbid_element()
test_parse_nfo_without_tmdb_id()
test_parse_invalid_xml()
print()
print("=" * 60)
print("ALL TESTS PASSED")
print("=" * 60)
logger.info("")
logger.info("%s", "=" * 60)
logger.info("ALL TESTS PASSED")
logger.info("%s", "=" * 60)

View File

@@ -5,11 +5,14 @@ each other. The background loader should process multiple series simultaneously
rather than sequentially.
"""
import asyncio
import logging
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
logger = logging.getLogger(__name__)
from src.server.services.background_loader_service import (
BackgroundLoaderService,
LoadingStatus,
@@ -162,9 +165,9 @@ async def test_parallel_anime_additions(
f"(indicating sequential processing)"
)
print(f"Parallel execution verified:")
print(f" - Start time difference: {start_diff:.3f}s")
print(f" - Total duration: {total_duration:.3f}s")
logger.info("Parallel execution verified")
logger.info("Start time difference: %.3fs", start_diff)
logger.info("Total duration: %.3fs", total_duration)
@pytest.mark.asyncio

View File

@@ -49,6 +49,33 @@ class TestSeriesAppInitialization:
with pytest.raises(RuntimeError):
SeriesApp(test_dir)
@patch('src.core.SeriesApp.Loaders')
@patch('src.core.SeriesApp.SerieScanner')
@patch('src.core.SeriesApp.SerieList')
@patch('src.core.services.nfo_factory.get_nfo_factory')
@patch('src.core.SeriesApp.settings')
def test_init_uses_config_fallback_for_nfo_service(
self,
mock_settings,
mock_get_factory,
mock_serie_list,
mock_scanner,
mock_loaders,
):
"""SeriesApp should initialize NFO via config.json even when TMDB_API_KEY is unset."""
test_dir = "/test/anime"
mock_settings.tmdb_api_key = None
mock_factory = Mock()
mock_service = Mock()
mock_factory.create.return_value = mock_service
mock_get_factory.return_value = mock_factory
app = SeriesApp(test_dir)
assert app.nfo_service is mock_service
mock_get_factory.assert_called_once()
class TestSeriesAppSearch:
"""Test search functionality."""

View File

@@ -124,8 +124,8 @@ async def test_get_series_with_no_episodes_mixed_downloads(
async_session: AsyncSession
):
"""Test series with mixed downloaded/undownloaded episodes.
Series with ANY missing episodes (is_downloaded=False) should appear.
Series should NOT appear when there is at least one downloaded episode.
"""
# Create series with some downloaded and some undownloaded episodes
series = await AnimeSeriesService.create(
@@ -135,7 +135,7 @@ async def test_get_series_with_no_episodes_mixed_downloads(
folder="Test Series Mixed (2024)",
site="https://example.com/testmixed",
)
# Add downloaded episode
await EpisodeService.create(
async_session,
@@ -144,7 +144,7 @@ async def test_get_series_with_no_episodes_mixed_downloads(
episode_number=1,
is_downloaded=True,
)
# Add undownloaded episode (MISSING)
await EpisodeService.create(
async_session,
@@ -153,15 +153,56 @@ async def test_get_series_with_no_episodes_mixed_downloads(
episode_number=2,
is_downloaded=False,
)
await async_session.commit()
# Query for series with no episodes in folder
result = await AnimeSeriesService.get_series_with_no_episodes(
async_session
)
# Should return the series because it has missing episodes
# Should not return the series because it has at least one downloaded episode
assert len(result) == 0
@pytest.mark.asyncio
async def test_get_series_with_missing_episodes_mixed_downloads(
async_session: AsyncSession
):
"""Test missing episodes filter includes series with mixed downloads."""
series = await AnimeSeriesService.create(
async_session,
key="test-series-mixed",
name="Test Series Mixed",
folder="Test Series Mixed (2024)",
site="https://example.com/testmixed",
)
# Add downloaded episode
await EpisodeService.create(
async_session,
series_id=series.id,
season=1,
episode_number=1,
is_downloaded=True,
)
# Add undownloaded episode (MISSING)
await EpisodeService.create(
async_session,
series_id=series.id,
season=1,
episode_number=2,
is_downloaded=False,
)
await async_session.commit()
# Query for series with missing episodes
result = await AnimeSeriesService.get_series_with_missing_episodes(
async_session
)
assert len(result) == 1
assert result[0].id == series.id
@@ -171,8 +212,8 @@ async def test_get_series_with_no_episodes_mixed_seasons(
async_session: AsyncSession
):
"""Test series with some seasons downloaded, some not.
If ANY episode is still missing (is_downloaded=False), series should appear.
Series should not appear when any episode is downloaded.
"""
series = await AnimeSeriesService.create(
async_session,
@@ -181,7 +222,7 @@ async def test_get_series_with_no_episodes_mixed_seasons(
folder="Test Series (2024)",
site="https://example.com/test",
)
# Season 1: all episodes downloaded
await EpisodeService.create(
async_session,
@@ -190,7 +231,7 @@ async def test_get_series_with_no_episodes_mixed_seasons(
episode_number=1,
is_downloaded=True,
)
# Season 2: has missing episode
await EpisodeService.create(
async_session,
@@ -199,16 +240,15 @@ async def test_get_series_with_no_episodes_mixed_seasons(
episode_number=1,
is_downloaded=False,
)
await async_session.commit()
result = await AnimeSeriesService.get_series_with_no_episodes(
async_session
)
# Should return the series because season 2 has missing episodes
assert len(result) == 1
assert result[0].id == series.id
# Should not return the series because it has downloaded episodes
assert len(result) == 0
@pytest.mark.asyncio