Add filter for series with no downloaded episodes

- Added get_series_with_no_episodes() method to AnimeSeriesService
- Updated list_anime endpoint to support filter='no_episodes' parameter
- Added comprehensive unit tests for the new filtering functionality
- All tests passing successfully
This commit is contained in:
2026-01-23 18:55:04 +01:00
parent 2b904fd01e
commit c7bf232fe1
4 changed files with 977 additions and 661 deletions

View File

@@ -119,7 +119,18 @@ For each task completed:
## TODO List: ## TODO List:
1. fix issue: **COMPLETED (1):** Fixed greenlet_spawn async lazy-loading error - Added selectinload for episode relationship in DownloadQueueService.get_all()
**COMPLETED (2):** Fixed anime add endpoint 500 error - Added explicit commit/rollback in database session dependencies
**COMPLETED (3):** Added database transactions - All database operations properly use session context managers with automatic commit/rollback
**TODO (4):** Create series filter to filter all series with no episodes found in folder
---
### ~~1. fix issue (COMPLETED):~~
2026-01-23 18:28:39 [info ] DownloadService initialized max*retries=3 2026-01-23 18:28:39 [info ] DownloadService initialized max*retries=3
INFO: QueueRepository initialized INFO: QueueRepository initialized
ERROR: Failed to get all items: greenlet_spawn has not been called; can't call await_only() here. Was IO attempted in an unexpected place? (Background on this error at: https://sqlalche.me/e/20/xd2s) ERROR: Failed to get all items: greenlet_spawn has not been called; can't call await_only() here. Was IO attempted in an unexpected place? (Background on this error at: https://sqlalche.me/e/20/xd2s)
@@ -838,7 +849,8 @@ ERROR: Exception in ASGI application
Traceback (most recent call last): Traceback (most recent call last):
File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/anyio/streams/memory.py", line 98, in receive File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/anyio/streams/memory.py", line 98, in receive
return self.receive_nowait() return self.receive_nowait()
~~~~~~~~~~~~~~~~~~~^^
```^^
File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/anyio/streams/memory.py", line 93, in receive_nowait File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/anyio/streams/memory.py", line 93, in receive_nowait
raise WouldBlock raise WouldBlock
anyio.WouldBlock anyio.WouldBlock
@@ -916,3 +928,8 @@ RuntimeError: generator didn't stop after athrow()
3. transactions 3. transactions
go throw code and add transactions. so that application stops the db is not curropted go throw code and add transactions. so that application stops the db is not curropted
4. filter
make a series filter to filter all series with no episodes found in folder
```

View File

@@ -237,7 +237,9 @@ async def list_anime(
per_page: Items per page (must be positive, max 1000) per_page: Items per page (must be positive, max 1000)
sort_by: Optional sorting parameter. Allowed: title, id, name, sort_by: Optional sorting parameter. Allowed: title, id, name,
missing_episodes missing_episodes
filter: Optional filter parameter (validated for security) filter: Optional filter parameter. Allowed values:
- "no_episodes": Show only series with no downloaded
episodes in folder
_auth: Ensures the caller is authenticated (value unused) _auth: Ensures the caller is authenticated (value unused)
series_app: Core SeriesApp instance provided via dependency. series_app: Core SeriesApp instance provided via dependency.
@@ -309,6 +311,14 @@ async def list_anime(
message="Invalid filter parameter" message="Invalid filter parameter"
) )
# Validate allowed filter values
allowed_filters = ["no_episodes"]
if filter not in allowed_filters:
allowed = ", ".join(allowed_filters)
raise ValidationError(
message=f"Invalid filter value. Allowed: {allowed}"
)
try: try:
# Get all series from series app # Get all series from series app
if not hasattr(series_app, "list"): if not hasattr(series_app, "list"):
@@ -317,15 +327,24 @@ async def list_anime(
series = series_app.list.GetList() series = series_app.list.GetList()
summaries: List[AnimeSummary] = [] summaries: List[AnimeSummary] = []
# Build a map of folder -> NFO data for efficient lookup # Build a map of folder -> NFO data and episode counts
# for efficient lookup
nfo_map = {} nfo_map = {}
# Track series with no downloaded episodes
series_with_no_episodes = set()
try: try:
# Get all series from database to fetch NFO metadata # 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.connection import get_sync_session
from src.server.database.models import AnimeSeries as DBAnimeSeries from src.server.database.models import (
AnimeSeries as DBAnimeSeries,
Episode
)
session = get_sync_session() session = get_sync_session()
try: try:
# Get NFO data for all series
db_series_list = session.query(DBAnimeSeries).all() db_series_list = session.query(DBAnimeSeries).all()
for db_series in db_series_list: for db_series in db_series_list:
nfo_created = ( nfo_created = (
@@ -342,12 +361,42 @@ async def list_anime(
"nfo_updated_at": nfo_updated, "nfo_updated_at": nfo_updated,
"tmdb_id": db_series.tmdb_id, "tmdb_id": db_series.tmdb_id,
"tvdb_id": db_series.tvdb_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: finally:
session.close() session.close()
except Exception as e: except Exception as e:
logger.warning(f"Could not fetch NFO data from database: {e}") logger.warning(f"Could not fetch data from database: {e}")
# Continue without NFO data if database query fails # Continue without filter data if database query fails
for serie in series: for serie in series:
# Get all properties from the serie object # Get all properties from the serie object
@@ -357,6 +406,12 @@ async def list_anime(
folder = getattr(serie, "folder", "") folder = getattr(serie, "folder", "")
episode_dict = getattr(serie, "episodeDict", {}) or {} 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
# Convert episode dict keys to strings for JSON serialization # Convert episode dict keys to strings for JSON serialization
missing_episodes = {str(k): v for k, v in episode_dict.items()} missing_episodes = {str(k): v for k, v in episode_dict.items()}

View File

@@ -252,6 +252,59 @@ class AnimeSeriesService:
) )
return list(result.scalars().all()) return list(result.scalars().all())
@staticmethod
async def get_series_with_no_episodes(
db: AsyncSession,
limit: Optional[int] = None,
offset: int = 0,
) -> List[AnimeSeries]:
"""Get anime series that have no downloaded episodes in folder.
Returns series where either:
- No episodes exist in the database, OR
- All episodes have is_downloaded=False
Args:
db: Database session
limit: Optional limit for results
offset: Offset for pagination
Returns:
List of AnimeSeries instances with no downloaded episodes
"""
from sqlalchemy import func, or_
# Subquery to count downloaded episodes per series
downloaded_count = (
select(
Episode.series_id,
func.count(Episode.id).label('downloaded_count')
)
.where(Episode.is_downloaded.is_(True))
.group_by(Episode.series_id)
.subquery()
)
# Select series that either have no episodes or no downloaded episodes
query = (
select(AnimeSeries)
.outerjoin(downloaded_count, AnimeSeries.id == downloaded_count.c.series_id)
.where(
or_(
downloaded_count.c.downloaded_count == None,
downloaded_count.c.downloaded_count == 0
)
)
.order_by(AnimeSeries.name)
.offset(offset)
)
if limit:
query = query.limit(limit)
result = await db.execute(query)
return list(result.scalars().all())
# ============================================================================ # ============================================================================
# Episode Service # Episode Service

View File

@@ -0,0 +1,191 @@
"""Tests for series filtering functionality."""
import pytest
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from src.server.database.models import Base
from src.server.database.service import AnimeSeriesService, EpisodeService
@pytest.fixture
async def db_engine():
"""Create test database engine."""
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
echo=False,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def session_factory(db_engine):
"""Create session factory for testing."""
return async_sessionmaker(
db_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
autocommit=False,
)
@pytest.fixture
async def async_session(session_factory):
"""Create database session for testing."""
async with session_factory() as session:
yield session
await session.rollback()
@pytest.mark.asyncio
async def test_get_series_with_no_episodes_empty_database(
async_session: AsyncSession
):
"""Test that empty database returns empty list."""
result = await AnimeSeriesService.get_series_with_no_episodes(
async_session
)
assert result == []
@pytest.mark.asyncio
async def test_get_series_with_no_episodes_no_downloaded_episodes(
async_session: AsyncSession
):
"""Test that series with no downloaded episodes are returned."""
# Create a series with no episodes
series1 = await AnimeSeriesService.create(
async_session,
key="test-series-1",
name="Test Series 1",
folder="Test Series 1 (2024)",
site="https://example.com/test1",
)
# Create a series with undownloaded episodes
series2 = await AnimeSeriesService.create(
async_session,
key="test-series-2",
name="Test Series 2",
folder="Test Series 2 (2024)",
site="https://example.com/test2",
)
await EpisodeService.create(
async_session,
series_id=series2.id,
season=1,
episode_number=1,
is_downloaded=False,
)
# Create a series with downloaded episodes (should not be in result)
series3 = await AnimeSeriesService.create(
async_session,
key="test-series-3",
name="Test Series 3",
folder="Test Series 3 (2024)",
site="https://example.com/test3",
)
await EpisodeService.create(
async_session,
series_id=series3.id,
season=1,
episode_number=1,
is_downloaded=True,
)
await async_session.commit()
# Query for series with no downloaded episodes
result = await AnimeSeriesService.get_series_with_no_episodes(
async_session
)
# Should return series1 and series2 but not series3
result_ids = {s.id for s in result}
assert series1.id in result_ids
assert series2.id in result_ids
assert series3.id not in result_ids
@pytest.mark.asyncio
async def test_get_series_with_no_episodes_mixed_downloads(
async_session: AsyncSession
):
"""Test series with mixed downloaded/undownloaded episodes."""
# Create series with some downloaded and some undownloaded episodes
series = await AnimeSeriesService.create(
async_session,
key="test-series-mixed",
name="Test Series Mixed",
folder="Test Series Mixed (2024)",
site="https://example.com/testmixed",
)
# Add downloaded episode
await EpisodeService.create(
async_session,
series_id=series.id,
season=1,
episode_number=1,
is_downloaded=True,
)
# Add undownloaded episode
await EpisodeService.create(
async_session,
series_id=series.id,
season=1,
episode_number=2,
is_downloaded=False,
)
await async_session.commit()
# Query for series with no downloaded episodes
result = await AnimeSeriesService.get_series_with_no_episodes(
async_session
)
# Should NOT include series with at least one downloaded episode
result_ids = {s.id for s in result}
assert series.id not in result_ids
@pytest.mark.asyncio
async def test_get_series_with_no_episodes_pagination(
async_session: AsyncSession
):
"""Test pagination works correctly."""
# Create multiple series without downloaded episodes
for i in range(5):
await AnimeSeriesService.create(
async_session,
key=f"test-series-{i}",
name=f"Test Series {i}",
folder=f"Test Series {i} (2024)",
site=f"https://example.com/test{i}",
)
await async_session.commit()
# Test limit
result = await AnimeSeriesService.get_series_with_no_episodes(
async_session, limit=3
)
assert len(result) == 3
# Test offset
result = await AnimeSeriesService.get_series_with_no_episodes(
async_session, offset=2, limit=2
)
assert len(result) == 2