Compare commits
4 Commits
ceac22fc34
...
v0.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 815a4f1520 | |||
| e3509f5c8f | |||
| 69c2fd01f9 | |||
| 0f36afd88c |
1
Docker/VERSION
Normal file
1
Docker/VERSION
Normal file
@@ -0,0 +1 @@
|
|||||||
|
v0.0.1
|
||||||
@@ -1285,7 +1285,7 @@ Basic health check endpoint.
|
|||||||
{
|
{
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"timestamp": "2025-12-13T10:30:00.000Z",
|
"timestamp": "2025-12-13T10:30:00.000Z",
|
||||||
"version": "1.0.0"
|
"version": "1.0.1"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1303,7 +1303,7 @@ Comprehensive health check with database, filesystem, and system metrics.
|
|||||||
{
|
{
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"timestamp": "2025-12-13T10:30:00.000Z",
|
"timestamp": "2025-12-13T10:30:00.000Z",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"database": {
|
"database": {
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
|
|||||||
Provides `parse_nfo_tags()`, `find_missing_tags()`, `nfo_needs_repair()`, and
|
Provides `parse_nfo_tags()`, `find_missing_tags()`, `nfo_needs_repair()`, and
|
||||||
`NfoRepairService.repair_series()`. 13 required tags are checked.
|
`NfoRepairService.repair_series()`. 13 required tags are checked.
|
||||||
- **`perform_nfo_repair_scan()`
|
- **`perform_nfo_repair_scan()`
|
||||||
(`src/server/services/initialization_service.py`)**: New async function
|
(`src/server/services/folder_scan_service.py`)**: New async function
|
||||||
that iterates every series directory, checks whether `tvshow.nfo` is missing
|
that iterates every series directory, checks whether `tvshow.nfo` is missing
|
||||||
required tags using `nfo_needs_repair()`, and queues the series for background
|
required tags using `nfo_needs_repair()`, and queues the series for background
|
||||||
reload via `asyncio.create_task`. Skips gracefully when `tmdb_api_key` or
|
reload via `asyncio.create_task`. Skips gracefully when `tmdb_api_key` or
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ Location: `data/config.json`
|
|||||||
"master_password_hash": "$pbkdf2-sha256$...",
|
"master_password_hash": "$pbkdf2-sha256$...",
|
||||||
"anime_directory": "/path/to/anime"
|
"anime_directory": "/path/to/anime"
|
||||||
},
|
},
|
||||||
"version": "1.0.0"
|
"version": "1.0.1"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -815,8 +815,7 @@ This calls `NFOService.update_tvshow_nfo()` directly and overwrites the existing
|
|||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||||
| `src/core/services/nfo_repair_service.py` | `REQUIRED_TAGS`, `parse_nfo_tags`, `find_missing_tags`, `nfo_needs_repair`, `NfoRepairService` |
|
| `src/core/services/nfo_repair_service.py` | `REQUIRED_TAGS`, `parse_nfo_tags`, `find_missing_tags`, `nfo_needs_repair`, `NfoRepairService` |
|
||||||
| `src/server/services/initialization_service.py` | `perform_nfo_repair_scan` — invoked from `FolderScanService` |
|
| `src/server/services/folder_scan_service.py` | `perform_nfo_repair_scan` — invoked during the scheduled daily folder scan |
|
||||||
| `src/server/services/folder_scan_service.py` | Calls `perform_nfo_repair_scan` during the scheduled daily folder scan |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aniworld-web",
|
"name": "aniworld-web",
|
||||||
"version": "1.0.0",
|
"version": "0.0.1",
|
||||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Iterable, Iterator, Optional
|
from typing import Callable, Iterable, Iterator, Optional
|
||||||
|
|
||||||
from events import Events
|
from events import Events
|
||||||
|
|
||||||
@@ -43,12 +43,17 @@ class SerieScanner:
|
|||||||
scanner = SerieScanner("/path/to/anime", loader)
|
scanner = SerieScanner("/path/to/anime", loader)
|
||||||
scanner.scan()
|
scanner.scan()
|
||||||
# Results are in scanner.keyDict
|
# Results are in scanner.keyDict
|
||||||
|
|
||||||
|
# With DB lookup fallback:
|
||||||
|
scanner = SerieScanner("/path/to/anime", loader,
|
||||||
|
db_lookup=lambda folder: my_db.get_by_folder(folder))
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
basePath: str,
|
basePath: str,
|
||||||
loader: Loader,
|
loader: Loader,
|
||||||
|
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Initialize the SerieScanner.
|
Initialize the SerieScanner.
|
||||||
@@ -56,8 +61,12 @@ class SerieScanner:
|
|||||||
Args:
|
Args:
|
||||||
basePath: Base directory containing anime series
|
basePath: Base directory containing anime series
|
||||||
loader: Loader instance for fetching series information
|
loader: Loader instance for fetching series information
|
||||||
callback_manager: Optional callback manager for progress updates
|
db_lookup: Optional callable ``(folder_name) -> Serie | None``.
|
||||||
|
When provided, it is called as a fallback when neither a
|
||||||
|
``key`` file nor a ``data`` file is found in the folder.
|
||||||
|
This allows the database to supply the series key for
|
||||||
|
folders that have never had a local key file.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If basePath is invalid or doesn't exist
|
ValueError: If basePath is invalid or doesn't exist
|
||||||
"""
|
"""
|
||||||
@@ -75,6 +84,7 @@ class SerieScanner:
|
|||||||
self.directory: str = abs_path
|
self.directory: str = abs_path
|
||||||
self.keyDict: dict[str, Serie] = {}
|
self.keyDict: dict[str, Serie] = {}
|
||||||
self.loader: Loader = loader
|
self.loader: Loader = loader
|
||||||
|
self._db_lookup: Optional[Callable[[str], Optional[Serie]]] = db_lookup
|
||||||
self._current_operation_id: Optional[str] = None
|
self._current_operation_id: Optional[str] = None
|
||||||
self.events = Events()
|
self.events = Events()
|
||||||
|
|
||||||
@@ -268,6 +278,30 @@ class SerieScanner:
|
|||||||
)
|
)
|
||||||
|
|
||||||
serie = self.__read_data_from_file(folder)
|
serie = self.__read_data_from_file(folder)
|
||||||
|
if serie is None or not serie.key or not serie.key.strip():
|
||||||
|
# Fallback: ask the database for a matching series
|
||||||
|
if self._db_lookup is not None:
|
||||||
|
try:
|
||||||
|
serie = self._db_lookup(folder)
|
||||||
|
if serie:
|
||||||
|
logger.info(
|
||||||
|
"DB lookup resolved folder '%s' -> key='%s'",
|
||||||
|
folder,
|
||||||
|
serie.key,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"DB lookup failed for folder '%s': %s",
|
||||||
|
folder,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
serie = None
|
||||||
|
|
||||||
|
if serie is None or not serie.key or not serie.key.strip():
|
||||||
|
logger.warning(
|
||||||
|
"No key or data file found for folder '%s', skipping",
|
||||||
|
folder,
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
serie is not None
|
serie is not None
|
||||||
and serie.key
|
and serie.key
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
from events import Events
|
from events import Events
|
||||||
|
|
||||||
@@ -143,12 +143,16 @@ class SeriesApp:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
directory_to_search: str,
|
directory_to_search: str,
|
||||||
|
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize SeriesApp.
|
Initialize SeriesApp.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
directory_to_search: Base directory for anime series
|
directory_to_search: Base directory for anime series
|
||||||
|
db_lookup: Optional callable ``(folder_name) -> Serie | None``
|
||||||
|
passed through to ``SerieScanner`` as a fallback key source
|
||||||
|
when no local ``key`` or ``data`` file exists.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self.directory_to_search = directory_to_search
|
self.directory_to_search = directory_to_search
|
||||||
@@ -162,7 +166,7 @@ class SeriesApp:
|
|||||||
self.loaders = Loaders()
|
self.loaders = Loaders()
|
||||||
self.loader = self.loaders.GetLoader(key="aniworld.to")
|
self.loader = self.loaders.GetLoader(key="aniworld.to")
|
||||||
self.serie_scanner = SerieScanner(
|
self.serie_scanner = SerieScanner(
|
||||||
directory_to_search, self.loader
|
directory_to_search, self.loader, db_lookup=db_lookup
|
||||||
)
|
)
|
||||||
# Skip automatic loading from data files - series will be loaded
|
# Skip automatic loading from data files - series will be loaded
|
||||||
# from database by the service layer during application setup
|
# from database by the service layer during application setup
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class HealthStatus(BaseModel):
|
|||||||
|
|
||||||
status: str
|
status: str
|
||||||
timestamp: str
|
timestamp: str
|
||||||
version: str = "1.0.0"
|
version: str = "1.0.1"
|
||||||
service: str = "aniworld-api"
|
service: str = "aniworld-api"
|
||||||
series_app_initialized: bool = False
|
series_app_initialized: bool = False
|
||||||
anime_directory_configured: bool = False
|
anime_directory_configured: bool = False
|
||||||
@@ -60,7 +60,7 @@ class DetailedHealthStatus(BaseModel):
|
|||||||
|
|
||||||
status: str
|
status: str
|
||||||
timestamp: str
|
timestamp: str
|
||||||
version: str = "1.0.0"
|
version: str = "1.0.1"
|
||||||
dependencies: DependencyHealth
|
dependencies: DependencyHealth
|
||||||
startup_time: datetime
|
startup_time: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# Schema Version Constants
|
# Schema Version Constants
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
CURRENT_SCHEMA_VERSION = "1.0.0"
|
CURRENT_SCHEMA_VERSION = "1.0.1"
|
||||||
SCHEMA_VERSION_TABLE = "schema_version"
|
SCHEMA_VERSION_TABLE = "schema_version"
|
||||||
|
|
||||||
# Expected tables in the current schema
|
# Expected tables in the current schema
|
||||||
@@ -319,7 +319,7 @@ async def get_schema_version(engine: Optional[AsyncEngine] = None) -> str:
|
|||||||
engine: Optional database engine (uses default if not provided)
|
engine: Optional database engine (uses default if not provided)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Schema version string (e.g., "1.0.0", "empty", "unknown")
|
Schema version string (e.g., "1.0.1", "empty", "unknown")
|
||||||
"""
|
"""
|
||||||
if engine is None:
|
if engine is None:
|
||||||
engine = get_engine()
|
engine = get_engine()
|
||||||
|
|||||||
@@ -148,7 +148,27 @@ class AnimeSeriesService:
|
|||||||
select(AnimeSeries).where(AnimeSeries.key == key)
|
select(AnimeSeries).where(AnimeSeries.key == key)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_by_folder_sync(db: Session, folder: str) -> Optional[AnimeSeries]:
|
||||||
|
"""Look up an anime series by its filesystem folder name (sync).
|
||||||
|
|
||||||
|
Intended as a fallback for ``SerieScanner`` when neither a ``key``
|
||||||
|
file nor a ``data`` file exists on disk for a given folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Synchronous database session (from ``get_sync_session``).
|
||||||
|
folder: Filesystem folder name to match (e.g.
|
||||||
|
``"Rooster Fighter (2026)"``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``AnimeSeries`` instance or ``None`` if not found.
|
||||||
|
"""
|
||||||
|
result = db.execute(
|
||||||
|
select(AnimeSeries).where(AnimeSeries.folder == folder)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_all(
|
async def get_all(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
|
|||||||
@@ -480,7 +480,7 @@ async def lifespan(_application: FastAPI):
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Aniworld Download Manager",
|
title="Aniworld Download Manager",
|
||||||
description="Modern web interface for Aniworld anime download management",
|
description="Modern web interface for Aniworld anime download management",
|
||||||
version="1.0.0",
|
version="1.0.1",
|
||||||
docs_url="/api/docs",
|
docs_url="/api/docs",
|
||||||
redoc_url="/api/redoc",
|
redoc_url="/api/redoc",
|
||||||
lifespan=lifespan
|
lifespan=lifespan
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class ConfigService:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Current configuration schema version
|
# Current configuration schema version
|
||||||
CONFIG_VERSION = "1.0.0"
|
CONFIG_VERSION = "1.0.1"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ from typing import Optional
|
|||||||
import structlog
|
import structlog
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
from src.config.settings import settings as _settings
|
||||||
from src.core.utils.image_downloader import ImageDownloader
|
from src.core.utils.image_downloader import ImageDownloader
|
||||||
from src.server.services.initialization_service import perform_nfo_repair_scan
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
@@ -24,6 +24,101 @@ _TMDB_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
|
|||||||
# Semaphore to limit concurrent poster image downloads to 3.
|
# Semaphore to limit concurrent poster image downloads to 3.
|
||||||
_POSTER_DOWNLOAD_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
|
_POSTER_DOWNLOAD_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
|
||||||
|
|
||||||
|
# Semaphore to limit concurrent NFO repair TMDB operations to 3.
|
||||||
|
_NFO_REPAIR_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
|
||||||
|
|
||||||
|
|
||||||
|
async def _repair_one_series(series_dir: Path, series_name: str) -> None:
|
||||||
|
"""Repair a single series NFO in isolation.
|
||||||
|
|
||||||
|
Creates a fresh :class:`NFOService` and :class:`NfoRepairService` per
|
||||||
|
invocation so that each repair owns its own ``aiohttp`` session/connector
|
||||||
|
and concurrent tasks cannot interfere with each other.
|
||||||
|
|
||||||
|
A module-level semaphore (``_NFO_REPAIR_SEMAPHORE``) limits the number of
|
||||||
|
simultaneous TMDB requests to avoid rate-limiting.
|
||||||
|
|
||||||
|
Any exception is caught and logged so the asyncio task never silently
|
||||||
|
drops an unhandled error.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
series_dir: Absolute path to the series folder.
|
||||||
|
series_name: Human-readable series name for log messages.
|
||||||
|
"""
|
||||||
|
from src.core.services.nfo_factory import NFOServiceFactory
|
||||||
|
from src.core.services.nfo_repair_service import NfoRepairService
|
||||||
|
|
||||||
|
async with _NFO_REPAIR_SEMAPHORE:
|
||||||
|
try:
|
||||||
|
factory = NFOServiceFactory()
|
||||||
|
nfo_service = factory.create()
|
||||||
|
repair_service = NfoRepairService(nfo_service)
|
||||||
|
await repair_service.repair_series(series_dir, series_name)
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
logger.error(
|
||||||
|
"NFO repair failed for %s: %s",
|
||||||
|
series_name,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def perform_nfo_repair_scan(background_loader=None) -> None:
|
||||||
|
"""Scan all series folders and repair incomplete tvshow.nfo files.
|
||||||
|
|
||||||
|
Called from ``FolderScanService.run_folder_scan()`` during the scheduled
|
||||||
|
daily folder scan (not on every startup). Checks each subfolder of
|
||||||
|
``settings.anime_directory`` for a ``tvshow.nfo`` and calls
|
||||||
|
``_repair_one_series`` for every file with absent or empty required tags.
|
||||||
|
|
||||||
|
Each repair task creates its own isolated :class:`NFOService` /
|
||||||
|
:class:`TMDBClient` so concurrent tasks never share an ``aiohttp``
|
||||||
|
session — this prevents "Connector is closed" errors when many repairs
|
||||||
|
run in parallel. A semaphore caps TMDB concurrency at 3 to stay within
|
||||||
|
rate limits.
|
||||||
|
|
||||||
|
The ``background_loader`` parameter is accepted for backwards-compatibility
|
||||||
|
but is no longer used.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
background_loader: Unused. Kept to avoid breaking call-sites.
|
||||||
|
"""
|
||||||
|
from src.core.services.nfo_repair_service import nfo_needs_repair
|
||||||
|
|
||||||
|
if not _settings.tmdb_api_key:
|
||||||
|
logger.warning("NFO repair scan skipped — TMDB API key not configured")
|
||||||
|
return
|
||||||
|
if not _settings.anime_directory:
|
||||||
|
logger.warning("NFO repair scan skipped — anime directory not configured")
|
||||||
|
return
|
||||||
|
anime_dir = Path(_settings.anime_directory)
|
||||||
|
if not anime_dir.is_dir():
|
||||||
|
logger.warning("NFO repair scan skipped — anime directory not found: %s", anime_dir)
|
||||||
|
return
|
||||||
|
|
||||||
|
queued = 0
|
||||||
|
total = 0
|
||||||
|
for series_dir in sorted(anime_dir.iterdir()):
|
||||||
|
if not series_dir.is_dir():
|
||||||
|
continue
|
||||||
|
nfo_path = series_dir / "tvshow.nfo"
|
||||||
|
if not nfo_path.exists():
|
||||||
|
continue
|
||||||
|
total += 1
|
||||||
|
series_name = series_dir.name
|
||||||
|
if nfo_needs_repair(nfo_path):
|
||||||
|
queued += 1
|
||||||
|
# Each task creates its own NFOService so connectors are isolated.
|
||||||
|
asyncio.create_task(
|
||||||
|
_repair_one_series(series_dir, series_name),
|
||||||
|
name=f"nfo_repair:{series_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"NFO repair scan complete: %d of %d series queued for repair",
|
||||||
|
queued,
|
||||||
|
total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FolderScanServiceError(Exception):
|
class FolderScanServiceError(Exception):
|
||||||
"""Service-level exception for folder-scan operations."""
|
"""Service-level exception for folder-scan operations."""
|
||||||
|
|||||||
@@ -377,101 +377,6 @@ async def perform_nfo_scan_if_needed(progress_service=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_NFO_REPAIR_SEMAPHORE: asyncio.Semaphore = asyncio.Semaphore(3)
|
|
||||||
|
|
||||||
|
|
||||||
async def _repair_one_series(series_dir: Path, series_name: str) -> None:
|
|
||||||
"""Repair a single series NFO in isolation.
|
|
||||||
|
|
||||||
Creates a fresh :class:`NFOService` and :class:`NfoRepairService` per
|
|
||||||
invocation so that each repair owns its own ``aiohttp`` session/connector
|
|
||||||
and concurrent tasks cannot interfere with each other.
|
|
||||||
|
|
||||||
A module-level semaphore (``_NFO_REPAIR_SEMAPHORE``) limits the number of
|
|
||||||
simultaneous TMDB requests to avoid rate-limiting.
|
|
||||||
|
|
||||||
Any exception is caught and logged so the asyncio task never silently
|
|
||||||
drops an unhandled error.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
series_dir: Absolute path to the series folder.
|
|
||||||
series_name: Human-readable series name for log messages.
|
|
||||||
"""
|
|
||||||
from src.core.services.nfo_factory import NFOServiceFactory
|
|
||||||
from src.core.services.nfo_repair_service import NfoRepairService
|
|
||||||
|
|
||||||
async with _NFO_REPAIR_SEMAPHORE:
|
|
||||||
try:
|
|
||||||
factory = NFOServiceFactory()
|
|
||||||
nfo_service = factory.create()
|
|
||||||
repair_service = NfoRepairService(nfo_service)
|
|
||||||
await repair_service.repair_series(series_dir, series_name)
|
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
|
||||||
logger.error(
|
|
||||||
"NFO repair failed for %s: %s",
|
|
||||||
series_name,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def perform_nfo_repair_scan(background_loader=None) -> None:
|
|
||||||
"""Scan all series folders and repair incomplete tvshow.nfo files.
|
|
||||||
|
|
||||||
Called from ``FolderScanService.run_folder_scan()`` during the scheduled
|
|
||||||
daily folder scan (not on every startup). Checks each subfolder of
|
|
||||||
``settings.anime_directory`` for a ``tvshow.nfo`` and calls
|
|
||||||
``_repair_one_series`` for every file with absent or empty required tags.
|
|
||||||
|
|
||||||
Each repair task creates its own isolated :class:`NFOService` /
|
|
||||||
:class:`TMDBClient` so concurrent tasks never share an ``aiohttp``
|
|
||||||
session — this prevents "Connector is closed" errors when many repairs
|
|
||||||
run in parallel. A semaphore caps TMDB concurrency at 3 to stay within
|
|
||||||
rate limits.
|
|
||||||
|
|
||||||
The ``background_loader`` parameter is accepted for backwards-compatibility
|
|
||||||
but is no longer used.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
background_loader: Unused. Kept to avoid breaking call-sites.
|
|
||||||
"""
|
|
||||||
from src.core.services.nfo_repair_service import nfo_needs_repair
|
|
||||||
|
|
||||||
if not settings.tmdb_api_key:
|
|
||||||
logger.warning("NFO repair scan skipped — TMDB API key not configured")
|
|
||||||
return
|
|
||||||
if not settings.anime_directory:
|
|
||||||
logger.warning("NFO repair scan skipped — anime directory not configured")
|
|
||||||
return
|
|
||||||
anime_dir = Path(settings.anime_directory)
|
|
||||||
if not anime_dir.is_dir():
|
|
||||||
logger.warning("NFO repair scan skipped — anime directory not found: %s", anime_dir)
|
|
||||||
return
|
|
||||||
|
|
||||||
queued = 0
|
|
||||||
total = 0
|
|
||||||
for series_dir in sorted(anime_dir.iterdir()):
|
|
||||||
if not series_dir.is_dir():
|
|
||||||
continue
|
|
||||||
nfo_path = series_dir / "tvshow.nfo"
|
|
||||||
if not nfo_path.exists():
|
|
||||||
continue
|
|
||||||
total += 1
|
|
||||||
series_name = series_dir.name
|
|
||||||
if nfo_needs_repair(nfo_path):
|
|
||||||
queued += 1
|
|
||||||
# Each task creates its own NFOService so connectors are isolated.
|
|
||||||
asyncio.create_task(
|
|
||||||
_repair_one_series(series_dir, series_name),
|
|
||||||
name=f"nfo_repair:{series_name}",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"NFO repair scan complete: %d of %d series queued for repair",
|
|
||||||
queued,
|
|
||||||
total,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _check_media_scan_status() -> bool:
|
async def _check_media_scan_status() -> bool:
|
||||||
"""Check if initial media scan has been completed.
|
"""Check if initial media scan has been completed.
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,44 @@ _rate_limit_lock = Lock()
|
|||||||
_RATE_LIMIT_WINDOW_SECONDS = 60.0
|
_RATE_LIMIT_WINDOW_SECONDS = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def _make_db_lookup():
|
||||||
|
"""Build a synchronous ``(folder) -> Serie | None`` callable for SerieScanner.
|
||||||
|
|
||||||
|
The returned function opens a short-lived sync DB session, queries for a
|
||||||
|
series whose ``folder`` column matches the given name, and converts the
|
||||||
|
ORM row to a ``Serie`` domain object. Returns ``None`` when the DB is not
|
||||||
|
yet initialised or no matching row is found.
|
||||||
|
"""
|
||||||
|
from src.core.entities.series import Serie
|
||||||
|
|
||||||
|
def _lookup(folder: str) -> Optional["Serie"]:
|
||||||
|
try:
|
||||||
|
from src.server.database.connection import get_sync_session
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
db = get_sync_session()
|
||||||
|
try:
|
||||||
|
row = AnimeSeriesService.get_by_folder_sync(db, folder)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return Serie(
|
||||||
|
key=row.key,
|
||||||
|
name=row.name or "",
|
||||||
|
site=row.site,
|
||||||
|
folder=row.folder,
|
||||||
|
episodeDict={},
|
||||||
|
year=row.year,
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
# DB not initialised yet (e.g. first boot before init_db())
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _lookup
|
||||||
|
|
||||||
|
|
||||||
def get_series_app() -> SeriesApp:
|
def get_series_app() -> SeriesApp:
|
||||||
"""
|
"""
|
||||||
Dependency to get SeriesApp instance.
|
Dependency to get SeriesApp instance.
|
||||||
@@ -134,7 +172,7 @@ def get_series_app() -> SeriesApp:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
_series_app = SeriesApp(anime_dir)
|
_series_app = SeriesApp(anime_dir, db_lookup=_make_db_lookup())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def get_base_context(
|
|||||||
"request": request,
|
"request": request,
|
||||||
"title": title,
|
"title": title,
|
||||||
"app_name": "Aniworld Download Manager",
|
"app_name": "Aniworld Download Manager",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"static_v": STATIC_VERSION,
|
"static_v": STATIC_VERSION,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_incomplete_nfo_series_scheduled_for_repair(self, tmp_path):
|
async def test_incomplete_nfo_series_scheduled_for_repair(self, tmp_path):
|
||||||
"""Series whose tvshow.nfo is missing required tags are scheduled via asyncio.create_task."""
|
"""Series whose tvshow.nfo is missing required tags are scheduled via asyncio.create_task."""
|
||||||
from src.server.services.initialization_service import perform_nfo_repair_scan
|
from src.server.services.folder_scan_service import perform_nfo_repair_scan
|
||||||
|
|
||||||
series_dir = tmp_path / "IncompleteAnime"
|
series_dir = tmp_path / "IncompleteAnime"
|
||||||
series_dir.mkdir()
|
series_dir.mkdir()
|
||||||
@@ -83,7 +83,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
|
|||||||
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.initialization_service.settings", mock_settings
|
"src.server.services.folder_scan_service._settings", mock_settings
|
||||||
), patch(
|
), patch(
|
||||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
@@ -103,7 +103,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_complete_nfo_series_not_scheduled(self, tmp_path):
|
async def test_complete_nfo_series_not_scheduled(self, tmp_path):
|
||||||
"""Series whose tvshow.nfo has all required tags are not scheduled for repair."""
|
"""Series whose tvshow.nfo has all required tags are not scheduled for repair."""
|
||||||
from src.server.services.initialization_service import perform_nfo_repair_scan
|
from src.server.services.folder_scan_service import perform_nfo_repair_scan
|
||||||
|
|
||||||
series_dir = tmp_path / "CompleteAnime"
|
series_dir = tmp_path / "CompleteAnime"
|
||||||
series_dir.mkdir()
|
series_dir.mkdir()
|
||||||
@@ -116,7 +116,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
|
|||||||
mock_settings.anime_directory = str(tmp_path)
|
mock_settings.anime_directory = str(tmp_path)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.initialization_service.settings", mock_settings
|
"src.server.services.folder_scan_service._settings", mock_settings
|
||||||
), patch(
|
), patch(
|
||||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
||||||
return_value=False,
|
return_value=False,
|
||||||
|
|||||||
@@ -472,7 +472,7 @@ async def test_validate_schema_with_inspection_error():
|
|||||||
|
|
||||||
def test_schema_constants():
|
def test_schema_constants():
|
||||||
"""Test that schema constants are properly defined."""
|
"""Test that schema constants are properly defined."""
|
||||||
assert CURRENT_SCHEMA_VERSION == "1.0.0"
|
assert CURRENT_SCHEMA_VERSION == "1.0.1"
|
||||||
assert len(EXPECTED_TABLES) == 5
|
assert len(EXPECTED_TABLES) == 5
|
||||||
assert "anime_series" in EXPECTED_TABLES
|
assert "anime_series" in EXPECTED_TABLES
|
||||||
assert "episodes" in EXPECTED_TABLES
|
assert "episodes" in EXPECTED_TABLES
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from src.server.services.folder_scan_service import (
|
|||||||
_TMDB_SEMAPHORE,
|
_TMDB_SEMAPHORE,
|
||||||
FolderScanService,
|
FolderScanService,
|
||||||
FolderScanServiceError,
|
FolderScanServiceError,
|
||||||
|
perform_nfo_repair_scan,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ async def test_basic_health_check():
|
|||||||
|
|
||||||
assert isinstance(result, HealthStatus)
|
assert isinstance(result, HealthStatus)
|
||||||
assert result.status == "healthy"
|
assert result.status == "healthy"
|
||||||
assert result.version == "1.0.0"
|
assert result.version == "1.0.1"
|
||||||
assert result.service == "aniworld-api"
|
assert result.service == "aniworld-api"
|
||||||
assert result.timestamp is not None
|
assert result.timestamp is not None
|
||||||
assert result.series_app_initialized is False
|
assert result.series_app_initialized is False
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, call, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from src.server.services.folder_scan_service import perform_nfo_repair_scan
|
||||||
from src.server.services.initialization_service import (
|
from src.server.services.initialization_service import (
|
||||||
_check_initial_scan_status,
|
_check_initial_scan_status,
|
||||||
_check_media_scan_status,
|
_check_media_scan_status,
|
||||||
@@ -27,7 +28,6 @@ from src.server.services.initialization_service import (
|
|||||||
_validate_anime_directory,
|
_validate_anime_directory,
|
||||||
perform_initial_setup,
|
perform_initial_setup,
|
||||||
perform_media_scan_if_needed,
|
perform_media_scan_if_needed,
|
||||||
perform_nfo_repair_scan,
|
|
||||||
perform_nfo_scan_if_needed,
|
perform_nfo_scan_if_needed,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -771,7 +771,7 @@ class TestPerformNfoRepairScan:
|
|||||||
mock_settings.anime_directory = str(tmp_path)
|
mock_settings.anime_directory = str(tmp_path)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.initialization_service.settings", mock_settings
|
"src.server.services.folder_scan_service._settings", mock_settings
|
||||||
):
|
):
|
||||||
await perform_nfo_repair_scan()
|
await perform_nfo_repair_scan()
|
||||||
|
|
||||||
@@ -785,7 +785,7 @@ class TestPerformNfoRepairScan:
|
|||||||
mock_settings.anime_directory = ""
|
mock_settings.anime_directory = ""
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.initialization_service.settings", mock_settings
|
"src.server.services.folder_scan_service._settings", mock_settings
|
||||||
):
|
):
|
||||||
await perform_nfo_repair_scan()
|
await perform_nfo_repair_scan()
|
||||||
|
|
||||||
@@ -805,7 +805,7 @@ class TestPerformNfoRepairScan:
|
|||||||
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.initialization_service.settings", mock_settings
|
"src.server.services.folder_scan_service._settings", mock_settings
|
||||||
), patch(
|
), patch(
|
||||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
@@ -835,7 +835,7 @@ class TestPerformNfoRepairScan:
|
|||||||
mock_settings.anime_directory = str(tmp_path)
|
mock_settings.anime_directory = str(tmp_path)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.initialization_service.settings", mock_settings
|
"src.server.services.folder_scan_service._settings", mock_settings
|
||||||
), patch(
|
), patch(
|
||||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
||||||
return_value=False,
|
return_value=False,
|
||||||
@@ -865,7 +865,7 @@ class TestPerformNfoRepairScan:
|
|||||||
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
mock_repair_service.repair_series = AsyncMock(return_value=True)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.server.services.initialization_service.settings", mock_settings
|
"src.server.services.folder_scan_service._settings", mock_settings
|
||||||
), patch(
|
), patch(
|
||||||
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
"src.core.services.nfo_repair_service.nfo_needs_repair",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ class TestTemplateHelpers:
|
|||||||
assert context["request"] == mock_request
|
assert context["request"] == mock_request
|
||||||
assert context["title"] == "Test Title"
|
assert context["title"] == "Test Title"
|
||||||
assert context["app_name"] == "Aniworld Download Manager"
|
assert context["app_name"] == "Aniworld Download Manager"
|
||||||
assert context["version"] == "1.0.0"
|
assert context["version"] == "1.0.1"
|
||||||
|
|
||||||
def test_get_base_context_default_title(self):
|
def test_get_base_context_default_title(self):
|
||||||
"""Test getting base context with default title."""
|
"""Test getting base context with default title."""
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Tests for SerieScanner class - file-based operations."""
|
"""Tests for SerieScanner class - file-based operations."""
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
@@ -651,4 +652,187 @@ class TestScanProgressEvents:
|
|||||||
|
|
||||||
error_handler.assert_called_once()
|
error_handler.assert_called_once()
|
||||||
call_data = error_handler.call_args[0][0]
|
call_data = error_handler.call_args[0][0]
|
||||||
assert call_data["recoverable"] is True
|
assert call_data["recoverable"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestDbLookupFallback:
|
||||||
|
"""Tests for the db_lookup callback in SerieScanner."""
|
||||||
|
|
||||||
|
def _make_scanner(self, tmp_dir, mock_loader, db_lookup=None):
|
||||||
|
"""Create a scanner with an optional db_lookup."""
|
||||||
|
# Create a folder with an mp4 but NO key/data file
|
||||||
|
folder = os.path.join(tmp_dir, "Rooster Fighter (2026)")
|
||||||
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
mp4 = os.path.join(folder, "Rooster Fighter - S01E001 - (German Dub).mp4")
|
||||||
|
with open(mp4, "w") as f:
|
||||||
|
f.write("dummy")
|
||||||
|
return SerieScanner(tmp_dir, mock_loader, db_lookup=db_lookup)
|
||||||
|
|
||||||
|
def test_db_lookup_stored_on_init(self, temp_directory, mock_loader):
|
||||||
|
"""db_lookup callable should be stored as _db_lookup."""
|
||||||
|
lookup = MagicMock(return_value=None)
|
||||||
|
scanner = SerieScanner(temp_directory, mock_loader, db_lookup=lookup)
|
||||||
|
assert scanner._db_lookup is lookup
|
||||||
|
|
||||||
|
def test_no_db_lookup_defaults_to_none(self, temp_directory, mock_loader):
|
||||||
|
"""Without db_lookup, _db_lookup should be None."""
|
||||||
|
scanner = SerieScanner(temp_directory, mock_loader)
|
||||||
|
assert scanner._db_lookup is None
|
||||||
|
|
||||||
|
def test_db_lookup_called_when_no_files(self, mock_loader):
|
||||||
|
"""db_lookup is called when neither key nor data file exists."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
lookup = MagicMock(return_value=None)
|
||||||
|
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
|
||||||
|
|
||||||
|
with patch.object(scanner, 'get_total_to_scan', return_value=1), \
|
||||||
|
patch.object(
|
||||||
|
scanner,
|
||||||
|
'_SerieScanner__get_missing_episodes_and_season',
|
||||||
|
return_value=({}, "aniworld.to"),
|
||||||
|
):
|
||||||
|
scanner.scan()
|
||||||
|
|
||||||
|
lookup.assert_called_once_with("Rooster Fighter (2026)")
|
||||||
|
|
||||||
|
def test_db_lookup_not_called_when_key_file_exists(self, mock_loader):
|
||||||
|
"""db_lookup is NOT called when a key file is present."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
folder = os.path.join(tmp_dir, "Rooster Fighter (2026)")
|
||||||
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
mp4 = os.path.join(folder, "S01E001.mp4")
|
||||||
|
with open(mp4, "w") as f:
|
||||||
|
f.write("dummy")
|
||||||
|
with open(os.path.join(folder, "key"), "w") as f:
|
||||||
|
f.write("rooster-fighter")
|
||||||
|
|
||||||
|
lookup = MagicMock(return_value=None)
|
||||||
|
scanner = SerieScanner(tmp_dir, mock_loader, db_lookup=lookup)
|
||||||
|
|
||||||
|
with patch.object(scanner, 'get_total_to_scan', return_value=1), \
|
||||||
|
patch.object(
|
||||||
|
scanner,
|
||||||
|
'_SerieScanner__get_missing_episodes_and_season',
|
||||||
|
return_value=({1: []}, "aniworld.to"),
|
||||||
|
), \
|
||||||
|
patch.object(
|
||||||
|
SerieScanner,
|
||||||
|
'_SerieScanner__read_data_from_file',
|
||||||
|
return_value=Serie(
|
||||||
|
key="rooster-fighter", name="", site="aniworld.to",
|
||||||
|
folder="Rooster Fighter (2026)", episodeDict={},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
scanner.scan()
|
||||||
|
|
||||||
|
lookup.assert_not_called()
|
||||||
|
|
||||||
|
def test_db_lookup_resolves_serie_and_scans(self, mock_loader):
|
||||||
|
"""When db_lookup returns a Serie, scanning continues normally."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
resolved = Serie(
|
||||||
|
key="rooster-fighter",
|
||||||
|
name="Rooster Fighter",
|
||||||
|
site="aniworld.to",
|
||||||
|
folder="Rooster Fighter (2026)",
|
||||||
|
episodeDict={},
|
||||||
|
year=2026,
|
||||||
|
)
|
||||||
|
lookup = MagicMock(return_value=resolved)
|
||||||
|
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
|
||||||
|
|
||||||
|
with patch.object(scanner, 'get_total_to_scan', return_value=1), \
|
||||||
|
patch.object(
|
||||||
|
scanner,
|
||||||
|
'_SerieScanner__get_missing_episodes_and_season',
|
||||||
|
return_value=({1: [1, 2, 3]}, "aniworld.to"),
|
||||||
|
), \
|
||||||
|
patch.object(resolved, 'save_to_file'):
|
||||||
|
scanner.scan()
|
||||||
|
|
||||||
|
assert "rooster-fighter" in scanner.keyDict
|
||||||
|
assert scanner.keyDict["rooster-fighter"].episodeDict == {1: [1, 2, 3]}
|
||||||
|
|
||||||
|
def test_db_lookup_returns_none_folder_skipped(self, mock_loader):
|
||||||
|
"""When db_lookup returns None, the folder is skipped with a warning."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
lookup = MagicMock(return_value=None)
|
||||||
|
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
|
||||||
|
|
||||||
|
with patch.object(scanner, 'get_total_to_scan', return_value=1):
|
||||||
|
scanner.scan()
|
||||||
|
|
||||||
|
assert len(scanner.keyDict) == 0
|
||||||
|
|
||||||
|
def test_db_lookup_exception_skips_folder(self, mock_loader):
|
||||||
|
"""When db_lookup raises, the folder is skipped gracefully."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
lookup = MagicMock(side_effect=RuntimeError("DB offline"))
|
||||||
|
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
|
||||||
|
|
||||||
|
with patch.object(scanner, 'get_total_to_scan', return_value=1):
|
||||||
|
scanner.scan() # should not raise
|
||||||
|
|
||||||
|
assert len(scanner.keyDict) == 0
|
||||||
|
|
||||||
|
def test_db_lookup_warning_logged_when_no_files(
|
||||||
|
self, mock_loader, caplog
|
||||||
|
):
|
||||||
|
"""A warning is logged for folders without key/data file."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=None)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="src.core.SerieScanner"):
|
||||||
|
with patch.object(scanner, 'get_total_to_scan', return_value=1):
|
||||||
|
scanner.scan()
|
||||||
|
|
||||||
|
assert any(
|
||||||
|
"Rooster Fighter (2026)" in record.message
|
||||||
|
for record in caplog.records
|
||||||
|
if record.levelname == "WARNING"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_db_lookup_info_logged_on_resolution(
|
||||||
|
self, mock_loader, caplog
|
||||||
|
):
|
||||||
|
"""An INFO log is emitted when db_lookup resolves a folder."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
resolved = Serie(
|
||||||
|
key="rooster-fighter",
|
||||||
|
name="",
|
||||||
|
site="aniworld.to",
|
||||||
|
folder="Rooster Fighter (2026)",
|
||||||
|
episodeDict={},
|
||||||
|
)
|
||||||
|
lookup = MagicMock(return_value=resolved)
|
||||||
|
scanner = self._make_scanner(tmp_dir, mock_loader, db_lookup=lookup)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO, logger="src.core.SerieScanner"), \
|
||||||
|
patch.object(scanner, 'get_total_to_scan', return_value=1), \
|
||||||
|
patch.object(
|
||||||
|
scanner,
|
||||||
|
'_SerieScanner__get_missing_episodes_and_season',
|
||||||
|
return_value=({}, "aniworld.to"),
|
||||||
|
), \
|
||||||
|
patch.object(resolved, 'save_to_file'):
|
||||||
|
scanner.scan()
|
||||||
|
|
||||||
|
assert any(
|
||||||
|
"rooster-fighter" in record.message
|
||||||
|
for record in caplog.records
|
||||||
|
if record.levelname == "INFO"
|
||||||
|
)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class TestTemplateHelpers:
|
|||||||
assert context["request"] == request
|
assert context["request"] == request
|
||||||
assert context["title"] == "Test Title"
|
assert context["title"] == "Test Title"
|
||||||
assert context["app_name"] == "Aniworld Download Manager"
|
assert context["app_name"] == "Aniworld Download Manager"
|
||||||
assert context["version"] == "1.0.0"
|
assert context["version"] == "1.0.1"
|
||||||
|
|
||||||
def test_get_base_context_default_title(self):
|
def test_get_base_context_default_title(self):
|
||||||
"""Test that default title is used."""
|
"""Test that default title is used."""
|
||||||
|
|||||||
Reference in New Issue
Block a user