Fix Issue 7: Enforce repository pattern consistency

- Added 5 new service methods for complete database coverage:
  * get_series_without_nfo()
  * count_all()
  * count_with_nfo()
  * count_with_tmdb_id()
  * count_with_tvdb_id()

- Eliminated all direct database queries from business logic:
  * series_manager_service.py - now uses AnimeSeriesService
  * anime_service.py - now uses service layer methods

- Documented architecture decision in ARCHITECTURE.md:
  * Service layer IS the repository layer
  * No direct SQLAlchemy queries allowed outside service layer

- All database access must go through service methods
- 1449 tests passing, repository pattern enforced
This commit is contained in:
2026-01-24 21:20:17 +01:00
parent 35a7aeac9e
commit ed3882991f
5 changed files with 237 additions and 130 deletions

View File

@@ -297,6 +297,111 @@ class AnimeSeriesService:
result = await db.execute(query)
return list(result.scalars().all())
@staticmethod
async def get_series_without_nfo(
db: AsyncSession,
limit: Optional[int] = None,
offset: int = 0,
) -> List[AnimeSeries]:
"""Get anime series without NFO files.
Returns series where has_nfo is False.
Args:
db: Database session
limit: Optional limit for results
offset: Offset for pagination
Returns:
List of AnimeSeries without NFO files
"""
query = (
select(AnimeSeries)
.where(AnimeSeries.has_nfo == False) # noqa: E712
.order_by(AnimeSeries.name)
.offset(offset)
)
if limit:
query = query.limit(limit)
result = await db.execute(query)
return list(result.scalars().all())
@staticmethod
async def count_all(db: AsyncSession) -> int:
"""Count total number of anime series.
Args:
db: Database session
Returns:
Total count of series
"""
from sqlalchemy import func
result = await db.execute(
select(func.count()).select_from(AnimeSeries)
)
return result.scalar() or 0
@staticmethod
async def count_with_nfo(db: AsyncSession) -> int:
"""Count anime series with NFO files.
Args:
db: Database session
Returns:
Count of series with has_nfo=True
"""
from sqlalchemy import func
result = await db.execute(
select(func.count())
.select_from(AnimeSeries)
.where(AnimeSeries.has_nfo == True) # noqa: E712
)
return result.scalar() or 0
@staticmethod
async def count_with_tmdb_id(db: AsyncSession) -> int:
"""Count anime series with TMDB ID.
Args:
db: Database session
Returns:
Count of series with tmdb_id set
"""
from sqlalchemy import func
result = await db.execute(
select(func.count())
.select_from(AnimeSeries)
.where(AnimeSeries.tmdb_id.isnot(None))
)
return result.scalar() or 0
@staticmethod
async def count_with_tvdb_id(db: AsyncSession) -> int:
"""Count anime series with TVDB ID.
Args:
db: Database session
Returns:
Count of series with tvdb_id set
"""
from sqlalchemy import func
result = await db.execute(
select(func.count())
.select_from(AnimeSeries)
.where(AnimeSeries.tvdb_id.isnot(None))
)
return result.scalar() or 0
# ============================================================================

View File

