Fix: Remove episodes from missing list on download/rescan

- Update _update_series_in_db to sync missing episodes bidirectionally
- Add delete_by_series_and_episode method to EpisodeService
- Remove downloaded episodes from DB after successful download
- Clear anime service cache when episodes are removed
- Fix tests to use 'message' instead of 'detail' in API responses
- Mock DB operations in rescan tests
This commit is contained in:
Lukas 2025-12-15 16:17:34 +01:00
parent bf332f27e0
commit 4c9bf6b982
8 changed files with 182 additions and 33 deletions

View File

@ -393,6 +393,51 @@ class EpisodeService:
) )
return result.rowcount > 0 return result.rowcount > 0
@staticmethod
async def delete_by_series_and_episode(
db: AsyncSession,
series_key: str,
season: int,
episode_number: int,
) -> bool:
"""Delete episode by series key, season, and episode number.
Used to remove episodes from the missing list when they are
downloaded successfully.
Args:
db: Database session
series_key: Unique provider key for the series
season: Season number
episode_number: Episode number within season
Returns:
True if deleted, False if not found
"""
# First get the series by key
series = await AnimeSeriesService.get_by_key(db, series_key)
if not series:
logger.warning(
f"Series not found for key: {series_key}"
)
return False
# Then delete the episode
result = await db.execute(
delete(Episode).where(
Episode.series_id == series.id,
Episode.season == season,
Episode.episode_number == episode_number,
)
)
deleted = result.rowcount > 0
if deleted:
logger.info(
f"Removed episode from missing list: "
f"{series_key} S{season:02d}E{episode_number:02d}"
)
return deleted
# ============================================================================ # ============================================================================
# Download Queue Service # Download Queue Service

View File

@ -397,32 +397,65 @@ class AnimeService:
) )
async def _update_series_in_db(self, serie, existing, db) -> None: async def _update_series_in_db(self, serie, existing, db) -> None:
"""Update an existing series in the database.""" """Update an existing series in the database.
Syncs the database episodes with the current missing episodes from scan.
- 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)
"""
from src.server.database.service import AnimeSeriesService, EpisodeService from src.server.database.service import AnimeSeriesService, EpisodeService
# Get existing episodes # Get existing episodes from database
existing_episodes = await EpisodeService.get_by_series(db, existing.id) existing_episodes = await EpisodeService.get_by_series(db, existing.id)
existing_dict: dict[int, list[int]] = {}
# Build dict of existing episodes: {season: {ep_num: episode_id}}
existing_dict: dict[int, dict[int, int]] = {}
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].append(ep.episode_number) existing_dict[ep.season][ep.episode_number] = ep.id
for season in existing_dict:
existing_dict[season].sort()
# Update episodes if changed # Get new missing episodes from scan
if existing_dict != serie.episodeDict:
new_dict = serie.episodeDict or {} new_dict = serie.episodeDict or {}
# Build set of new missing episodes for quick lookup
new_missing_set: set[tuple[int, int]] = set()
for season, episode_numbers in new_dict.items(): for season, episode_numbers in new_dict.items():
existing_eps = set(existing_dict.get(season, []))
for ep_num in episode_numbers: for ep_num in episode_numbers:
if ep_num not in existing_eps: new_missing_set.add((season, ep_num))
# Add new missing episodes that are not in the database
for season, episode_numbers in new_dict.items():
existing_season_eps = existing_dict.get(season, {})
for ep_num in episode_numbers:
if ep_num not in existing_season_eps:
await EpisodeService.create( await EpisodeService.create(
db=db, db=db,
series_id=existing.id, series_id=existing.id,
season=season, season=season,
episode_number=ep_num, episode_number=ep_num,
) )
logger.debug(
"Added missing episode to database: %s S%02dE%02d",
serie.key,
season,
ep_num
)
# Remove episodes from database that are no longer missing
# (i.e., the episode file now exists on the filesystem)
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:
await EpisodeService.delete(db, episode_id)
logger.info(
"Removed episode from database (no longer missing): "
"%s S%02dE%02d",
serie.key,
season,
ep_num
)
# Update folder if changed # Update folder if changed
if existing.folder != serie.folder: if existing.folder != serie.folder:

View File

