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:
1329
docs/instructions.md
1329
docs/instructions.md
File diff suppressed because it is too large
Load Diff
@@ -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()}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
191
tests/unit/test_series_filter.py
Normal file
191
tests/unit/test_series_filter.py
Normal 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
|
||||
Reference in New Issue
Block a user