@@ -485,11 +485,8 @@ class AnimeService:
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
from src.server.database.service import AnimeSeriesService
# Get all series from SeriesApp
if not hasattr(self._app, "list"):
@@ -506,9 +503,8 @@ class AnimeService:
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()
# Get all series NFO metadata using service layer
db_series_list = await AnimeSeriesService.get_all(db)
for db_series in db_series_list:
nfo_created = (
@@ -528,28 +524,18 @@ class AnimeService:
"series_id": db_series.id,
}
# If filter is "no_episodes", get series with no downloaded episodes
# 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()
# Use service method to get series with
# undownloaded episodes
series_no_downloads = (
await AnimeSeriesService
.get_series_with_no_episodes(db)
)
series_ids_with_downloads = {
row[0] for row in episodes_result.all()
series_with_no_episodes = {
s.folder for s in series_no_downloads
}
# 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 = []
@@ -1113,22 +1099,15 @@ class AnimeService:
Raises:
AnimeServiceError: If update fails
"""
from datetime import datetime, timezone
from sqlalchemy import select
from src.server.database.connection import get_db_session
from src.server.database.models import AnimeSeries
from src.server.database.service import AnimeSeriesService
try:
# Get or create database session
if db is None:
async with get_db_session() as db:
# Find series by key
result = await db.execute(
select(AnimeSeries).filter(AnimeSeries.key == key)
)
series = result.scalars().first()
# Find series by key using service layer
series = await AnimeSeriesService.get_by_key(db, key)
if not series:
logger.warning(
@@ -1137,21 +1116,23 @@ class AnimeService:
)
return
# Update NFO fields
# Prepare update fields
now = datetime.now(timezone.utc)
series.has_nfo = has_nfo
update_fields = {"has_nfo": has_nfo}
if has_nfo:
if series.nfo_created_at is None:
series.nfo_created_at = now
series.nfo_updated_at = now
update_fields["nfo_created_at"] = now
update_fields["nfo_updated_at"] = now
if tmdb_id is not None:
series.tmdb_id = tmdb_id
update_fields["tmdb_id"] = tmdb_id
if tvdb_id is not None:
series.tvdb_id = tvdb_id
update_fields["tvdb_id"] = tvdb_id
# Use service layer for update
await AnimeSeriesService.update(db, series.id, **update_fields)
await db.commit()
logger.info(
"Updated NFO status in database",
@@ -1162,10 +1143,7 @@ class AnimeService:
)
else:
# Use provided session
result = await db.execute(
select(AnimeSeries).filter(AnimeSeries.key == key)
)
series = result.scalars().first()
series = await AnimeSeriesService.get_by_key(db, key)
if not series:
logger.warning(
@@ -1174,9 +1152,9 @@ class AnimeService:
)
return
# Update NFO fields
# Prepare update fields
now = datetime.now(timezone.utc)
series.has_nfo = has_nfo
update_fields = {"has_nfo": has_nfo}
if has_nfo:
if series.nfo_created_at is None:
@@ -1227,17 +1205,14 @@ class AnimeService:
from sqlalchemy import select
from src.server.database.connection import get_db_session
from src.server.database.models import AnimeSeries
from src.server.database.service import AnimeSeriesService
try:
# Get or create database session
if db is None:
async with get_db_session() as db:
# Query series without NFO
result_obj = await db.execute(
select(AnimeSeries).filter(AnimeSeries.has_nfo == False) # noqa: E712
)
series_list = result_obj.scalars().all()
# Query series without NFO using service layer
series_list = await AnimeSeriesService.get_series_without_nfo(db)
result = []
for series in series_list:
@@ -1259,10 +1234,7 @@ class AnimeService:
return result
else:
# Use provided session
result_obj = await db.execute(
select(AnimeSeries).filter(AnimeSeries.has_nfo == False) # noqa: E712
)
series_list = result_obj.scalars().all()
series_list = await AnimeSeriesService.get_series_without_nfo(db)
result = []
for series in series_list:
@@ -1309,33 +1281,17 @@ class AnimeService:
from sqlalchemy import func, select
from src.server.database.connection import get_db_session
from src.server.database.models import AnimeSeries
from src.server.database.service import AnimeSeriesService
try:
# Get or create database session
if db is None:
async with get_db_session() as db:
# Count total series
total_result = await db.execute(select(func.count()).select_from(AnimeSeries))
total = total_result.scalar()
# Count series with NFO
with_nfo_result = await db.execute(
select(func.count()).select_from(AnimeSeries).filter(AnimeSeries.has_nfo == True) # noqa: E712
)
with_nfo = with_nfo_result.scalar()
# Count series with TMDB ID
with_tmdb_result = await db.execute(
select(func.count()).select_from(AnimeSeries).filter(AnimeSeries.tmdb_id.isnot(None))
)
with_tmdb = with_tmdb_result.scalar()
# Count series with TVDB ID
with_tvdb_result = await db.execute(
select(func.count()).select_from(AnimeSeries).filter(AnimeSeries.tvdb_id.isnot(None))
)
with_tvdb = with_tvdb_result.scalar()
# Use service layer count methods
total = await AnimeSeriesService.count_all(db)
with_nfo = await AnimeSeriesService.count_with_nfo(db)
with_tmdb = await AnimeSeriesService.count_with_tmdb_id(db)
with_tvdb = await AnimeSeriesService.count_with_tvdb_id(db)
stats = {
"total": total,
@@ -1348,22 +1304,11 @@ class AnimeService:
logger.info("Retrieved NFO statistics", **stats)
return stats
else:
# Use provided session
# Count total series
total_result = await db.execute(select(func.count()).select_from(AnimeSeries))
total = total_result.scalar()
# Count series with NFO
with_nfo_result = await db.execute(
select(func.count()).select_from(AnimeSeries).filter(AnimeSeries.has_nfo == True) # noqa: E712
)
with_nfo = with_nfo_result.scalar()
# Count series with TMDB ID
with_tmdb_result = await db.execute(
select(func.count()).select_from(AnimeSeries).filter(AnimeSeries.tmdb_id.isnot(None))
)
with_tmdb = with_tmdb_result.scalar()
# Use provided session and service layer count methods
total = await AnimeSeriesService.count_all(db)
with_nfo = await AnimeSeriesService.count_with_nfo(db)
with_tmdb = await AnimeSeriesService.count_with_tmdb_id(db)
with_tvdb = await AnimeSeriesService.count_with_tvdb_id(db)
# Count series with TVDB ID
with_tvdb_result = await db.execute(