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",
"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",
"timestamp": "2025-12-13T10:30:00.000Z",
"version": "1.0.0",
"version": "1.0.1",
"dependencies": {
"database": {
"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
`NfoRepairService.repair_series()`. 13 required tags are checked.
- **`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
required tags using `nfo_needs_repair()`, and queues the series for background
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$...",
"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 |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `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` | Calls `perform_nfo_repair_scan` during the scheduled daily folder scan |
| `src/server/services/folder_scan_service.py` | `perform_nfo_repair_scan` — invoked during the scheduled daily folder scan |
---

View File

@@ -1,6 +1,6 @@
{
"name": "aniworld-web",
"version": "1.0.0",
"version": "0.0.1",
"description": "Aniworld Anime Download Manager - Web Frontend",
"type": "module",
"scripts": {

View File

@@ -15,7 +15,7 @@ import os
import re
import traceback
import uuid
from typing import Iterable, Iterator, Optional
from typing import Callable, Iterable, Iterator, Optional
from events import Events
@@ -43,12 +43,17 @@ class SerieScanner:
scanner = SerieScanner("/path/to/anime", loader)
scanner.scan()
# 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__(
self,
basePath: str,
loader: Loader,
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
) -> None:
"""
Initialize the SerieScanner.
@@ -56,8 +61,12 @@ class SerieScanner:
Args:
basePath: Base directory containing anime series
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:
ValueError: If basePath is invalid or doesn't exist
"""
@@ -75,6 +84,7 @@ class SerieScanner:
self.directory: str = abs_path
self.keyDict: dict[str, Serie] = {}
self.loader: Loader = loader
self._db_lookup: Optional[Callable[[str], Optional[Serie]]] = db_lookup
self._current_operation_id: Optional[str] = None
self.events = Events()
@@ -268,6 +278,30 @@ class SerieScanner:
)
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 (
serie is not None
and serie.key

View File

@@ -14,7 +14,7 @@ import asyncio
import logging
import os
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional
from events import Events
@@ -143,12 +143,16 @@ class SeriesApp:
def __init__(
self,
directory_to_search: str,
db_lookup: Optional[Callable[[str], Optional["Serie"]]] = None,
):
"""
Initialize SeriesApp.
Args:
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
@@ -162,7 +166,7 @@ class SeriesApp:
self.loaders = Loaders()
self.loader = self.loaders.GetLoader(key="aniworld.to")
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
# from database by the service layer during application setup

View File

@@ -22,7 +22,7 @@ class HealthStatus(BaseModel):
status: str
timestamp: str
version: str = "1.0.0"
version: str = "1.0.1"
service: str = "aniworld-api"
series_app_initialized: bool = False
anime_directory_configured: bool = False
@@ -60,7 +60,7 @@ class DetailedHealthStatus(BaseModel):
status: str
timestamp: str
version: str = "1.0.0"
version: str = "1.0.1"
dependencies: DependencyHealth
startup_time: datetime

View File

@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
# Schema Version Constants
# =============================================================================
CURRENT_SCHEMA_VERSION = "1.0.0"
CURRENT_SCHEMA_VERSION = "1.0.1"
SCHEMA_VERSION_TABLE = "schema_version"
# 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)
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:
engine = get_engine()

View File

@@ -148,7 +148,27 @@ class AnimeSeriesService:
select(AnimeSeries).where(AnimeSeries.key == key)
)
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
async def get_all(
db: AsyncSession,

View File

@@ -480,7 +480,7 @@ async def lifespan(_application: FastAPI):
app = FastAPI(
title="Aniworld Download Manager",
description="Modern web interface for Aniworld anime download management",
version="1.0.0",
version="1.0.1",
docs_url="/api/docs",
redoc_url="/api/redoc",
lifespan=lifespan

View File

@@ -44,7 +44,7 @@ class ConfigService:
"""
# Current configuration schema version
CONFIG_VERSION = "1.0.0"
CONFIG_VERSION = "1.0.1"
def __init__(
self,

View File

@@ -13,8 +13,8 @@ from typing import Optional
import structlog
from lxml import etree
from src.config.settings import settings as _settings
from src.core.utils.image_downloader import ImageDownloader
from src.server.services.initialization_service import perform_nfo_repair_scan
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.
_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):
"""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:
"""Check if initial media scan has been completed.

View File

@@ -57,6 +57,44 @@ _rate_limit_lock = Lock()
_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:
"""
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:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,

View File

@@ -48,7 +48,7 @@ def get_base_context(
"request": request,
"title": title,
"app_name": "Aniworld Download Manager",
"version": "1.0.0",
"version": "1.0.1",
"static_v": STATIC_VERSION,
}

View File

@@ -67,7 +67,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
@pytest.mark.asyncio
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."""
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.mkdir()
@@ -83,7 +83,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
mock_repair_service.repair_series = AsyncMock(return_value=True)
with patch(
"src.server.services.initialization_service.settings", mock_settings
"src.server.services.folder_scan_service._settings", mock_settings
), patch(
"src.core.services.nfo_repair_service.nfo_needs_repair",
return_value=True,
@@ -103,7 +103,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
@pytest.mark.asyncio
async def test_complete_nfo_series_not_scheduled(self, tmp_path):
"""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.mkdir()
@@ -116,7 +116,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
mock_settings.anime_directory = str(tmp_path)
with patch(
"src.server.services.initialization_service.settings", mock_settings
"src.server.services.folder_scan_service._settings", mock_settings
), patch(
"src.core.services.nfo_repair_service.nfo_needs_repair",
return_value=False,

View File

@@ -472,7 +472,7 @@ async def test_validate_schema_with_inspection_error():
def test_schema_constants():
"""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 "anime_series" in EXPECTED_TABLES
assert "episodes" in EXPECTED_TABLES

View File

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

View File

@@ -25,7 +25,7 @@ async def test_basic_health_check():
assert isinstance(result, HealthStatus)
assert result.status == "healthy"
assert result.version == "1.0.0"
assert result.version == "1.0.1"
assert result.service == "aniworld-api"
assert result.timestamp is not None
assert result.series_app_initialized is False

View File

@@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
from src.server.services.folder_scan_service import perform_nfo_repair_scan
from src.server.services.initialization_service import (
_check_initial_scan_status,
_check_media_scan_status,
@@ -27,7 +28,6 @@ from src.server.services.initialization_service import (
_validate_anime_directory,
perform_initial_setup,
perform_media_scan_if_needed,
perform_nfo_repair_scan,
perform_nfo_scan_if_needed,
)
@@ -771,7 +771,7 @@ class TestPerformNfoRepairScan:
mock_settings.anime_directory = str(tmp_path)
with patch(
"src.server.services.initialization_service.settings", mock_settings
"src.server.services.folder_scan_service._settings", mock_settings
):
await perform_nfo_repair_scan()
@@ -785,7 +785,7 @@ class TestPerformNfoRepairScan:
mock_settings.anime_directory = ""
with patch(
"src.server.services.initialization_service.settings", mock_settings
"src.server.services.folder_scan_service._settings", mock_settings
):
await perform_nfo_repair_scan()
@@ -805,7 +805,7 @@ class TestPerformNfoRepairScan:
mock_repair_service.repair_series = AsyncMock(return_value=True)
with patch(
"src.server.services.initialization_service.settings", mock_settings
"src.server.services.folder_scan_service._settings", mock_settings
), patch(
"src.core.services.nfo_repair_service.nfo_needs_repair",
return_value=True,
@@ -835,7 +835,7 @@ class TestPerformNfoRepairScan:
mock_settings.anime_directory = str(tmp_path)
with patch(
"src.server.services.initialization_service.settings", mock_settings
"src.server.services.folder_scan_service._settings", mock_settings
), patch(
"src.core.services.nfo_repair_service.nfo_needs_repair",
return_value=False,
@@ -865,7 +865,7 @@ class TestPerformNfoRepairScan:
mock_repair_service.repair_series = AsyncMock(return_value=True)
with patch(
"src.server.services.initialization_service.settings", mock_settings
"src.server.services.folder_scan_service._settings", mock_settings
), patch(
"src.core.services.nfo_repair_service.nfo_needs_repair",
return_value=True,

View File

@@ -187,7 +187,7 @@ class TestTemplateHelpers:
assert context["request"] == mock_request
assert context["title"] == "Test Title"
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):
"""Test getting base context with default title."""

View File

@@ -1,5 +1,6 @@
"""Tests for SerieScanner class - file-based operations."""
import logging
import os
import tempfile
from unittest.mock import MagicMock, patch
@@ -651,4 +652,187 @@ class TestScanProgressEvents:
error_handler.assert_called_once()
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["title"] == "Test Title"
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):
"""Test that default title is used."""