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:
2026-02-26 21:02:08 +01:00
parent 624c0db16e
commit b34ee59bca
3 changed files with 363 additions and 19 deletions

View File

@@ -121,7 +121,7 @@ For each task completed:
--- ---
1. ~~remove gui action button for each card~~ 1. ~~fix: after download completed remove missing episode~~
~~<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>~~ ~~afer successuflly downloaded a episode remove the missing episode entry from db.~~
2. ~~add nfo refresh action for all selected cards~~ ~~Check if this is implemented correcly. Implement it if not. add logs so i can check it.~~
~~so a button right next to "Select All" that call update nfo for all selected items~~ **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.

View File

@@ -210,8 +210,12 @@ class DownloadService:
) -> bool: ) -> bool:
"""Remove a downloaded episode from the missing episodes list. """Remove a downloaded episode from the missing episodes list.
Called when a download completes successfully to update the Called when a download completes successfully to update both:
database so the episode no longer appears as missing. 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: Args:
series_key: Unique provider key for the series 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.connection import get_db_session
from src.server.database.service import EpisodeService 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: async with get_db_session() as db:
deleted = await EpisodeService.delete_by_series_and_episode( deleted = await EpisodeService.delete_by_series_and_episode(
db=db, db=db,
@@ -234,25 +246,136 @@ class DownloadService:
) )
if deleted: if deleted:
logger.info( logger.info(
"Removed episode from missing list: " "Successfully removed episode from DB missing list: "
"%s S%02dE%02d", "%s S%02dE%02d",
series_key, series_key,
season, season,
episode, episode,
) )
# Clear the anime service cache so list_missing else:
# returns updated data logger.warning(
try: "Episode not found in DB missing list "
self._anime_service._cached_list_missing.cache_clear() "(may already be removed): %s S%02dE%02d",
except Exception: series_key,
pass season,
return deleted 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: except Exception as e:
logger.error( 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 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: async def _init_queue_progress(self) -> None:
"""Initialize the download queue progress tracking. """Initialize the download queue progress tracking.
@@ -933,18 +1056,35 @@ class DownloadService:
self._completed_items.append(item) 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) await self._delete_from_database(item.id)
# Remove episode from missing episodes list in database # Remove episode from missing episodes list
await self._remove_episode_from_missing_list( # (both database and in-memory)
removed = await self._remove_episode_from_missing_list(
series_key=item.serie_id, series_key=item.serie_id,
season=item.episode.season, season=item.episode.season,
episode=item.episode.episode, episode=item.episode.episode,
) )
logger.info( 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: else:
raise AnimeServiceError("Download returned False") raise AnimeServiceError("Download returned False")

View File

@@ -630,3 +630,207 @@ class TestErrorHandling:
download_service._failed_items[0].status == DownloadStatus.FAILED download_service._failed_items[0].status == DownloadStatus.FAILED
) )
assert download_service._failed_items[0].error is not None 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]