From 9a81b04b65f33c211715856f0c0288273abe6e68 Mon Sep 17 00:00:00 2001 From: Lukas Date: Mon, 25 May 2026 21:30:31 +0200 Subject: [PATCH] fix: downloaded episodes no longer appear as missing Use the database as the authoritative source for missing-episode lists so that episodes marked is_downloaded=True are never shown as missing, even when the in-memory state is stale. Key changes: - EpisodeService.get_by_series() gains only_missing flag - AnimeService uses DB-backed episodeDict and preserves downloaded episodes during sync, skipping them when adding/removing missing episodes - DownloadService broadcasts series_updated after marking an episode downloaded so the frontend reflects the change immediately - Frontend filters out series with zero missing episodes client-side and fixes renderSeries to respect the active filter - Unit tests updated to assert the broadcast is sent --- src/server/database/service.py | 7 + src/server/services/anime_service.py | 121 +++++++++++++++--- src/server/services/download_service.py | 27 +++- .../web/static/js/index/series-manager.js | 19 ++- tests/unit/test_download_service.py | 5 + 5 files changed, 161 insertions(+), 18 deletions(-) diff --git a/src/server/database/service.py b/src/server/database/service.py index d13770d..dcb1e1e 100644 --- a/src/server/database/service.py +++ b/src/server/database/service.py @@ -541,6 +541,7 @@ class EpisodeService: db: AsyncSession, series_id: int, season: Optional[int] = None, + only_missing: bool = False, ) -> List[Episode]: """Get episodes for a series. @@ -548,6 +549,9 @@ class EpisodeService: db: Database session series_id: Foreign key to AnimeSeries season: Optional season filter + only_missing: If True, only return episodes where + is_downloaded is False (i.e., missing episodes). + Default False returns all episodes. Returns: List of Episode instances @@ -557,6 +561,9 @@ class EpisodeService: if season is not None: query = query.where(Episode.season == season) + if only_missing: + query = query.where(Episode.is_downloaded == False) + query = query.order_by(Episode.season, Episode.episode_number) 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 7eb9759..4fba9ca 100644 --- a/src/server/services/anime_service.py +++ b/src/server/services/anime_service.py @@ -498,13 +498,19 @@ class AnimeService: logger.info("No series found in SeriesApp") return [] - # Build NFO metadata map and filter data from database - nfo_map = {} - series_with_no_episodes = set() + # Build NFO metadata map, episode dict, and filter data from database. + # Using DB as authoritative source for episodeDict ensures that + # episodes marked is_downloaded=True are never shown as missing, + # even if the in-memory state is stale. + nfo_map: dict = {} + db_episode_dict_map: dict[str, dict[int, list[int]]] = {} + series_with_no_episodes: set = set() async with get_db_session() as db: - # Get all series NFO metadata using service layer - db_series_list = await AnimeSeriesService.get_all(db) + # Single query: load all series with their episodes eagerly + db_series_list = await AnimeSeriesService.get_all( + db, with_episodes=True + ) for db_series in db_series_list: nfo_created = ( @@ -523,6 +529,20 @@ class AnimeService: "tvdb_id": db_series.tvdb_id, "series_id": db_series.id, } + + # Build episodeDict from DB, skipping is_downloaded=True + # episodes so they are never shown as missing in the UI. + ep_dict: dict[int, list[int]] = {} + if db_series.episodes: + for ep in db_series.episodes: + if ep.is_downloaded: + continue + if ep.season not in ep_dict: + ep_dict[ep.season] = [] + ep_dict[ep.season].append(ep.episode_number) + for s in ep_dict: + ep_dict[s].sort() + db_episode_dict_map[db_series.folder] = ep_dict # If filter is "missing_episodes", get series with any missing episodes if filter_type == "missing_episodes": @@ -545,7 +565,12 @@ class AnimeService: name = getattr(serie, "name", "") site = getattr(serie, "site", "") folder = getattr(serie, "folder", "") - episode_dict = getattr(serie, "episodeDict", {}) or {} + # Use DB-backed episodeDict (is_downloaded=True already filtered out) + # with in-memory episodeDict as fallback if the series isn't in DB yet. + episode_dict = db_episode_dict_map.get( + folder, + getattr(serie, "episodeDict", {}) or {} + ) # Apply filter if specified if filter_type == "missing_episodes": @@ -815,18 +840,24 @@ class AnimeService: - 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) + - Preserves episodes marked as downloaded (is_downloaded=True) + so download history is not lost """ from src.server.database.service import AnimeSeriesService, EpisodeService - # Get existing episodes from database + # Get existing episodes from database (all episodes, including downloaded) existing_episodes = await EpisodeService.get_by_series(db, existing.id) # Build dict of existing episodes: {season: {ep_num: episode_id}} + # and track which ones are already downloaded existing_dict: dict[int, dict[int, int]] = {} + downloaded_set: set[tuple[int, int]] = set() for ep in existing_episodes: if ep.season not in existing_dict: existing_dict[ep.season] = {} existing_dict[ep.season][ep.episode_number] = ep.id + if ep.is_downloaded: + downloaded_set.add((ep.season, ep.episode_number)) # Get new missing episodes from scan new_dict = serie.episodeDict or {} @@ -857,9 +888,22 @@ class AnimeService: # Remove episodes from database that are no longer missing # (i.e., the episode file now exists on the filesystem) + # BUT: preserve episodes that are already downloaded (is_downloaded=True) + # so we don't lose download history 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: + # Skip already-downloaded episodes — they should stay in DB + # with is_downloaded=True to preserve download history + if (season, ep_num) in downloaded_set: + logger.debug( + "Preserving downloaded episode in database: " + "%s S%02dE%02d", + serie.key, + season, + ep_num + ) + continue await EpisodeService.delete(db, episode_id) logger.info( "Removed episode from database (no longer missing): " @@ -889,6 +933,10 @@ class AnimeService: This method is called during initialization and after rescans to ensure the in-memory series list is in sync with the database. + + Only episodes where is_downloaded=False are loaded into the + in-memory episodeDict, so downloaded episodes are not shown + as missing. """ from src.core.entities.series import Serie from src.server.database.connection import get_db_session @@ -903,9 +951,14 @@ class AnimeService: series_list = [] for anime_series in anime_series_list: # Build episode_dict from episodes relationship + # Only include episodes that are NOT downloaded (is_downloaded=False) + # so the missing-episode list stays accurate episode_dict: dict[int, list[int]] = {} if anime_series.episodes: for episode in anime_series.episodes: + # Skip downloaded episodes — they are not missing + if episode.is_downloaded: + continue season = episode.season if season not in episode_dict: episode_dict[season] = [] @@ -963,23 +1016,39 @@ class AnimeService: logger.warning("Series not found in database: %s", series_key) return 0 - # Get existing episodes from database + # Get existing episodes from database (all, including downloaded) existing_episodes = await EpisodeService.get_by_series(db, series_db.id) # Build dict of existing episodes: {season: {ep_num: episode_id}} + # and track which ones are already downloaded existing_dict: dict[int, dict[int, int]] = {} + downloaded_set: set[tuple[int, int]] = set() for ep in existing_episodes: if ep.season not in existing_dict: existing_dict[ep.season] = {} existing_dict[ep.season][ep.episode_number] = ep.id + if ep.is_downloaded: + downloaded_set.add((ep.season, ep.episode_number)) # Get new missing episodes from in-memory serie new_dict = serie.episodeDict or {} # Add new missing episodes that are not in the database + # Skip episodes that are already downloaded (is_downloaded=True) + # so we don't re-add them as missing after they've been downloaded for season, episode_numbers in new_dict.items(): existing_season_eps = existing_dict.get(season, {}) for ep_num in episode_numbers: + # Skip if already downloaded — don't re-add as missing + if (season, ep_num) in downloaded_set: + logger.debug( + "Skipping already-downloaded episode: " + "%s S%02dE%02d", + series_key, + season, + ep_num, + ) + continue if ep_num not in existing_season_eps: await EpisodeService.create( db=db, @@ -1015,20 +1084,23 @@ class AnimeService: if hasattr(self._app, 'list') and hasattr(self._app.list, 'keyDict'): serie = self._app.list.keyDict.get(series_key) if serie: - # Convert episode dict keys to strings for JSON - missing_episodes = {str(k): v for k, v in (serie.episodeDict or {}).items()} - total_missing = sum(len(eps) for eps in missing_episodes.values()) - - # Fetch NFO metadata from database + # Fetch NFO metadata and episodes from database. + # Using DB as the authoritative source for missing_episodes + # ensures that episodes marked is_downloaded=True are never + # broadcast as missing, even if in-memory state is stale. has_nfo = False nfo_created_at = None nfo_updated_at = None tmdb_id = None tvdb_id = None + missing_episodes: dict[str, list] = {} try: from src.server.database.connection import get_db_session - from src.server.database.service import AnimeSeriesService + from src.server.database.service import ( + AnimeSeriesService, + EpisodeService, + ) async with get_db_session() as db: db_series = await AnimeSeriesService.get_by_key(db, series_key) @@ -1044,12 +1116,31 @@ class AnimeService: ) tmdb_id = db_series.tmdb_id tvdb_id = db_series.tvdb_id + + # Build missing_episodes from DB, skipping is_downloaded=True + db_episodes = await EpisodeService.get_by_series( + db, db_series.id, only_missing=True + ) + for ep in db_episodes: + key_str = str(ep.season) + if key_str not in missing_episodes: + missing_episodes[key_str] = [] + missing_episodes[key_str].append(ep.episode_number) + for s in missing_episodes: + missing_episodes[s].sort() except Exception as e: logger.warning( - "Could not fetch NFO data for %s: %s", + "Could not fetch series data for %s from DB: %s", series_key, str(e) ) + # Fallback to in-memory state + missing_episodes = { + str(k): v + for k, v in (serie.episodeDict or {}).items() + } + + total_missing = sum(len(eps) for eps in missing_episodes.values()) series_data = { "key": serie.key, diff --git a/src/server/services/download_service.py b/src/server/services/download_service.py index de87f3e..4a6bc5b 100644 --- a/src/server/services/download_service.py +++ b/src/server/services/download_service.py @@ -275,7 +275,7 @@ class DownloadService: """ try: from src.server.database.connection import get_db_session - from src.server.database.service import EpisodeService, AnimeSeriesService + from src.server.database.service import AnimeSeriesService, EpisodeService logger.info( "Attempting to mark episode as downloaded in DB: " @@ -362,6 +362,31 @@ class DownloadService: except Exception: pass + # Broadcast real-time update to frontend so the series card + # immediately reflects the new downloaded state (no longer + # shows the episode as missing) without waiting for a full + # reload on DOWNLOAD_COMPLETED. + try: + await self._anime_service._broadcast_series_updated( + series_key + ) + logger.debug( + "Broadcast series_updated after marking " + "%s S%02dE%02d as downloaded", + series_key, + season, + episode, + ) + except Exception as broadcast_exc: + logger.warning( + "Failed to broadcast series update after marking " + "%s S%02dE%02d as downloaded: %s", + series_key, + season, + episode, + broadcast_exc, + ) + return True except Exception as e: logger.error( diff --git a/src/server/web/static/js/index/series-manager.js b/src/server/web/static/js/index/series-manager.js index 036d916..a4acda5 100644 --- a/src/server/web/static/js/index/series-manager.js +++ b/src/server/web/static/js/index/series-manager.js @@ -203,6 +203,17 @@ AniWorld.SeriesManager = (function() { function applyFiltersAndSort() { let filtered = seriesData.slice(); + // Apply client-side filter so that real-time WebSocket updates + // (e.g. an episode being marked downloaded) are immediately + // reflected without a full server reload. + if (filterMode === 'missing_episodes') { + filtered = filtered.filter(function(s) { + return s.missing_episodes > 0; + }); + } + // 'no_episodes' filter state is maintained server-side; + // don't try to replicate it client-side here. + // Sort based on the current sorting mode filtered.sort(function(a, b) { if (sortAlphabetical) { @@ -233,8 +244,12 @@ AniWorld.SeriesManager = (function() { */ function renderSeries() { const grid = document.getElementById('series-grid'); - const dataToRender = filteredSeriesData.length > 0 ? filteredSeriesData : - (seriesData.length > 0 ? seriesData : []); + // Always use filteredSeriesData — applyFiltersAndSort() is always + // called before renderSeries(), so filteredSeriesData is current. + // The old fallback to seriesData was incorrect: when a filter is + // active and filteredSeriesData is empty it must show the empty-state + // message, not fall through to unfiltered seriesData. + const dataToRender = filteredSeriesData; if (dataToRender.length === 0) { let message; diff --git a/tests/unit/test_download_service.py b/tests/unit/test_download_service.py index 3730125..a396a7b 100644 --- a/tests/unit/test_download_service.py +++ b/tests/unit/test_download_service.py @@ -101,6 +101,7 @@ def mock_anime_service(): service = MagicMock(spec=AnimeService) service.download = AsyncMock(return_value=True) service._directory = "/mock/anime/directory" + service._broadcast_series_updated = AsyncMock(return_value=None) return service @@ -848,6 +849,10 @@ class TestRemoveEpisodeFromMissingList: assert serie.episodeDict[1] == [1, 3] # Cache was cleared download_service._anime_service._cached_list_missing.cache_clear.assert_called() + # Broadcast was sent so frontend gets real-time update + download_service._anime_service._broadcast_series_updated.assert_awaited_once_with( + "test-series" + ) assert result is True @pytest.mark.asyncio