fix: remove missing episode from DB and memory after download completes
- Fixed _remove_episode_from_missing_list to also update in-memory Serie.episodeDict and refresh series_list - Added _remove_episode_from_memory helper method - Enhanced logging for download completion and episode removal - Added 5 unit tests for missing episode removal
This commit is contained in:
@@ -121,7 +121,7 @@ For each task completed:
|
||||
|
||||
---
|
||||
|
||||
1. ~~remove gui action button for each card~~
|
||||
~~<div class="series-actions"><button class="btn btn-sm btn-secondary nfo-view-btn" data-key="he-is-my-master" title="View NFO"><i class="fas fa-eye"></i> View NFO</button><button class="btn btn-sm btn-secondary nfo-refresh-btn" data-key="he-is-my-master" title="Refresh NFO"><i class="fas fa-sync-alt"></i> Refresh</button></div>~~
|
||||
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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
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
|
||||
# returns updated data
|
||||
# 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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user