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

File diff suppressed because it is too large Load Diff

View File

@@ -237,7 +237,9 @@ async def list_anime(
per_page: Items per page (must be positive, max 1000)
sort_by: Optional sorting parameter. Allowed: title, id, name,
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)
series_app: Core SeriesApp instance provided via dependency.
@@ -308,6 +310,14 @@ async def list_anime(
raise ValidationError(
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:
# Get all series from series app
@@ -317,15 +327,24 @@ async def list_anime(
series = series_app.list.GetList()
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 = {}
# 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 (
AnimeSeries as DBAnimeSeries,
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 = (
@@ -342,12 +361,42 @@ async def list_anime(
"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 NFO data from database: {e}")
# Continue without NFO data if database query fails
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
@@ -357,6 +406,12 @@ async def list_anime(
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
# Convert episode dict keys to strings for JSON serialization
missing_episodes = {str(k): v for k, v in episode_dict.items()}

View File

@@ -251,6 +251,59 @@ class AnimeSeriesService:
.limit(limit)
)
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())
# ============================================================================

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