diff --git a/docs/instructions.md b/docs/instructions.md index 142d506..6f5dc71 100644 --- a/docs/instructions.md +++ b/docs/instructions.md @@ -121,7 +121,7 @@ For each task completed: --- -1. ~~remove gui action button for each card~~ - ~~
~~ -2. ~~add nfo refresh action for all selected cards~~ - ~~so a button right next to "Select All" that call update nfo for all selected items~~ +1. ~~fix: after download completed remove missing episode~~ + ~~afer successuflly downloaded a episode remove the missing episode entry from db.~~ + ~~Check if this is implemented correcly. Implement it if not. add logs so i can check it.~~ + **DONE** - Fixed `_remove_episode_from_missing_list` to also update in-memory `Serie.episodeDict` and refresh `series_list`. Added `_remove_episode_from_memory` helper. Enhanced logging throughout. 5 new unit tests added. diff --git a/src/server/services/download_service.py b/src/server/services/download_service.py index ddd7b48..475d0c2 100644 --- a/src/server/services/download_service.py +++ b/src/server/services/download_service.py @@ -210,8 +210,12 @@ class DownloadService: ) -> 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. + Called when a download completes successfully to update both: + 1. The database (Episode record deleted) + 2. The in-memory Serie.episodeDict and series_list cache + + This ensures the episode no longer appears as missing in both + the API responses and the UI immediately after download. Args: series_key: Unique provider key for the series @@ -225,6 +229,14 @@ class DownloadService: from src.server.database.connection import get_db_session from src.server.database.service import EpisodeService + logger.info( + "Attempting to remove missing episode from DB: " + "%s S%02dE%02d", + series_key, + season, + episode, + ) + async with get_db_session() as db: deleted = await EpisodeService.delete_by_series_and_episode( db=db, @@ -234,25 +246,136 @@ class DownloadService: ) if deleted: logger.info( - "Removed episode from missing list: " + "Successfully removed episode from DB 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 + else: + logger.warning( + "Episode not found in DB missing list " + "(may already be removed): %s S%02dE%02d", + series_key, + season, + episode, + ) + + # Update in-memory Serie.episodeDict so list_missing is + # immediately consistent without a full DB reload + self._remove_episode_from_memory(series_key, season, episode) + + # Clear the anime service cache so list_missing + # re-reads from the (now updated) in-memory state + try: + self._anime_service._cached_list_missing.cache_clear() + logger.debug( + "Cleared list_missing cache after removing " + "%s S%02dE%02d", + series_key, + season, + episode, + ) + except Exception: + pass + + return deleted except Exception as e: logger.error( - "Failed to remove episode from missing list: %s", e + "Failed to remove episode from missing list: " + "%s S%02dE%02d - %s", + series_key, + season, + episode, + e, ) return False + def _remove_episode_from_memory( + self, + series_key: str, + season: int, + episode: int, + ) -> None: + """Remove an episode from the in-memory Serie.episodeDict. + + Updates the SeriesApp's keyDict so that list_missing and + series_list reflect the removal immediately without needing + a full database reload. + + Args: + series_key: Unique provider key for the series + season: Season number + episode: Episode number within season + """ + try: + app = self._anime_service._app + serie = app.list.keyDict.get(series_key) + if not serie: + logger.debug( + "Series %s not found in keyDict, skipping " + "in-memory removal", + series_key, + ) + return + + ep_dict = serie.episodeDict + if season not in ep_dict: + logger.debug( + "Season %d not in episodeDict for %s, " + "skipping in-memory removal", + season, + series_key, + ) + return + + if episode in ep_dict[season]: + ep_dict[season].remove(episode) + logger.info( + "Removed episode from in-memory episodeDict: " + "%s S%02dE%02d (remaining in season: %s)", + series_key, + season, + episode, + ep_dict[season], + ) + + # Remove the season key if no episodes remain + if not ep_dict[season]: + del ep_dict[season] + logger.info( + "Removed empty season %d from episodeDict " + "for %s", + season, + series_key, + ) + + # Refresh series_list so GetMissingEpisode() + # reflects the change + app.series_list = app.list.GetMissingEpisode() + logger.info( + "Refreshed series_list: %d series with " + "missing episodes remaining", + len(app.series_list), + ) + else: + logger.debug( + "Episode %d not in season %d for %s, " + "already removed from memory", + episode, + season, + series_key, + ) + except Exception as e: + logger.warning( + "Failed to remove episode from in-memory state: " + "%s S%02dE%02d - %s", + series_key, + season, + episode, + e, + ) + async def _init_queue_progress(self) -> None: """Initialize the download queue progress tracking. @@ -933,18 +1056,35 @@ class DownloadService: self._completed_items.append(item) - # Delete completed item from database (status is in-memory) + logger.info( + "Download succeeded, cleaning up: item_id=%s, " + "serie_key=%s, S%02dE%02d", + item.id, + item.serie_id, + item.episode.season, + item.episode.episode, + ) + + # Delete completed item from download queue database await self._delete_from_database(item.id) - # Remove episode from missing episodes list in database - await self._remove_episode_from_missing_list( + # Remove episode from missing episodes list + # (both database and in-memory) + removed = 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 + "Download completed successfully: item_id=%s, " + "serie_key=%s, S%02dE%02d, " + "missing_episode_removed=%s", + item.id, + item.serie_id, + item.episode.season, + item.episode.episode, + removed, ) else: raise AnimeServiceError("Download returned False") diff --git a/tests/unit/test_download_service.py b/tests/unit/test_download_service.py index b32ea4c..d2d6f6c 100644 --- a/tests/unit/test_download_service.py +++ b/tests/unit/test_download_service.py @@ -630,3 +630,207 @@ class TestErrorHandling: download_service._failed_items[0].status == DownloadStatus.FAILED ) assert download_service._failed_items[0].error is not None + + +class TestRemoveEpisodeFromMissingList: + """Test that completed downloads remove episodes from missing list.""" + + @pytest.mark.asyncio + async def test_remove_episode_from_memory(self, download_service): + """Test _remove_episode_from_memory updates in-memory state.""" + from src.core.entities.series import Serie + + # Set up in-memory series with missing episodes + serie = Serie( + key="test-series", + name="Test Series", + site="https://example.com", + folder="Test Series (2024)", + episodeDict={1: [1, 2, 3], 2: [1, 2]}, + ) + mock_app = MagicMock() + mock_app.list.keyDict = {"test-series": serie} + mock_app.list.GetMissingEpisode.return_value = [serie] + mock_app.series_list = [serie] + download_service._anime_service._app = mock_app + + # Remove episode S01E02 + download_service._remove_episode_from_memory("test-series", 1, 2) + + # Episode should be removed from episodeDict + assert 2 not in serie.episodeDict[1] + assert serie.episodeDict[1] == [1, 3] + # Season 2 should be untouched + assert serie.episodeDict[2] == [1, 2] + + @pytest.mark.asyncio + async def test_remove_last_episode_in_season_removes_season( + self, download_service + ): + """Test removing the last episode in a season removes the season key.""" + from src.core.entities.series import Serie + + serie = Serie( + key="test-series", + name="Test Series", + site="https://example.com", + folder="Test Series (2024)", + episodeDict={1: [5], 2: [1, 2]}, + ) + mock_app = MagicMock() + mock_app.list.keyDict = {"test-series": serie} + mock_app.list.GetMissingEpisode.return_value = [serie] + mock_app.series_list = [serie] + download_service._anime_service._app = mock_app + + # Remove the only episode in season 1 + download_service._remove_episode_from_memory("test-series", 1, 5) + + # Season 1 should be completely removed + assert 1 not in serie.episodeDict + # Season 2 untouched + assert serie.episodeDict[2] == [1, 2] + # GetMissingEpisode should have been called to refresh + mock_app.list.GetMissingEpisode.assert_called() + + @pytest.mark.asyncio + async def test_remove_episode_unknown_series_no_error( + self, download_service + ): + """Test removing episode for unknown series does not raise.""" + mock_app = MagicMock() + mock_app.list.keyDict = {} + download_service._anime_service._app = mock_app + + # Should not raise + download_service._remove_episode_from_memory( + "nonexistent-series", 1, 1 + ) + + @pytest.mark.asyncio + async def test_remove_episode_from_missing_list_calls_db_and_memory( + self, download_service + ): + """Test _remove_episode_from_missing_list updates both DB and memory.""" + from unittest.mock import patch + + from src.core.entities.series import Serie + + # Set up in-memory state + serie = Serie( + key="test-series", + name="Test Series", + site="https://example.com", + folder="Test Series (2024)", + episodeDict={1: [1, 2, 3]}, + ) + mock_app = MagicMock() + mock_app.list.keyDict = {"test-series": serie} + mock_app.list.GetMissingEpisode.return_value = [serie] + mock_app.series_list = [serie] + download_service._anime_service._app = mock_app + download_service._anime_service._cached_list_missing = MagicMock() + + # Mock DB call + mock_db_session = AsyncMock() + mock_delete = AsyncMock(return_value=True) + + with patch( + "src.server.database.connection.get_db_session" + ) as mock_get_db, patch( + "src.server.database.service.EpisodeService" + ) as mock_ep_svc: + mock_get_db.return_value.__aenter__ = AsyncMock( + return_value=mock_db_session + ) + mock_get_db.return_value.__aexit__ = AsyncMock( + return_value=False + ) + mock_ep_svc.delete_by_series_and_episode = mock_delete + + result = await download_service._remove_episode_from_missing_list( + series_key="test-series", + season=1, + episode=2, + ) + + # DB deletion was called + mock_delete.assert_awaited_once_with( + db=mock_db_session, + series_key="test-series", + season=1, + episode_number=2, + ) + # In-memory update happened + assert 2 not in serie.episodeDict[1] + assert serie.episodeDict[1] == [1, 3] + # Cache was cleared + download_service._anime_service._cached_list_missing.cache_clear.assert_called() + assert result is True + + @pytest.mark.asyncio + async def test_download_completion_removes_missing_episode( + self, download_service + ): + """Test full flow: download success removes episode from missing list.""" + from unittest.mock import patch + + from src.core.entities.series import Serie + + # Setup mock anime service to return success + download_service._anime_service.download = AsyncMock( + return_value=True + ) + + # Set up in-memory series state + serie = Serie( + key="series-1", + name="Test Series", + site="https://example.com", + folder="series", + episodeDict={1: [1, 2, 3]}, + ) + mock_app = MagicMock() + mock_app.list.keyDict = {"series-1": serie} + mock_app.list.GetMissingEpisode.return_value = [serie] + mock_app.series_list = [serie] + download_service._anime_service._app = mock_app + download_service._anime_service._cached_list_missing = MagicMock() + + # Add episode to queue + await download_service.add_to_queue( + serie_id="series-1", + serie_folder="series", + serie_name="Test Series", + episodes=[EpisodeIdentifier(season=1, episode=2)], + ) + + # Mock DB calls + mock_db_session = AsyncMock() + mock_delete = AsyncMock(return_value=True) + + with patch( + "src.server.database.connection.get_db_session" + ) as mock_get_db, patch( + "src.server.database.service.EpisodeService" + ) as mock_ep_svc: + mock_get_db.return_value.__aenter__ = AsyncMock( + return_value=mock_db_session + ) + mock_get_db.return_value.__aexit__ = AsyncMock( + return_value=False + ) + mock_ep_svc.delete_by_series_and_episode = mock_delete + + # Process the download + item = download_service._pending_queue.popleft() + download_service._pending_items_by_id.pop(item.id, None) + await download_service._process_download(item) + + # Episode should be completed + assert len(download_service._completed_items) == 1 + assert download_service._completed_items[0].status == DownloadStatus.COMPLETED + + # Episode 2 should be removed from in-memory missing list + assert 2 not in serie.episodeDict[1] + assert serie.episodeDict[1] == [1, 3]