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)
|
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.
|
||||||
|
|
||||||
@@ -308,6 +310,14 @@ async def list_anime(
|
|||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
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
|
||||||
@@ -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()}
|
||||||
|
|
||||||
|
|||||||
@@ -251,6 +251,59 @@ class AnimeSeriesService:
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
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())
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
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