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
This commit is contained in:
2026-05-25 21:30:31 +02:00
parent a336733ea9
commit 9a81b04b65
5 changed files with 161 additions and 18 deletions

View File

@@ -541,6 +541,7 @@ class EpisodeService:
db: AsyncSession, db: AsyncSession,
series_id: int, series_id: int,
season: Optional[int] = None, season: Optional[int] = None,
only_missing: bool = False,
) -> List[Episode]: ) -> List[Episode]:
"""Get episodes for a series. """Get episodes for a series.
@@ -548,6 +549,9 @@ class EpisodeService:
db: Database session db: Database session
series_id: Foreign key to AnimeSeries series_id: Foreign key to AnimeSeries
season: Optional season filter 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: Returns:
List of Episode instances List of Episode instances
@@ -557,6 +561,9 @@ class EpisodeService:
if season is not None: if season is not None:
query = query.where(Episode.season == season) 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) query = query.order_by(Episode.season, Episode.episode_number)
result = await db.execute(query) result = await db.execute(query)
return list(result.scalars().all()) return list(result.scalars().all())

View File

@@ -498,13 +498,19 @@ class AnimeService:
logger.info("No series found in SeriesApp") logger.info("No series found in SeriesApp")
return [] return []
# Build NFO metadata map and filter data from database # Build NFO metadata map, episode dict, and filter data from database.
nfo_map = {} # Using DB as authoritative source for episodeDict ensures that
series_with_no_episodes = set() # 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: async with get_db_session() as db:
# Get all series NFO metadata using service layer # Single query: load all series with their episodes eagerly
db_series_list = await AnimeSeriesService.get_all(db) db_series_list = await AnimeSeriesService.get_all(
db, with_episodes=True
)
for db_series in db_series_list: for db_series in db_series_list:
nfo_created = ( nfo_created = (
@@ -523,6 +529,20 @@ class AnimeService:
"tvdb_id": db_series.tvdb_id, "tvdb_id": db_series.tvdb_id,
"series_id": db_series.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 is "missing_episodes", get series with any missing episodes
if filter_type == "missing_episodes": if filter_type == "missing_episodes":
@@ -545,7 +565,12 @@ class AnimeService:
name = getattr(serie, "name", "") name = getattr(serie, "name", "")
site = getattr(serie, "site", "") site = getattr(serie, "site", "")
folder = getattr(serie, "folder", "") 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 # Apply filter if specified
if filter_type == "missing_episodes": if filter_type == "missing_episodes":
@@ -815,18 +840,24 @@ class AnimeService:
- Adds new missing episodes that are not in the database - Adds new missing episodes that are not in the database
- Removes episodes from database that are no longer missing - Removes episodes from database that are no longer missing
(i.e., the file has been added to the filesystem) (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 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) existing_episodes = await EpisodeService.get_by_series(db, existing.id)
# Build dict of existing episodes: {season: {ep_num: episode_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]] = {} existing_dict: dict[int, dict[int, int]] = {}
downloaded_set: set[tuple[int, int]] = set()
for ep in existing_episodes: for ep in existing_episodes:
if ep.season not in existing_dict: if ep.season not in existing_dict:
existing_dict[ep.season] = {} existing_dict[ep.season] = {}
existing_dict[ep.season][ep.episode_number] = ep.id 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 # Get new missing episodes from scan
new_dict = serie.episodeDict or {} new_dict = serie.episodeDict or {}
@@ -857,9 +888,22 @@ class AnimeService:
# Remove episodes from database that are no longer missing # Remove episodes from database that are no longer missing
# (i.e., the episode file now exists on the filesystem) # (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 season, eps_dict in existing_dict.items():
for ep_num, episode_id in eps_dict.items(): for ep_num, episode_id in eps_dict.items():
if (season, ep_num) not in new_missing_set: 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) await EpisodeService.delete(db, episode_id)
logger.info( logger.info(
"Removed episode from database (no longer missing): " "Removed episode from database (no longer missing): "
@@ -889,6 +933,10 @@ class AnimeService:
This method is called during initialization and after rescans This method is called during initialization and after rescans
to ensure the in-memory series list is in sync with the database. 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.core.entities.series import Serie
from src.server.database.connection import get_db_session from src.server.database.connection import get_db_session
@@ -903,9 +951,14 @@ class AnimeService:
series_list = [] series_list = []
for anime_series in anime_series_list: for anime_series in anime_series_list:
# Build episode_dict from episodes relationship # 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]] = {} episode_dict: dict[int, list[int]] = {}
if anime_series.episodes: if anime_series.episodes:
for episode in anime_series.episodes: for episode in anime_series.episodes:
# Skip downloaded episodes — they are not missing
if episode.is_downloaded:
continue
season = episode.season season = episode.season
if season not in episode_dict: if season not in episode_dict:
episode_dict[season] = [] episode_dict[season] = []
@@ -963,23 +1016,39 @@ class AnimeService:
logger.warning("Series not found in database: %s", series_key) logger.warning("Series not found in database: %s", series_key)
return 0 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) existing_episodes = await EpisodeService.get_by_series(db, series_db.id)
# Build dict of existing episodes: {season: {ep_num: episode_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]] = {} existing_dict: dict[int, dict[int, int]] = {}
downloaded_set: set[tuple[int, int]] = set()
for ep in existing_episodes: for ep in existing_episodes:
if ep.season not in existing_dict: if ep.season not in existing_dict:
existing_dict[ep.season] = {} existing_dict[ep.season] = {}
existing_dict[ep.season][ep.episode_number] = ep.id 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 # Get new missing episodes from in-memory serie
new_dict = serie.episodeDict or {} new_dict = serie.episodeDict or {}
# Add new missing episodes that are not in the database # 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(): for season, episode_numbers in new_dict.items():
existing_season_eps = existing_dict.get(season, {}) existing_season_eps = existing_dict.get(season, {})
for ep_num in episode_numbers: 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: if ep_num not in existing_season_eps:
await EpisodeService.create( await EpisodeService.create(
db=db, db=db,
@@ -1015,20 +1084,23 @@ class AnimeService:
if hasattr(self._app, 'list') and hasattr(self._app.list, 'keyDict'): if hasattr(self._app, 'list') and hasattr(self._app.list, 'keyDict'):
serie = self._app.list.keyDict.get(series_key) serie = self._app.list.keyDict.get(series_key)
if serie: if serie:
# Convert episode dict keys to strings for JSON # Fetch NFO metadata and episodes from database.
missing_episodes = {str(k): v for k, v in (serie.episodeDict or {}).items()} # Using DB as the authoritative source for missing_episodes
total_missing = sum(len(eps) for eps in missing_episodes.values()) # ensures that episodes marked is_downloaded=True are never
# broadcast as missing, even if in-memory state is stale.
# Fetch NFO metadata from database
has_nfo = False has_nfo = False
nfo_created_at = None nfo_created_at = None
nfo_updated_at = None nfo_updated_at = None
tmdb_id = None tmdb_id = None
tvdb_id = None tvdb_id = None
missing_episodes: dict[str, list] = {}
try: try:
from src.server.database.connection import get_db_session 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: async with get_db_session() as db:
db_series = await AnimeSeriesService.get_by_key(db, series_key) db_series = await AnimeSeriesService.get_by_key(db, series_key)
@@ -1044,12 +1116,31 @@ class AnimeService:
) )
tmdb_id = db_series.tmdb_id tmdb_id = db_series.tmdb_id
tvdb_id = db_series.tvdb_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: except Exception as e:
logger.warning( logger.warning(
"Could not fetch NFO data for %s: %s", "Could not fetch series data for %s from DB: %s",
series_key, series_key,
str(e) 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 = { series_data = {
"key": serie.key, "key": serie.key,

View File

@@ -275,7 +275,7 @@ class DownloadService:
""" """
try: try:
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, AnimeSeriesService from src.server.database.service import AnimeSeriesService, EpisodeService
logger.info( logger.info(
"Attempting to mark episode as downloaded in DB: " "Attempting to mark episode as downloaded in DB: "
@@ -362,6 +362,31 @@ class DownloadService:
except Exception: except Exception:
pass 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 return True
except Exception as e: except Exception as e:
logger.error( logger.error(

View File

@@ -203,6 +203,17 @@ AniWorld.SeriesManager = (function() {
function applyFiltersAndSort() { function applyFiltersAndSort() {
let filtered = seriesData.slice(); 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 // Sort based on the current sorting mode
filtered.sort(function(a, b) { filtered.sort(function(a, b) {
if (sortAlphabetical) { if (sortAlphabetical) {
@@ -233,8 +244,12 @@ AniWorld.SeriesManager = (function() {
*/ */
function renderSeries() { function renderSeries() {
const grid = document.getElementById('series-grid'); const grid = document.getElementById('series-grid');
const dataToRender = filteredSeriesData.length > 0 ? filteredSeriesData : // Always use filteredSeriesData — applyFiltersAndSort() is always
(seriesData.length > 0 ? seriesData : []); // 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) { if (dataToRender.length === 0) {
let message; let message;

View File

@@ -101,6 +101,7 @@ def mock_anime_service():
service = MagicMock(spec=AnimeService) service = MagicMock(spec=AnimeService)
service.download = AsyncMock(return_value=True) service.download = AsyncMock(return_value=True)
service._directory = "/mock/anime/directory" service._directory = "/mock/anime/directory"
service._broadcast_series_updated = AsyncMock(return_value=None)
return service return service
@@ -848,6 +849,10 @@ class TestRemoveEpisodeFromMissingList:
assert serie.episodeDict[1] == [1, 3] assert serie.episodeDict[1] == [1, 3]
# Cache was cleared # Cache was cleared
download_service._anime_service._cached_list_missing.cache_clear.assert_called() 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 assert result is True
@pytest.mark.asyncio @pytest.mark.asyncio