refactor: remove database access from core layer

- Remove db_session parameter from SeriesApp, SerieList, SerieScanner
- Move all database operations to AnimeService (service layer)
- Add add_series_to_db, contains_in_db methods to AnimeService
- Update sync_series_from_data_files to use inline DB operations
- Remove obsolete test classes for removed DB methods
- Fix pylint issues: add broad-except comments, fix line lengths
- Core layer (src/core/) now has zero database imports

722 unit tests pass
This commit is contained in:
2025-12-15 15:19:03 +01:00
parent 27108aacda
commit 596476f9ac
12 changed files with 877 additions and 1651 deletions

View File

@@ -2,7 +2,7 @@ import logging
import warnings
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession

View File

@@ -142,7 +142,7 @@ class AnimeService:
),
loop
)
except Exception as exc:
except Exception as exc: # pylint: disable=broad-except
logger.error(
"Error handling download status event",
error=str(exc)
@@ -221,7 +221,7 @@ class AnimeService:
),
loop
)
except Exception as exc:
except Exception as exc: # pylint: disable=broad-except
logger.error("Error handling scan status event: %s", exc)
@lru_cache(maxsize=128)
@@ -288,6 +288,8 @@ class AnimeService:
The SeriesApp handles progress tracking via events which are
forwarded to the ProgressService through event handlers.
After scanning, results are persisted to the database.
All series are identified by their 'key' (provider identifier),
with 'folder' stored as metadata.
"""
@@ -295,19 +297,268 @@ class AnimeService:
# Store event loop for event handlers
self._event_loop = asyncio.get_running_loop()
# SeriesApp.rescan is now async and handles events internally
await self._app.rescan()
# SeriesApp.rescan returns scanned series list
scanned_series = await self._app.rescan()
# Persist scan results to database
if scanned_series:
await self._save_scan_results_to_db(scanned_series)
# Reload series from database to ensure consistency
await self._load_series_from_db()
# invalidate cache
try:
self._cached_list_missing.cache_clear()
except Exception:
except Exception: # pylint: disable=broad-except
pass
except Exception as exc:
except Exception as exc: # pylint: disable=broad-except
logger.exception("rescan failed")
raise AnimeServiceError("Rescan failed") from exc
async def _save_scan_results_to_db(self, series_list: list) -> int:
"""
Save scan results to the database.
Creates or updates series records in the database based on
scan results.
Args:
series_list: List of Serie objects from scan
Returns:
Number of series saved/updated
"""
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
saved_count = 0
async with get_db_session() as db:
for serie in series_list:
try:
# Check if series already exists
existing = await AnimeSeriesService.get_by_key(
db, serie.key
)
if existing:
# Update existing series
await self._update_series_in_db(
serie, existing, db
)
else:
# Create new series
await self._create_series_in_db(serie, db)
saved_count += 1
except Exception as e: # pylint: disable=broad-except
logger.warning(
"Failed to save series to database: %s (key=%s) - %s",
serie.name,
serie.key,
str(e)
)
logger.info(
"Saved %d series to database from scan results",
saved_count
)
return saved_count
async def _create_series_in_db(self, serie, db) -> None:
"""Create a new series in the database."""
from src.server.database.service import (
AnimeSeriesService, EpisodeService
)
anime_series = await AnimeSeriesService.create(
db=db,
key=serie.key,
name=serie.name,
site=serie.site,
folder=serie.folder,
)
# Create Episode records
if serie.episodeDict:
for season, episode_numbers in serie.episodeDict.items():
for ep_num in episode_numbers:
await EpisodeService.create(
db=db,
series_id=anime_series.id,
season=season,
episode_number=ep_num,
)
logger.debug(
"Created series in database: %s (key=%s)",
serie.name,
serie.key
)
async def _update_series_in_db(self, serie, existing, db) -> None:
"""Update an existing series in the database."""
from src.server.database.service import (
AnimeSeriesService, EpisodeService
)
# Get existing episodes
existing_episodes = await EpisodeService.get_by_series(db, existing.id)
existing_dict: dict[int, list[int]] = {}
for ep in existing_episodes:
if ep.season not in existing_dict:
existing_dict[ep.season] = []
existing_dict[ep.season].append(ep.episode_number)
for season in existing_dict:
existing_dict[season].sort()
# Update episodes if changed
if existing_dict != serie.episodeDict:
new_dict = serie.episodeDict or {}
for season, episode_numbers in new_dict.items():
existing_eps = set(existing_dict.get(season, []))
for ep_num in episode_numbers:
if ep_num not in existing_eps:
await EpisodeService.create(
db=db,
series_id=existing.id,
season=season,
episode_number=ep_num,
)
# Update folder if changed
if existing.folder != serie.folder:
await AnimeSeriesService.update(
db,
existing.id,
folder=serie.folder
)
logger.debug(
"Updated series in database: %s (key=%s)",
serie.name,
serie.key
)
async def _load_series_from_db(self) -> None:
"""
Load series from the database into SeriesApp.
This method is called during initialization and after rescans
to ensure the in-memory series list is in sync with the database.
"""
from src.core.entities.series import Serie
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService
async with get_db_session() as db:
anime_series_list = await AnimeSeriesService.get_all(
db, with_episodes=True
)
# Convert to Serie objects
series_list = []
for anime_series in anime_series_list:
# Build episode_dict from episodes relationship
episode_dict: dict[int, list[int]] = {}
if anime_series.episodes:
for episode in anime_series.episodes:
season = episode.season
if season not in episode_dict:
episode_dict[season] = []
episode_dict[season].append(episode.episode_number)
# Sort episode numbers
for season in episode_dict:
episode_dict[season].sort()
serie = Serie(
key=anime_series.key,
name=anime_series.name,
site=anime_series.site,
folder=anime_series.folder,
episodeDict=episode_dict
)
series_list.append(serie)
# Load into SeriesApp
self._app.load_series_from_list(series_list)
async def add_series_to_db(
self,
serie,
db
):
"""
Add a series to the database if it doesn't already exist.
Uses serie.key for identification. Creates a new AnimeSeries
record in the database if it doesn't already exist.
Args:
serie: The Serie instance to add
db: Database session for async operations
Returns:
Created AnimeSeries instance, or None if already exists
"""
from src.server.database.service import AnimeSeriesService, EpisodeService
# Check if series already exists in DB
existing = await AnimeSeriesService.get_by_key(db, serie.key)
if existing:
logger.debug(
"Series already exists in database: %s (key=%s)",
serie.name,
serie.key
)
return None
# Create new series in database
anime_series = await AnimeSeriesService.create(
db=db,
key=serie.key,
name=serie.name,
site=serie.site,
folder=serie.folder,
)
# Create Episode records for each episode in episodeDict
if serie.episodeDict:
for season, episode_numbers in serie.episodeDict.items():
for episode_number in episode_numbers:
await EpisodeService.create(
db=db,
series_id=anime_series.id,
season=season,
episode_number=episode_number,
)
logger.info(
"Added series to database: %s (key=%s)",
serie.name,
serie.key
)
return anime_series
async def contains_in_db(self, key: str, db) -> bool:
"""
Check if a series with the given key exists in the database.
Args:
key: The unique provider identifier for the series
db: Database session for async operations
Returns:
True if the series exists in the database
"""
from src.server.database.service import AnimeSeriesService
existing = await AnimeSeriesService.get_by_key(db, key)
return existing is not None
async def download(
self,
serie_folder: str,
@@ -365,7 +616,7 @@ def get_anime_service(series_app: SeriesApp) -> AnimeService:
async def sync_series_from_data_files(
anime_directory: str,
logger=None
log_instance=None
) -> int:
"""
Sync series from data files to the database.
@@ -379,17 +630,17 @@ async def sync_series_from_data_files(
Args:
anime_directory: Path to the anime directory with data files
logger: Optional logger instance for logging operations.
log_instance: Optional logger instance for logging operations.
If not provided, uses structlog.
Returns:
Number of new series added to the database
"""
log = logger or structlog.get_logger(__name__)
log = log_instance or structlog.get_logger(__name__)
try:
from src.core.entities.SerieList import SerieList
from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService, EpisodeService
log.info(
"Starting data file to database sync",
@@ -412,11 +663,6 @@ async def sync_series_from_data_files(
)
async with get_db_session() as db:
serie_list = SerieList(
anime_directory,
db_session=db,
skip_load=True
)
added_count = 0
skipped_count = 0
for serie in all_series:
@@ -438,15 +684,43 @@ async def sync_series_from_data_files(
continue
try:
result = await serie_list.add_to_db(serie, db)
if result:
added_count += 1
# Check if series already exists in DB
existing = await AnimeSeriesService.get_by_key(db, serie.key)
if existing:
log.debug(
"Added series to database",
"Series already exists in database",
name=serie.name,
key=serie.key
)
except Exception as e:
continue
# Create new series in database
anime_series = await AnimeSeriesService.create(
db=db,
key=serie.key,
name=serie.name,
site=serie.site,
folder=serie.folder,
)
# Create Episode records for each episode in episodeDict
if serie.episodeDict:
for season, episode_numbers in serie.episodeDict.items():
for episode_number in episode_numbers:
await EpisodeService.create(
db=db,
series_id=anime_series.id,
season=season,
episode_number=episode_number,
)
added_count += 1
log.debug(
"Added series to database",
name=serie.name,
key=serie.key
)
except Exception as e: # pylint: disable=broad-except
log.warning(
"Failed to add series to database",
key=serie.key,
@@ -462,7 +736,7 @@ async def sync_series_from_data_files(
)
return added_count
except Exception as e:
except Exception as e: # pylint: disable=broad-except
log.warning(
"Failed to sync series to database",
error=str(e),

View File

@@ -169,43 +169,6 @@ async def get_optional_database_session() -> AsyncGenerator:
yield None
async def get_series_app_with_db(
db: AsyncSession = Depends(get_optional_database_session),
) -> SeriesApp:
"""
Dependency to get SeriesApp instance with database support.
This creates or returns a SeriesApp instance and injects the
database session for database-backed storage.
Args:
db: Optional database session from dependency injection
Returns:
SeriesApp: The main application instance with database support
Raises:
HTTPException: If SeriesApp is not initialized or anime directory
is not configured
Example:
@app.post("/api/anime/scan")
async def scan_anime(
series_app: SeriesApp = Depends(get_series_app_with_db)
):
# series_app has db_session configured
await series_app.serie_scanner.scan_async()
"""
# Get the base SeriesApp
app = get_series_app()
# Inject database session if available
if db:
app.set_db_session(db)
return app
def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(
http_bearer_security