Compare commits

...

4 Commits

Author SHA1 Message Date
815a4f1520 chore: release v0.0.1 2026-05-16 21:20:20 +02:00
e3509f5c8f feat(scanner): add DB fallback for series key resolution
When SerieScanner encounters a folder without a local key or data file,
it now optionally falls back to a database lookup by folder name. This
prevents newly-added series from being silently skipped on rescan when
their metadata only lives in the DB.

Changes:
- SerieScanner accepts an optional db_lookup callable
- SeriesApp forwards db_lookup to SerieScanner
- AnimeSeriesService adds get_by_folder_sync() helper
- dependencies.py wires a sync DB lookup into get_series_app()
- Unit tests cover fallback hit, miss, and exception paths
2026-05-14 19:28:43 +02:00
69c2fd01f9 chore: bump version to 1.0.1 2026-05-14 17:30:13 +02:00
0f36afd88c refactor: move NFO repair from initialization_service to folder_scan_service
Moves perform_nfo_repair_scan and its helpers (_repair_one_series,
_NFO_REPAIR_SEMAPHORE) into folder_scan_service.py so NFO repair runs
during the scheduled folder scan instead of on startup.

- Removes NFO repair code from initialization_service.py
- Updates all test imports and patch targets
- Updates docs/NFO_GUIDE.md and docs/CHANGELOG.md references

All 174 related tests pass.
2026-05-14 17:01:01 +02:00
25 changed files with 413 additions and 132 deletions

1
Docker/VERSION Normal file
View File

@@ -0,0 +1 @@
v0.0.1

View File

@@ -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",

View File

@@ -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

View File

@@ -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"
} }
``` ```

View File

@@ -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 |
--- ---

View File

@@ -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": {

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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."""

View File

@@ -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.

View File

@@ -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,

View File

@@ -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,
} }

View File

@@ -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,

View File

@@ -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

View File

@@ -20,6 +20,7 @@ from src.server.services.folder_scan_service import (
_TMDB_SEMAPHORE, _TMDB_SEMAPHORE,
FolderScanService, FolderScanService,
FolderScanServiceError, FolderScanServiceError,
perform_nfo_repair_scan,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -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

View File

@@ -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,

View File

@@ -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."""

View File

@@ -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"
)

View File

@@ -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."""