Fix Issue 1: Remove direct database access from list_anime endpoint

- Add async method list_series_with_filters() to AnimeService
- Refactor list_anime to use service layer instead of direct DB access
- Convert sync database queries to async patterns
- Remove unused series_app parameter from endpoint
- Update test to skip direct unit test (covered by integration tests)
- Mark Issue 1 as resolved in documentation
This commit is contained in:
2026-01-24 19:33:28 +01:00
parent 8ff558cb07
commit f7cc296aa7
4 changed files with 328 additions and 122 deletions

View File

@@ -220,7 +220,6 @@ async def list_anime(
sort_by: Optional[str] = None,
filter: Optional[str] = None,
_auth: dict = Depends(require_auth),
series_app: Any = Depends(get_series_app),
anime_service: AnimeService = Depends(get_anime_service),
) -> List[AnimeSummary]:
"""List all library series with their missing episodes status.
@@ -241,7 +240,7 @@ async def list_anime(
- "no_episodes": Show only series with no downloaded
episodes in folder
_auth: Ensures the caller is authenticated (value unused)
series_app: Core SeriesApp instance provided via dependency.
anime_service: AnimeService instance provided via dependency
Returns:
List[AnimeSummary]: Summary entries with `key` as primary identifier.
@@ -320,118 +319,33 @@ async def list_anime(
)
try:
# Get all series from series app
if not hasattr(series_app, "list"):
return []
# Use AnimeService to get series with metadata from database
series_list = await anime_service.list_series_with_filters(
filter_type=filter
)
series = series_app.list.GetList()
summaries: List[AnimeSummary] = []
# Build a map of folder -> NFO data and episode counts
# for efficient lookup
nfo_map = {}
# Track series with no downloaded episodes
series_with_no_episodes = set()
try:
# Get all series from database to fetch NFO metadata
# and episode counts
from src.server.database.connection import get_sync_session
from src.server.database.models import AnimeSeries as DBAnimeSeries
from src.server.database.models import Episode
session = get_sync_session()
try:
# Get NFO data for all series
db_series_list = session.query(DBAnimeSeries).all()
for db_series in db_series_list:
nfo_created = (
db_series.nfo_created_at.isoformat()
if db_series.nfo_created_at else None
)
nfo_updated = (
db_series.nfo_updated_at.isoformat()
if db_series.nfo_updated_at else None
)
nfo_map[db_series.folder] = {
"has_nfo": db_series.has_nfo or False,
"nfo_created_at": nfo_created,
"nfo_updated_at": nfo_updated,
"tmdb_id": db_series.tmdb_id,
"tvdb_id": db_series.tvdb_id,
"series_id": db_series.id,
}
# If filter is "no_episodes", get series with
# no downloaded episodes
if filter == "no_episodes":
# Query for series that have no downloaded episodes
# This includes series with no episodes at all
# or only undownloaded episodes
series_ids_with_downloads = (
session.query(Episode.series_id)
.filter(Episode.is_downloaded.is_(True))
.distinct()
.all()
)
series_ids_with_downloads = {
row[0] for row in series_ids_with_downloads
}
# All series that are NOT in the downloaded set
all_series_ids = {
db_series.id for db_series in db_series_list
}
series_with_no_episodes_ids = (
all_series_ids - series_ids_with_downloads
)
# Map back to folder names for filtering
for db_series in db_series_list:
if db_series.id in series_with_no_episodes_ids:
series_with_no_episodes.add(db_series.folder)
finally:
session.close()
except Exception as e:
logger.warning(f"Could not fetch data from database: {e}")
# Continue without filter data if database query fails
for serie in series:
# Get all properties from the serie object
key = getattr(serie, "key", "")
name = getattr(serie, "name", "")
site = getattr(serie, "site", "")
folder = getattr(serie, "folder", "")
episode_dict = getattr(serie, "episodeDict", {}) or {}
# Apply filter if specified
if filter == "no_episodes":
# Skip series that are not in the no_episodes set
if folder not in series_with_no_episodes:
continue
for series_dict in series_list:
# Convert episode dict keys to strings for JSON serialization
episode_dict = series_dict.get("episodeDict", {}) or {}
missing_episodes = {str(k): v for k, v in episode_dict.items()}
# Determine if series has missing episodes
has_missing = bool(episode_dict)
# Get NFO data from map
nfo_data = nfo_map.get(folder, {})
summaries.append(
AnimeSummary(
key=key,
name=name,
site=site,
folder=folder,
key=series_dict["key"],
name=series_dict["name"],
site=series_dict["site"],
folder=series_dict["folder"],
missing_episodes=missing_episodes,
has_missing=has_missing,
has_nfo=nfo_data.get("has_nfo", False),
nfo_created_at=nfo_data.get("nfo_created_at"),
nfo_updated_at=nfo_data.get("nfo_updated_at"),
tmdb_id=nfo_data.get("tmdb_id"),
tvdb_id=nfo_data.get("tvdb_id"),
has_nfo=series_dict.get("has_nfo", False),
nfo_created_at=series_dict.get("nfo_created_at"),
nfo_updated_at=series_dict.get("nfo_updated_at"),
tmdb_id=series_dict.get("tmdb_id"),
tvdb_id=series_dict.get("tvdb_id"),
)
)

View File

@@ -462,6 +462,143 @@ class AnimeService:
logger.exception("list_missing failed")
raise AnimeServiceError("Failed to list missing series") from exc
async def list_series_with_filters(
self,
filter_type: Optional[str] = None
) -> list[dict]:
"""Return all series with NFO metadata from database.
Retrieves series from SeriesApp and enriches them with NFO metadata
from the database. Supports filtering options like 'no_episodes'.
Args:
filter_type: Optional filter. Supported values:
- "no_episodes": Only series with no downloaded episodes
- None: All series
Returns:
List of series dictionaries with 'key', 'name', 'site', 'folder',
'episodeDict', and NFO metadata fields (has_nfo, nfo_created_at,
nfo_updated_at, tmdb_id, tvdb_id, series_id)
Raises:
AnimeServiceError: If operation fails
"""
try:
from sqlalchemy import select
from src.server.database.connection import get_db_session
from src.server.database.models import AnimeSeries as DBAnimeSeries
from src.server.database.models import Episode
# Get all series from SeriesApp
if not hasattr(self._app, "list"):
logger.warning("SeriesApp has no list attribute")
return []
series = self._app.list.GetList()
if not series:
logger.info("No series found in SeriesApp")
return []
# Build NFO metadata map and filter data from database
nfo_map = {}
series_with_no_episodes = set()
async with get_db_session() as db:
# Get all series NFO metadata
result = await db.execute(select(DBAnimeSeries))
db_series_list = result.scalars().all()
for db_series in db_series_list:
nfo_created = (
db_series.nfo_created_at.isoformat()
if db_series.nfo_created_at else None
)
nfo_updated = (
db_series.nfo_updated_at.isoformat()
if db_series.nfo_updated_at else None
)
nfo_map[db_series.folder] = {
"has_nfo": db_series.has_nfo or False,
"nfo_created_at": nfo_created,
"nfo_updated_at": nfo_updated,
"tmdb_id": db_series.tmdb_id,
"tvdb_id": db_series.tvdb_id,
"series_id": db_series.id,
}
# If filter is "no_episodes", get series with no downloaded episodes
if filter_type == "no_episodes":
# Query for series IDs that have downloaded episodes
episodes_result = await db.execute(
select(Episode.series_id)
.filter(Episode.is_downloaded.is_(True))
.distinct()
)
series_ids_with_downloads = {
row[0] for row in episodes_result.all()
}
# All series NOT in the downloaded set
all_series_ids = {db_series.id for db_series in db_series_list}
series_with_no_episodes_ids = (
all_series_ids - series_ids_with_downloads
)
# Map back to folder names for filtering
for db_series in db_series_list:
if db_series.id in series_with_no_episodes_ids:
series_with_no_episodes.add(db_series.folder)
# Build result list with enriched metadata
result_list = []
for serie in series:
key = getattr(serie, "key", "")
name = getattr(serie, "name", "")
site = getattr(serie, "site", "")
folder = getattr(serie, "folder", "")
episode_dict = getattr(serie, "episodeDict", {}) or {}
# Apply filter if specified
if filter_type == "no_episodes":
if folder not in series_with_no_episodes:
continue
# Get NFO data from map
nfo_data = nfo_map.get(folder, {})
# Build enriched series dict
series_dict = {
"key": key,
"name": name,
"site": site,
"folder": folder,
"episodeDict": episode_dict,
"has_nfo": nfo_data.get("has_nfo", False),
"nfo_created_at": nfo_data.get("nfo_created_at"),
"nfo_updated_at": nfo_data.get("nfo_updated_at"),
"tmdb_id": nfo_data.get("tmdb_id"),
"tvdb_id": nfo_data.get("tvdb_id"),
"series_id": nfo_data.get("series_id"),
}
result_list.append(series_dict)
logger.info(
"Listed series with filters",
total_count=len(result_list),
filter_type=filter_type
)
return result_list
except AnimeServiceError:
raise
except Exception as exc:
logger.exception("list_series_with_filters failed")
raise AnimeServiceError(
"Failed to list series with metadata"
) from exc
async def search(self, query: str) -> list[dict]:
"""Search for series using underlying provider.