diff --git a/docs/API.md b/docs/API.md index 529de09..2548916 100644 --- a/docs/API.md +++ b/docs/API.md @@ -203,14 +203,14 @@ List library series that have missing episodes. | `page` | int | 1 | Page number (must be positive) | | `per_page` | int | 20 | Items per page (max 1000) | | `sort_by` | string | null | Sort field: `title`, `id`, `name`, `missing_episodes` | -| `filter` | string | null | Filter: `no_episodes` (shows only series with missing episodes - episodes in DB that haven't been downloaded yet) | +| `filter` | string | null | Filter: `missing_episodes` (shows series with any missing episodes), `no_episodes` (shows series with zero downloaded episodes) | **Filter Details:** -- `no_episodes`: Returns series that have at least one episode in the database with `is_downloaded=False` +- `missing_episodes`: Returns series that have at least one missing episode recorded in the database (`is_downloaded=False`) +- `no_episodes`: Returns series that have missing episodes and no downloaded episodes (i.e., only missing episodes exist in the database) - Episodes in the database represent MISSING episodes (from episodeDict during scanning) - `is_downloaded=False` means the episode file was not found in the folder -- This effectively shows series where no video files were found for missing episodes **Response (200 OK):** diff --git a/docs/features.md b/docs/features.md index 45dcc16..d43b606 100644 --- a/docs/features.md +++ b/docs/features.md @@ -59,6 +59,10 @@ The application now features a comprehensive configuration system that allows us ## Anime Management - **Anime Library Page**: Display list of anime series with missing episodes +- **Library Filters**: + - "Missing Episodes Only" (shows only series with missing episodes, including series that currently have no downloaded episodes) + - "No Episodes" (shows series that are present in the library but have zero downloaded episodes) + - "Show All Series" (overrides other filters to show every series) - **Database-Backed Series Storage**: All series metadata and missing episodes stored in SQLite database - **Automatic Database Synchronization**: Series loaded from database on startup, stays in sync with filesystem - **Series Selection**: Select individual anime series and add episodes to download queue diff --git a/docs/instructions.md b/docs/instructions.md index 3de7c14..80ddc74 100644 --- a/docs/instructions.md +++ b/docs/instructions.md @@ -101,20 +101,26 @@ conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0. For each task completed: -- [ ] Implementation follows coding standards -- [ ] Unit tests written and passing -- [ ] Integration tests passing -- [ ] Documentation updated -- [ ] Error handling implemented -- [ ] Logging added -- [ ] Security considerations addressed -- [ ] Performance validated -- [ ] Code reviewed -- [ ] Task marked as complete in instructions.md -- [ ] Infrastructure.md updated and other docs -- [ ] Changes committed to git; keep your messages in git short and clear -- [ ] Take the next task +- [x] Implementation follows coding standards +- [x] Unit tests written and passing +- [x] Integration tests passing +- [x] Documentation updated +- [x] Error handling implemented +- [x] Logging added +- [x] Security considerations addressed +- [x] Performance validated +- [x] Code reviewed +- [x] Task marked as complete in instructions.md +- [x] Infrastructure.md updated and other docs +- [x] Changes committed to git; keep your messages in git short and clear +- [x] Take the next task --- ## TODO List: + +- [x] Add a UI option to show series that have **no episodes downloaded** (i.e., series present in the library but with zero episodes). This should be a new filter state that is distinct from the current "Missing Episodes Only" behavior. +- [x] Ensure the existing "Missing Episodes Only" filter correctly shows series that are missing episodes (including those with no episodes) and does not omit series that are in the library but filtered out by other filters. +- [x] Update the frontend series library filter UI (buttons and toggle text) to clearly indicate when the view is showing "Missing Episodes Only", "No Episodes", or "Show All Series". +- [x] Update `docs/features.md` to document the new filter behavior and any new UI options. +- [x] Add or update unit/integration tests to validate the new filter logic and API behavior. diff --git a/src/server/api/anime.py b/src/server/api/anime.py index 34746fb..deae710 100644 --- a/src/server/api/anime.py +++ b/src/server/api/anime.py @@ -236,8 +236,8 @@ async def list_anime( sort_by: Optional sorting parameter. Allowed: title, id, name, missing_episodes filter: Optional filter parameter. Allowed values: - - "no_episodes": Show only series with no downloaded - episodes in folder + - "missing_episodes": Show only series that have any missing episodes + - "no_episodes": Show only series that have no downloaded episodes _auth: Ensures the caller is authenticated (value unused) anime_service: AnimeService instance provided via dependency @@ -298,7 +298,7 @@ async def list_anime( # Validate filter parameter if filter: try: - allowed_filters = ["no_episodes"] + allowed_filters = ["missing_episodes", "no_episodes"] validate_filter_value(filter, allowed_filters) except ValueError as e: raise ValidationError(message=str(e)) diff --git a/src/server/database/service.py b/src/server/database/service.py index 9da6172..2eb746d 100644 --- a/src/server/database/service.py +++ b/src/server/database/service.py @@ -253,48 +253,92 @@ class AnimeSeriesService: return list(result.scalars().all()) @staticmethod - async def get_series_with_no_episodes( + async def get_series_with_missing_episodes( db: AsyncSession, limit: Optional[int] = None, offset: int = 0, ) -> List[AnimeSeries]: - """Get anime series that have no episodes found in folder. - - Since episodes in the database represent MISSING episodes - (from episodeDict), this returns series that have episodes - in the DB with is_downloaded=False, meaning they have missing - episodes and no files were found in the folder for those episodes. - - Returns series where: - - At least one episode exists in database with is_downloaded=False - + """Get anime series that currently have missing episodes. + + Episodes in the database represent missing episodes (from episodeDict). + This returns series that have at least one missing episode recorded in + the database (is_downloaded=False). + Args: db: Database session limit: Optional limit for results offset: Offset for pagination - + Returns: - List of AnimeSeries with missing episodes (not in folder) + List of AnimeSeries that have missing episodes. """ - # Subquery to find series IDs with at least one undownloaded episode - undownloaded_series_ids = ( + # Subquery to find series IDs with at least one missing episode + missing_series_ids = ( select(Episode.series_id) .where(Episode.is_downloaded == False) .distinct() .subquery() ) - - # Select series that have undownloaded episodes + query = ( select(AnimeSeries) - .where(AnimeSeries.id.in_(select(undownloaded_series_ids.c.series_id))) + .where(AnimeSeries.id.in_(select(missing_series_ids.c.series_id))) .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 get_series_with_no_episodes( + db: AsyncSession, + limit: Optional[int] = None, + offset: int = 0, + ) -> List[AnimeSeries]: + """Get anime series that have no downloaded episodes. + + A series has "no episodes" if it has at least one missing episode + (is_downloaded=False) and no downloaded episodes (is_downloaded=True). + + Args: + db: Database session + limit: Optional limit for results + offset: Offset for pagination + + Returns: + List of AnimeSeries where no episodes are downloaded. + """ + # Series with missing episodes + missing_series_ids = ( + select(Episode.series_id) + .where(Episode.is_downloaded == False) + .distinct() + .subquery() + ) + + # Series with any downloaded episodes + downloaded_series_ids = ( + select(Episode.series_id) + .where(Episode.is_downloaded == True) + .distinct() + .subquery() + ) + + query = ( + select(AnimeSeries) + .where(AnimeSeries.id.in_(select(missing_series_ids.c.series_id))) + .where(~AnimeSeries.id.in_(select(downloaded_series_ids.c.series_id))) + .order_by(AnimeSeries.name) + .offset(offset) + ) + + if limit: + query = query.limit(limit) + result = await db.execute(query) return list(result.scalars().all()) diff --git a/src/server/services/anime_service.py b/src/server/services/anime_service.py index 5ae6b3e..f623a08 100644 --- a/src/server/services/anime_service.py +++ b/src/server/services/anime_service.py @@ -524,19 +524,20 @@ class AnimeService: "series_id": db_series.id, } - # If filter is "no_episodes", get series with no - # downloaded episodes - if filter_type == "no_episodes": - # Use service method to get series with - # undownloaded episodes - series_no_downloads = ( - await AnimeSeriesService - .get_series_with_no_episodes(db) + # If filter is "missing_episodes", get series with any missing episodes + if filter_type == "missing_episodes": + series_missing = ( + await AnimeSeriesService.get_series_with_missing_episodes(db) ) - series_with_no_episodes = { - s.folder for s in series_no_downloads - } - + series_with_missing_episodes = {s.folder for s in series_missing} + + # If filter is "no_episodes", get series with no downloaded episodes + if filter_type == "no_episodes": + series_no_downloads = ( + await AnimeSeriesService.get_series_with_no_episodes(db) + ) + series_with_no_episodes = {s.folder for s in series_no_downloads} + # Build result list with enriched metadata result_list = [] for serie in series: @@ -545,8 +546,11 @@ class AnimeService: site = getattr(serie, "site", "") folder = getattr(serie, "folder", "") episode_dict = getattr(serie, "episodeDict", {}) or {} - + # Apply filter if specified + if filter_type == "missing_episodes": + if folder not in series_with_missing_episodes: + continue if filter_type == "no_episodes": if folder not in series_with_no_episodes: continue diff --git a/src/server/web/static/js/index/series-manager.js b/src/server/web/static/js/index/series-manager.js index b43facf..036d916 100644 --- a/src/server/web/static/js/index/series-manager.js +++ b/src/server/web/static/js/index/series-manager.js @@ -16,7 +16,7 @@ AniWorld.SeriesManager = (function() { // State let seriesData = []; let filteredSeriesData = []; - let showMissingOnly = false; + let filterMode = 'all'; // 'all' | 'missing_episodes' | 'no_episodes' let sortAlphabetical = false; /** @@ -24,15 +24,16 @@ AniWorld.SeriesManager = (function() { */ function init() { bindEvents(); + updateFilterButtonUI(); } /** * Bind UI events for filtering and sorting */ function bindEvents() { - const missingOnlyBtn = document.getElementById('show-missing-only'); - if (missingOnlyBtn) { - missingOnlyBtn.addEventListener('click', toggleMissingOnlyFilter); + const filterBtn = document.getElementById('show-missing-only'); + if (filterBtn) { + filterBtn.addEventListener('click', toggleFilterMode); } const sortBtn = document.getElementById('sort-alphabetical'); @@ -49,7 +50,10 @@ AniWorld.SeriesManager = (function() { try { AniWorld.UI.showLoading(); - const response = await AniWorld.ApiClient.get(API.ANIME_LIST); + const url = filterMode && filterMode !== 'all' + ? `${API.ANIME_LIST}?filter=${encodeURIComponent(filterMode)}` + : API.ANIME_LIST; + const response = await AniWorld.ApiClient.get(url); if (!response) { return []; @@ -111,28 +115,28 @@ AniWorld.SeriesManager = (function() { } /** - * Toggle missing episodes only filter + * Cycle through filter modes: + * - all: Show all series + * - missing_episodes: Show only series with missing episodes + * - no_episodes: Show only series with zero downloaded episodes */ - function toggleMissingOnlyFilter() { - showMissingOnly = !showMissingOnly; + async function toggleFilterMode() { const button = document.getElementById('show-missing-only'); - - button.setAttribute('data-active', showMissingOnly); - button.classList.toggle('active', showMissingOnly); - const icon = button.querySelector('i'); const text = button.querySelector('span'); - if (showMissingOnly) { - icon.className = 'fas fa-filter-circle-xmark'; - text.textContent = 'Show All Series'; + // Cycle through modes + if (filterMode === 'all') { + filterMode = 'missing_episodes'; + } else if (filterMode === 'missing_episodes') { + filterMode = 'no_episodes'; } else { - icon.className = 'fas fa-filter'; - text.textContent = 'Missing Episodes Only'; + filterMode = 'all'; } - applyFiltersAndSort(); - renderSeries(); + // Update button UI and reload list based on new filter. + updateFilterButtonUI(); + await loadSeries(); // Clear selection when filter changes if (AniWorld.SelectionManager) { @@ -140,6 +144,34 @@ AniWorld.SeriesManager = (function() { } } + /** + * Update the filter button UI to reflect current filter mode + */ + function updateFilterButtonUI() { + const button = document.getElementById('show-missing-only'); + if (!button) { + return; + } + + const icon = button.querySelector('i'); + const text = button.querySelector('span'); + + const isActive = filterMode !== 'all'; + button.setAttribute('data-active', isActive); + button.classList.toggle('active', isActive); + + if (filterMode === 'missing_episodes') { + icon.className = 'fas fa-filter'; + text.textContent = 'Missing Episodes Only'; + } else if (filterMode === 'no_episodes') { + icon.className = 'fas fa-ban'; + text.textContent = 'No Episodes'; + } else { + icon.className = 'fas fa-filter-circle-xmark'; + text.textContent = 'Show All Series'; + } + } + /** * Toggle alphabetical sorting */ @@ -193,13 +225,6 @@ AniWorld.SeriesManager = (function() { } }); - // Apply missing episodes filter - if (showMissingOnly) { - filtered = filtered.filter(function(serie) { - return serie.missing_episodes > 0; - }); - } - filteredSeriesData = filtered; } @@ -212,9 +237,14 @@ AniWorld.SeriesManager = (function() { (seriesData.length > 0 ? seriesData : []); if (dataToRender.length === 0) { - const message = showMissingOnly ? - 'No series with missing episodes found.' : - 'No series found. Try searching for anime or rescanning your directory.'; + let message; + if (filterMode === 'missing_episodes') { + message = 'No series with missing episodes found.'; + } else if (filterMode === 'no_episodes') { + message = 'No series with zero downloaded episodes found.'; + } else { + message = 'No series found. Try searching for anime or rescanning your directory.'; + } grid.innerHTML = '
' + diff --git a/tests/unit/test_series_filter.py b/tests/unit/test_series_filter.py index 396abbf..9a67195 100644 --- a/tests/unit/test_series_filter.py +++ b/tests/unit/test_series_filter.py @@ -124,8 +124,8 @@ async def test_get_series_with_no_episodes_mixed_downloads( async_session: AsyncSession ): """Test series with mixed downloaded/undownloaded episodes. - - Series with ANY missing episodes (is_downloaded=False) should appear. + + Series should NOT appear when there is at least one downloaded episode. """ # Create series with some downloaded and some undownloaded episodes series = await AnimeSeriesService.create( @@ -135,7 +135,7 @@ async def test_get_series_with_no_episodes_mixed_downloads( folder="Test Series Mixed (2024)", site="https://example.com/testmixed", ) - + # Add downloaded episode await EpisodeService.create( async_session, @@ -144,7 +144,7 @@ async def test_get_series_with_no_episodes_mixed_downloads( episode_number=1, is_downloaded=True, ) - + # Add undownloaded episode (MISSING) await EpisodeService.create( async_session, @@ -153,15 +153,56 @@ async def test_get_series_with_no_episodes_mixed_downloads( episode_number=2, is_downloaded=False, ) - + await async_session.commit() - + # Query for series with no episodes in folder result = await AnimeSeriesService.get_series_with_no_episodes( async_session ) - - # Should return the series because it has missing episodes + + # Should not return the series because it has at least one downloaded episode + assert len(result) == 0 + + +@pytest.mark.asyncio +async def test_get_series_with_missing_episodes_mixed_downloads( + async_session: AsyncSession +): + """Test missing episodes filter includes series with mixed downloads.""" + 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 (MISSING) + 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 missing episodes + result = await AnimeSeriesService.get_series_with_missing_episodes( + async_session + ) + assert len(result) == 1 assert result[0].id == series.id @@ -171,8 +212,8 @@ async def test_get_series_with_no_episodes_mixed_seasons( async_session: AsyncSession ): """Test series with some seasons downloaded, some not. - - If ANY episode is still missing (is_downloaded=False), series should appear. + + Series should not appear when any episode is downloaded. """ series = await AnimeSeriesService.create( async_session, @@ -181,7 +222,7 @@ async def test_get_series_with_no_episodes_mixed_seasons( folder="Test Series (2024)", site="https://example.com/test", ) - + # Season 1: all episodes downloaded await EpisodeService.create( async_session, @@ -190,7 +231,7 @@ async def test_get_series_with_no_episodes_mixed_seasons( episode_number=1, is_downloaded=True, ) - + # Season 2: has missing episode await EpisodeService.create( async_session, @@ -199,16 +240,15 @@ async def test_get_series_with_no_episodes_mixed_seasons( episode_number=1, is_downloaded=False, ) - + await async_session.commit() - + result = await AnimeSeriesService.get_series_with_no_episodes( async_session ) - - # Should return the series because season 2 has missing episodes - assert len(result) == 1 - assert result[0].id == series.id + + # Should not return the series because it has downloaded episodes + assert len(result) == 0 @pytest.mark.asyncio