diff --git a/src/server/database/service.py b/src/server/database/service.py index 1b48816..fabb763 100644 --- a/src/server/database/service.py +++ b/src/server/database/service.py @@ -393,6 +393,51 @@ class EpisodeService: ) return result.rowcount > 0 + @staticmethod + async def delete_by_series_and_episode( + db: AsyncSession, + series_key: str, + season: int, + episode_number: int, + ) -> bool: + """Delete episode by series key, season, and episode number. + + Used to remove episodes from the missing list when they are + downloaded successfully. + + Args: + db: Database session + series_key: Unique provider key for the series + season: Season number + episode_number: Episode number within season + + Returns: + True if deleted, False if not found + """ + # First get the series by key + series = await AnimeSeriesService.get_by_key(db, series_key) + if not series: + logger.warning( + f"Series not found for key: {series_key}" + ) + return False + + # Then delete the episode + result = await db.execute( + delete(Episode).where( + Episode.series_id == series.id, + Episode.season == season, + Episode.episode_number == episode_number, + ) + ) + deleted = result.rowcount > 0 + if deleted: + logger.info( + f"Removed episode from missing list: " + f"{series_key} S{season:02d}E{episode_number:02d}" + ) + return deleted + # ============================================================================ # Download Queue Service diff --git a/src/server/services/anime_service.py b/src/server/services/anime_service.py index e569348..e49d9e4 100644 --- a/src/server/services/anime_service.py +++ b/src/server/services/anime_service.py @@ -397,32 +397,65 @@ class AnimeService: ) async def _update_series_in_db(self, serie, existing, db) -> None: - """Update an existing series in the database.""" + """Update an existing series in the database. + + Syncs the database episodes with the current missing episodes from scan. + - Adds new missing episodes that are not in the database + - Removes episodes from database that are no longer missing + (i.e., the file has been added to the filesystem) + """ from src.server.database.service import AnimeSeriesService, EpisodeService - # Get existing episodes + # Get existing episodes from database existing_episodes = await EpisodeService.get_by_series(db, existing.id) - existing_dict: dict[int, list[int]] = {} + + # Build dict of existing episodes: {season: {ep_num: episode_id}} + existing_dict: dict[int, dict[int, int]] = {} for ep in existing_episodes: if ep.season not in existing_dict: - existing_dict[ep.season] = [] - existing_dict[ep.season].append(ep.episode_number) - for season in existing_dict: - existing_dict[season].sort() + existing_dict[ep.season] = {} + existing_dict[ep.season][ep.episode_number] = ep.id - # Update episodes if changed - if existing_dict != serie.episodeDict: - new_dict = serie.episodeDict or {} - for season, episode_numbers in new_dict.items(): - existing_eps = set(existing_dict.get(season, [])) - for ep_num in episode_numbers: - if ep_num not in existing_eps: - await EpisodeService.create( - db=db, - series_id=existing.id, - season=season, - episode_number=ep_num, - ) + # Get new missing episodes from scan + new_dict = serie.episodeDict or {} + + # Build set of new missing episodes for quick lookup + new_missing_set: set[tuple[int, int]] = set() + for season, episode_numbers in new_dict.items(): + for ep_num in episode_numbers: + new_missing_set.add((season, ep_num)) + + # Add new missing episodes that are not in the database + for season, episode_numbers in new_dict.items(): + existing_season_eps = existing_dict.get(season, {}) + for ep_num in episode_numbers: + if ep_num not in existing_season_eps: + await EpisodeService.create( + db=db, + series_id=existing.id, + season=season, + episode_number=ep_num, + ) + logger.debug( + "Added missing episode to database: %s S%02dE%02d", + serie.key, + season, + ep_num + ) + + # Remove episodes from database that are no longer missing + # (i.e., the episode file now exists on the filesystem) + for season, eps_dict in existing_dict.items(): + for ep_num, episode_id in eps_dict.items(): + if (season, ep_num) not in new_missing_set: + await EpisodeService.delete(db, episode_id) + logger.info( + "Removed episode from database (no longer missing): " + "%s S%02dE%02d", + serie.key, + season, + ep_num + ) # Update folder if changed if existing.folder != serie.folder: diff --git a/src/server/services/download_service.py b/src/server/services/download_service.py index c4d6c73..baee091 100644 --- a/src/server/services/download_service.py +++ b/src/server/services/download_service.py @@ -201,7 +201,58 @@ class DownloadService: except Exception as e: logger.error("Failed to delete from database: %s", e) return False - + + async def _remove_episode_from_missing_list( + self, + series_key: str, + season: int, + episode: int, + ) -> bool: + """Remove a downloaded episode from the missing episodes list. + + Called when a download completes successfully to update the + database so the episode no longer appears as missing. + + Args: + series_key: Unique provider key for the series + season: Season number + episode: Episode number within season + + Returns: + True if episode was removed, False otherwise + """ + try: + from src.server.database.connection import get_db_session + from src.server.database.service import EpisodeService + + async with get_db_session() as db: + deleted = await EpisodeService.delete_by_series_and_episode( + db=db, + series_key=series_key, + season=season, + episode_number=episode, + ) + if deleted: + logger.info( + "Removed episode from missing list: " + "%s S%02dE%02d", + series_key, + season, + episode, + ) + # Clear the anime service cache so list_missing + # returns updated data + try: + self._anime_service._cached_list_missing.cache_clear() + except Exception: + pass + return deleted + except Exception as e: + logger.error( + "Failed to remove episode from missing list: %s", e + ) + return False + async def _init_queue_progress(self) -> None: """Initialize the download queue progress tracking. @@ -885,6 +936,13 @@ class DownloadService: # Delete completed item from database (status is in-memory) await self._delete_from_database(item.id) + # Remove episode from missing episodes list in database + await self._remove_episode_from_missing_list( + series_key=item.serie_id, + season=item.episode.season, + episode=item.episode.episode, + ) + logger.info( "Download completed successfully: item_id=%s", item.id ) diff --git a/tests/api/test_download_endpoints.py b/tests/api/test_download_endpoints.py index 6c8603e..04815dd 100644 --- a/tests/api/test_download_endpoints.py +++ b/tests/api/test_download_endpoints.py @@ -236,7 +236,7 @@ async def test_add_to_queue_service_error( ) assert response.status_code == 400 - assert "Queue full" in response.json()["detail"] + assert "Queue full" in response.json()["message"] @pytest.mark.asyncio @@ -294,8 +294,8 @@ async def test_start_download_empty_queue( assert response.status_code == 400 data = response.json() - detail = data["detail"].lower() - assert "empty" in detail or "no pending" in detail + message = data["message"].lower() + assert "empty" in message or "no pending" in message @pytest.mark.asyncio @@ -311,8 +311,8 @@ async def test_start_download_already_active( assert response.status_code == 400 data = response.json() - detail_lower = data["detail"].lower() - assert "already" in detail_lower or "progress" in detail_lower + message_lower = data["message"].lower() + assert "already" in message_lower or "progress" in message_lower @pytest.mark.asyncio diff --git a/tests/frontend/test_existing_ui_integration.py b/tests/frontend/test_existing_ui_integration.py index 6c83b6b..ca3c85b 100644 --- a/tests/frontend/test_existing_ui_integration.py +++ b/tests/frontend/test_existing_ui_integration.py @@ -201,7 +201,7 @@ class TestFrontendAnimeAPI: async def test_rescan_anime(self, authenticated_client): """Test POST /api/anime/rescan triggers rescan with events.""" - from unittest.mock import MagicMock + from unittest.mock import MagicMock, patch from src.server.services.progress_service import ProgressService from src.server.utils.dependencies import get_anime_service @@ -210,7 +210,7 @@ class TestFrontendAnimeAPI: mock_series_app = MagicMock() mock_series_app.directory_to_search = "/tmp/test" mock_series_app.series_list = [] - mock_series_app.rescan = AsyncMock() + mock_series_app.rescan = AsyncMock(return_value=[]) mock_series_app.download_status = None mock_series_app.scan_status = None @@ -232,7 +232,16 @@ class TestFrontendAnimeAPI: app.dependency_overrides[get_anime_service] = lambda: anime_service try: - response = await authenticated_client.post("/api/anime/rescan") + # Mock database operations called during rescan + with patch.object( + anime_service, '_save_scan_results_to_db', new_callable=AsyncMock + ): + with patch.object( + anime_service, '_load_series_from_db', new_callable=AsyncMock + ): + response = await authenticated_client.post( + "/api/anime/rescan" + ) assert response.status_code == 200 data = response.json() @@ -448,7 +457,7 @@ class TestFrontendJavaScriptIntegration: assert response.status_code in [200, 400] if response.status_code == 400: # Verify error message indicates empty queue - assert "No pending downloads" in response.json()["detail"] + assert "No pending downloads" in response.json()["message"] # Test pause - always succeeds even if nothing is processing response = await authenticated_client.post("/api/queue/pause") diff --git a/tests/integration/test_download_flow.py b/tests/integration/test_download_flow.py index f951b6e..80986c1 100644 --- a/tests/integration/test_download_flow.py +++ b/tests/integration/test_download_flow.py @@ -220,7 +220,8 @@ class TestDownloadFlowEndToEnd: assert response.status_code == 400 data = response.json() - assert "detail" in data + # API returns 'message' for error responses + assert "message" in data async def test_validation_error_for_invalid_priority(self, authenticated_client): """Test validation error for invalid priority level.""" diff --git a/tests/integration/test_websocket_integration.py b/tests/integration/test_websocket_integration.py index 5c0fe7b..2c93343 100644 --- a/tests/integration/test_websocket_integration.py +++ b/tests/integration/test_websocket_integration.py @@ -6,7 +6,7 @@ real-time updates are properly broadcasted to connected clients. """ import asyncio from typing import Any, Dict, List -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -64,6 +64,9 @@ async def anime_service(mock_series_app, progress_service): series_app=mock_series_app, progress_service=progress_service, ) + # Mock database operations that are called during rescan + service._save_scan_results_to_db = AsyncMock(return_value=0) + service._load_series_from_db = AsyncMock(return_value=None) yield service diff --git a/tests/unit/test_serie_list.py b/tests/unit/test_serie_list.py index 3bf29a8..99d858a 100644 --- a/tests/unit/test_serie_list.py +++ b/tests/unit/test_serie_list.py @@ -1,9 +1,9 @@ """Tests for SerieList class - identifier standardization.""" +# pylint: disable=redefined-outer-name import os import tempfile import warnings -from unittest.mock import MagicMock, patch import pytest