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:
parent
bf332f27e0
commit
4c9bf6b982
@ -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
|
||||||
|
|||||||
@ -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 {}
|
|
||||||
for season, episode_numbers in new_dict.items():
|
# Build set of new missing episodes for quick lookup
|
||||||
existing_eps = set(existing_dict.get(season, []))
|
new_missing_set: set[tuple[int, int]] = set()
|
||||||
for ep_num in episode_numbers:
|
for season, episode_numbers in new_dict.items():
|
||||||
if ep_num not in existing_eps:
|
for ep_num in episode_numbers:
|
||||||
await EpisodeService.create(
|
new_missing_set.add((season, ep_num))
|
||||||
db=db,
|
|
||||||
series_id=existing.id,
|
# Add new missing episodes that are not in the database
|
||||||
season=season,
|
for season, episode_numbers in new_dict.items():
|
||||||
episode_number=ep_num,
|
existing_season_eps = existing_dict.get(season, {})
|
||||||
)
|
for ep_num in episode_numbers:
|
||||||
|
if ep_num not in existing_season_eps:
|
||||||
|
await EpisodeService.create(
|
||||||
|
db=db,
|
||||||
|
series_id=existing.id,
|
||||||
|
season=season,
|
||||||
|
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:
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user