@ -202,6 +202,57 @@ class DownloadService:
logger.error("Failed to delete from database: %s", e) logger.error("Failed to delete from database: %s", e)
return False return False
async def _remove_episode_from_missing_list(
self,
series_key: str,
season: int,
episode: int,
) -> 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.
Args:
series_key: Unique provider key for the series
season: Season number
episode: Episode number within season
Returns:
True if episode was removed, False otherwise
"""
try:
from src.server.database.connection import get_db_session
from src.server.database.service import EpisodeService
async with get_db_session() as db:
deleted = await EpisodeService.delete_by_series_and_episode(
db=db,
series_key=series_key,
season=season,
episode_number=episode,
)
if deleted:
logger.info(
"Removed episode from 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
except Exception as e:
logger.error(
"Failed to remove episode from missing list: %s", e
)
return False
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.
@ -885,6 +936,13 @@ class DownloadService:
# Delete completed item from database (status is in-memory) # Delete completed item from database (status is in-memory)
await self._delete_from_database(item.id) await self._delete_from_database(item.id)
# Remove episode from missing episodes list in database
await self._remove_episode_from_missing_list(
series_key=item.serie_id,
season=item.episode.season,
episode=item.episode.episode,
)
logger.info( logger.info(
"Download completed successfully: item_id=%s", item.id "Download completed successfully: item_id=%s", item.id
) )

View File

@ -236,7 +236,7 @@ async def test_add_to_queue_service_error(
) )
assert response.status_code == 400 assert response.status_code == 400
assert "Queue full" in response.json()["detail"] assert "Queue full" in response.json()["message"]
@pytest.mark.asyncio @pytest.mark.asyncio
@ -294,8 +294,8 @@ async def test_start_download_empty_queue(
assert response.status_code == 400 assert response.status_code == 400
data = response.json() data = response.json()
detail = data["detail"].lower() message = data["message"].lower()
assert "empty" in detail or "no pending" in detail assert "empty" in message or "no pending" in message
@pytest.mark.asyncio @pytest.mark.asyncio
@ -311,8 +311,8 @@ async def test_start_download_already_active(
assert response.status_code == 400 assert response.status_code == 400
data = response.json() data = response.json()
detail_lower = data["detail"].lower() message_lower = data["message"].lower()
assert "already" in detail_lower or "progress" in detail_lower assert "already" in message_lower or "progress" in message_lower
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@ -201,7 +201,7 @@ class TestFrontendAnimeAPI:
async def test_rescan_anime(self, authenticated_client): async def test_rescan_anime(self, authenticated_client):
"""Test POST /api/anime/rescan triggers rescan with events.""" """Test POST /api/anime/rescan triggers rescan with events."""
from unittest.mock import MagicMock from unittest.mock import MagicMock, patch
from src.server.services.progress_service import ProgressService from src.server.services.progress_service import ProgressService
from src.server.utils.dependencies import get_anime_service from src.server.utils.dependencies import get_anime_service
@ -210,7 +210,7 @@ class TestFrontendAnimeAPI:
mock_series_app = MagicMock() mock_series_app = MagicMock()
mock_series_app.directory_to_search = "/tmp/test" mock_series_app.directory_to_search = "/tmp/test"
mock_series_app.series_list = [] mock_series_app.series_list = []
mock_series_app.rescan = AsyncMock() mock_series_app.rescan = AsyncMock(return_value=[])
mock_series_app.download_status = None mock_series_app.download_status = None
mock_series_app.scan_status = None mock_series_app.scan_status = None
@ -232,7 +232,16 @@ class TestFrontendAnimeAPI:
app.dependency_overrides[get_anime_service] = lambda: anime_service app.dependency_overrides[get_anime_service] = lambda: anime_service
try: try:
response = await authenticated_client.post("/api/anime/rescan") # Mock database operations called during rescan
with patch.object(
anime_service, '_save_scan_results_to_db', new_callable=AsyncMock
):
with patch.object(
anime_service, '_load_series_from_db', new_callable=AsyncMock
):
response = await authenticated_client.post(
"/api/anime/rescan"
)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@ -448,7 +457,7 @@ class TestFrontendJavaScriptIntegration:
assert response.status_code in [200, 400] assert response.status_code in [200, 400]
if response.status_code == 400: if response.status_code == 400:
# Verify error message indicates empty queue # Verify error message indicates empty queue
assert "No pending downloads" in response.json()["detail"] assert "No pending downloads" in response.json()["message"]
# Test pause - always succeeds even if nothing is processing # Test pause - always succeeds even if nothing is processing
response = await authenticated_client.post("/api/queue/pause") response = await authenticated_client.post("/api/queue/pause")

View File

@ -220,7 +220,8 @@ class TestDownloadFlowEndToEnd:
assert response.status_code == 400 assert response.status_code == 400
data = response.json() data = response.json()
assert "detail" in data # API returns 'message' for error responses
assert "message" in data
async def test_validation_error_for_invalid_priority(self, authenticated_client): async def test_validation_error_for_invalid_priority(self, authenticated_client):
"""Test validation error for invalid priority level.""" """Test validation error for invalid priority level."""

View File

@ -6,7 +6,7 @@ real-time updates are properly broadcasted to connected clients.
""" """
import asyncio import asyncio
from typing import Any, Dict, List from typing import Any, Dict, List
from unittest.mock import Mock, patch from unittest.mock import AsyncMock, Mock, patch
import pytest import pytest
@ -64,6 +64,9 @@ async def anime_service(mock_series_app, progress_service):
series_app=mock_series_app, series_app=mock_series_app,
progress_service=progress_service, progress_service=progress_service,
) )
# Mock database operations that are called during rescan
service._save_scan_results_to_db = AsyncMock(return_value=0)
service._load_series_from_db = AsyncMock(return_value=None)
yield service yield service

View File

@ -1,9 +1,9 @@
"""Tests for SerieList class - identifier standardization.""" """Tests for SerieList class - identifier standardization."""
# pylint: disable=redefined-outer-name
import os import os
import tempfile import tempfile
import warnings import warnings
from unittest.mock import MagicMock, patch
import pytest import pytest