diff --git a/src/server/api/anime.py b/src/server/api/anime.py
index 8bfbaee..4883e87 100644
--- a/src/server/api/anime.py
+++ b/src/server/api/anime.py
@@ -15,12 +15,10 @@ from src.server.exceptions import (
ValidationError,
)
from src.server.services.anime_service import AnimeService, AnimeServiceError
-from src.server.services.background_loader_service import (
- BackgroundLoaderService,
- get_background_loader_service,
-)
+from src.server.services.background_loader_service import BackgroundLoaderService
from src.server.utils.dependencies import (
get_anime_service,
+ get_background_loader_service,
get_optional_database_session,
get_series_app,
require_auth,
@@ -641,6 +639,7 @@ async def add_series(
request: AddSeriesRequest,
_auth: dict = Depends(require_auth),
series_app: Any = Depends(get_series_app),
+ anime_service: AnimeService = Depends(get_anime_service),
db: Optional[AsyncSession] = Depends(get_optional_database_session),
background_loader: BackgroundLoaderService = Depends(get_background_loader_service),
) -> dict:
@@ -831,8 +830,44 @@ async def add_series(
key,
e
)
+
+ # Step F: Scan missing episodes immediately if background loader is not running
+ # Uses existing SerieScanner and AnimeService sync to avoid duplicates
+ try:
+ loader_running = (
+ background_loader.worker_task is not None
+ and not background_loader.worker_task.done()
+ )
+ if (
+ not loader_running
+ and series_app
+ and hasattr(series_app, "serie_scanner")
+ ):
+ missing_episodes = series_app.serie_scanner.scan_single_series(
+ key=key,
+ folder=folder
+ )
+ total_missing = sum(
+ len(eps) for eps in missing_episodes.values()
+ )
+ logger.info(
+ "Scanned %d missing episodes for %s",
+ total_missing,
+ key
+ )
+
+ # Persist scan results to database (includes episodes)
+ # scan_single_series updates serie_scanner.keyDict with episodeDict
+ # sync_single_series_after_scan retrieves from there and saves to DB
+ await anime_service.sync_single_series_after_scan(key)
+ except Exception as e:
+ logger.warning(
+ "Failed to scan missing episodes for %s: %s",
+ key,
+ e
+ )
- # Step F: Return immediate response (202 Accepted)
+ # Step G: Return immediate response (202 Accepted)
response = {
"status": "success",
"message": f"Series added successfully: {name}. Data will be loaded in background.",
diff --git a/src/server/fastapi_app.py b/src/server/fastapi_app.py
index c3c5458..ac17e13 100644
--- a/src/server/fastapi_app.py
+++ b/src/server/fastapi_app.py
@@ -251,17 +251,9 @@ async def lifespan(_application: FastAPI):
logger.info("Download service initialized and queue restored")
# Initialize background loader service
- from src.server.services.background_loader_service import (
- init_background_loader_service,
- )
- from src.server.utils.dependencies import get_series_app
+ from src.server.utils.dependencies import get_background_loader_service
- series_app_instance = get_series_app()
- background_loader = init_background_loader_service(
- websocket_service=ws_service,
- anime_service=anime_service,
- series_app=series_app_instance
- )
+ background_loader = get_background_loader_service()
await background_loader.start()
initialized['background_loader'] = True
logger.info("Background loader service started")
diff --git a/src/server/services/anime_service.py b/src/server/services/anime_service.py
index 342bfcd..8f47f5b 100644
--- a/src/server/services/anime_service.py
+++ b/src/server/services/anime_service.py
@@ -658,6 +658,57 @@ class AnimeService:
logger.exception("rescan failed")
raise AnimeServiceError("Rescan failed") from exc
+ async def sync_single_series_after_scan(self, series_key: str) -> None:
+ """Persist a single scanned series and refresh cached state.
+
+ Reuses the same save/reload/cache invalidation flow as `rescan`
+ to keep the database, in-memory list, and UI in sync.
+
+ Args:
+ series_key: Series key to persist and refresh.
+ """
+ # Get serie from scanner's keyDict, not series_app.list.keyDict
+ # scan_single_series updates serie_scanner.keyDict with episodeDict
+ if not hasattr(self._app, "serie_scanner") or not hasattr(self._app.serie_scanner, "keyDict"):
+ logger.warning(
+ "Serie scanner not available for single-series sync: %s",
+ series_key,
+ )
+ return
+
+ serie = self._app.serie_scanner.keyDict.get(series_key)
+ if not serie:
+ logger.warning(
+ "Series not found in scanner keyDict for single-series sync: %s",
+ series_key,
+ )
+ return
+
+ total_episodes = sum(len(eps) for eps in (serie.episodeDict or {}).values())
+ logger.info(
+ "Syncing series %s with %d missing episodes. episodeDict: %s",
+ series_key,
+ total_episodes,
+ serie.episodeDict
+ )
+
+ await self._save_scan_results_to_db([serie])
+ await self._load_series_from_db()
+
+ try:
+ self._cached_list_missing.cache_clear()
+ except Exception: # pylint: disable=broad-except
+ pass
+
+ try:
+ await self._broadcast_series_updated(series_key)
+ except Exception as exc: # pylint: disable=broad-except
+ logger.warning(
+ "Failed to broadcast series update for %s: %s",
+ series_key,
+ exc,
+ )
+
async def _save_scan_results_to_db(self, series_list: list) -> int:
"""
Save scan results to the database.
@@ -684,13 +735,27 @@ class AnimeService:
db, serie.key
)
+ total_episodes = sum(len(eps) for eps in (serie.episodeDict or {}).values())
+
if existing:
# Update existing series
+ logger.info(
+ "Updating existing series %s with %d episodes. episodeDict: %s",
+ serie.key,
+ total_episodes,
+ serie.episodeDict
+ )
await self._update_series_in_db(
serie, existing, db
)
else:
# Create new series
+ logger.info(
+ "Creating new series %s with %d episodes. episodeDict: %s",
+ serie.key,
+ total_episodes,
+ serie.episodeDict
+ )
await self._create_series_in_db(serie, db)
saved_count += 1
@@ -936,17 +1001,79 @@ class AnimeService:
return episodes_added
async def _broadcast_series_updated(self, series_key: str) -> None:
- """Broadcast series update event to WebSocket clients."""
+ """Broadcast series update event to WebSocket clients with full data."""
if not self._websocket_service:
return
+ # Get updated series data to send to frontend
+ series_data = None
+ 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
+ has_nfo = False
+ nfo_created_at = None
+ nfo_updated_at = None
+ tmdb_id = None
+ tvdb_id = None
+
+ try:
+ from src.server.database.connection import get_db_session
+ from src.server.database.service import AnimeSeriesService
+
+ async with get_db_session() as db:
+ db_series = await AnimeSeriesService.get_by_key(db, series_key)
+ if db_series:
+ has_nfo = db_series.has_nfo or False
+ nfo_created_at = (
+ db_series.nfo_created_at.isoformat()
+ if db_series.nfo_created_at else None
+ )
+ nfo_updated_at = (
+ db_series.nfo_updated_at.isoformat()
+ if db_series.nfo_updated_at else None
+ )
+ tmdb_id = db_series.tmdb_id
+ tvdb_id = db_series.tvdb_id
+ except Exception as e:
+ logger.warning(
+ "Could not fetch NFO data for %s: %s",
+ series_key,
+ str(e)
+ )
+
+ series_data = {
+ "key": serie.key,
+ "name": serie.name,
+ "folder": serie.folder,
+ "site": serie.site,
+ "missing_episodes": missing_episodes,
+ "has_missing": total_missing > 0,
+ "has_nfo": has_nfo,
+ "nfo_created_at": nfo_created_at,
+ "nfo_updated_at": nfo_updated_at,
+ "tmdb_id": tmdb_id,
+ "tvdb_id": tvdb_id,
+ }
+
payload = {
"type": "series_updated",
"key": series_key,
- "message": "Episodes updated",
+ "data": series_data,
+ "message": "Series episodes updated",
"timestamp": datetime.now(timezone.utc).isoformat()
}
+ logger.info(
+ "Broadcasting series update for %s with %d missing episodes",
+ series_key,
+ sum(len(eps) for eps in (series_data.get("missing_episodes", {}).values())) if series_data else 0
+ )
+
await self._websocket_service.broadcast(payload)
async def add_series_to_db(
diff --git a/src/server/services/background_loader_service.py b/src/server/services/background_loader_service.py
index 1093336..fdd439e 100644
--- a/src/server/services/background_loader_service.py
+++ b/src/server/services/background_loader_service.py
@@ -626,9 +626,10 @@ class BackgroundLoaderService:
)
# Notify anime_service to sync episodes to database
+ # Use sync_single_series_after_scan which gets data from serie_scanner.keyDict
if self.anime_service:
- logger.debug(f"Calling anime_service.sync_episodes_to_db for {task.key}")
- await self.anime_service.sync_episodes_to_db(task.key)
+ logger.debug(f"Calling anime_service.sync_single_series_after_scan for {task.key}")
+ await self.anime_service.sync_single_series_after_scan(task.key)
else:
logger.warning(f"anime_service not available, episodes will not be synced to DB for {task.key}")
else:
diff --git a/src/server/web/static/js/index/series-manager.js b/src/server/web/static/js/index/series-manager.js
index b000161..ea3a66a 100644
--- a/src/server/web/static/js/index/series-manager.js
+++ b/src/server/web/static/js/index/series-manager.js
@@ -338,6 +338,18 @@ AniWorld.SeriesManager = (function() {
const canBeSelected = hasMissingEpisodes;
const hasNfo = serie.has_nfo || false;
const isLoading = serie.loading_status && serie.loading_status !== 'completed' && serie.loading_status !== 'failed';
+
+ // Debug logging for troubleshooting
+ if (serie.key === 'so-im-a-spider-so-what') {
+ console.log('[createSerieCard] Spider series:', {
+ key: serie.key,
+ missing_episodes: serie.missing_episodes,
+ missing_episodes_type: typeof serie.missing_episodes,
+ episodeDict: serie.episodeDict,
+ hasMissingEpisodes: hasMissingEpisodes,
+ has_missing: serie.has_missing
+ });
+ }
return '
0
+
+ # Verify NFO metadata is present
+ assert "has_nfo" in data
+ assert "nfo_created_at" in data
+ assert "tmdb_id" in data
+ assert "tvdb_id" in data
+
+ async def test_websocket_data_structure_matches_api_format(self):
+ """Test that WebSocket data structure matches expected API format."""
+ # This verifies the data structure that should be sent via WebSocket
+ # matches what the API endpoints return
+
+ # Expected series data structure (from API and WebSocket)
+ series_data = {
+ "key": "test-anime",
+ "name": "Test Anime",
+ "site": "aniworld.to",
+ "folder": "Test Anime (2024)",
+ "missing_episodes": {"1": [1, 2, 3], "2": [1, 2]},
+ "has_missing": True,
+ "has_nfo": False,
+ "nfo_created_at": None,
+ "nfo_updated_at": None,
+ "tmdb_id": None,
+ "tvdb_id": None,
+ }
+
+ # Verify required fields exist
+ required_fields = [
+ "key", "name", "folder", "site",
+ "missing_episodes", "has_missing",
+ "has_nfo"
+ ]
+ for field in required_fields:
+ assert field in series_data, f"Missing required field: {field}"
+
+ # Verify missing_episodes is a dictionary with string keys
+ assert isinstance(series_data["missing_episodes"], dict)
+ for season_key, episodes in series_data["missing_episodes"].items():
+ assert isinstance(season_key, str), "Season keys must be strings for JSON"
+ assert isinstance(episodes, list), "Episodes must be a list"
+
+ # Calculate total episodes (what JavaScript does)
+ total_episodes = sum(len(eps) for eps in series_data["missing_episodes"].values())
+ assert total_episodes == 5 # 3 + 2
+
+ # Verify has_missing reflects episode count
+ assert series_data["has_missing"] is True
+ assert total_episodes > 0
+
+ async def test_series_card_elements_for_missing_episodes(self):
+ """Test that series card HTML elements are correct for series with missing episodes."""
+ # This test validates the expected HTML structure
+ # The actual rendering happens in JavaScript, so we test the data flow
+
+ # Expected WebSocket data format
+ websocket_data = {
+ "key": "so-im-a-spider-so-what",
+ "name": "So I'm a Spider, So What?",
+ "folder": "So I'm a Spider, So What",
+ "site": "aniworld.to",
+ "missing_episodes": {"1": list(range(1, 25))}, # Season 1: episodes 1-24
+ "has_missing": True,
+ "has_nfo": True,
+ "nfo_created_at": "2026-01-23T20:14:31.565228",
+ "nfo_updated_at": None,
+ "tmdb_id": None,
+ "tvdb_id": None,
+ }
+
+ # Expected HTML elements that should be generated:
+ # 1. Series card should have class "has-missing" (not "complete")
+ # 2. Checkbox should be enabled (not disabled)
+ # 3. Status text should show "24 missing episodes" (not "Complete")
+ # 4. Status icon should be fa-exclamation-triangle (not fa-check)
+ # 5. NFO badge should have class "nfo-exists" (since has_nfo is True)
+
+ # Calculate expected episode count
+ total_episodes = sum(len(eps) for eps in websocket_data["missing_episodes"].values())
+ assert total_episodes == 24, "Should count 24 missing episodes"
+
+ # Expected HTML patterns that should be generated by JavaScript
+ expected_patterns = {
+ "card_class": "has-missing", # Not "complete"
+ "checkbox_disabled": False, # Should be enabled
+ "status_text": f"{total_episodes} missing episodes", # Not "Complete"
+ "status_icon": "fa-exclamation-triangle", # Not "fa-check"
+ "nfo_badge_class": "nfo-exists", # Since has_nfo is True
+ }
+
+ # Verify data structure for JavaScript processing
+ assert websocket_data["has_missing"] is True
+ assert isinstance(websocket_data["missing_episodes"], dict)
+ assert len(websocket_data["missing_episodes"]) > 0
+ assert websocket_data["has_nfo"] is True
+
+ # The JavaScript should:
+ # 1. Count total episodes: Object.values(episodeDict).reduce(...)
+ # 2. Set hasMissingEpisodes = totalMissing > 0 (should be True)
+ # 3. Add "has-missing" class to card
+ # 4. Enable checkbox (canBeSelected = hasMissingEpisodes)
+ # 5. Show episode count instead of "Complete"
+
+ return expected_patterns
+
+ async def test_series_card_elements_for_complete_series(self):
+ """Test that series card HTML elements are correct for complete series."""
+ # Expected WebSocket data format for complete series
+ websocket_data = {
+ "key": "complete-anime",
+ "name": "Complete Anime",
+ "folder": "Complete Anime (2024)",
+ "site": "aniworld.to",
+ "missing_episodes": {}, # No missing episodes
+ "has_missing": False,
+ "has_nfo": True,
+ "nfo_created_at": "2026-01-23T20:14:31.565228",
+ "nfo_updated_at": None,
+ "tmdb_id": "12345",
+ "tvdb_id": "67890",
+ }
+
+ # Expected HTML elements for complete series:
+ # 1. Series card should have class "complete" (not "has-missing")
+ # 2. Checkbox should be disabled
+ # 3. Status text should show "Complete" (not episode count)
+ # 4. Status icon should be fa-check (not fa-exclamation-triangle)
+ # 5. Status icon should have class "status-complete"
+
+ # Calculate expected episode count
+ total_episodes = sum(len(eps) for eps in websocket_data["missing_episodes"].values())
+ assert total_episodes == 0, "Should have no missing episodes"
+
+ # Expected HTML patterns
+ expected_patterns = {
+ "card_class": "complete", # Not "has-missing"
+ "checkbox_disabled": True, # Should be disabled
+ "status_text": "Complete", # Not episode count
+ "status_icon": "fa-check", # Not "fa-exclamation-triangle"
+ "status_icon_class": "status-complete",
+ }
+
+ # Verify data structure
+ assert websocket_data["has_missing"] is False
+ assert isinstance(websocket_data["missing_episodes"], dict)
+ assert len(websocket_data["missing_episodes"]) == 0
+
+ return expected_patterns
+
+ async def test_javascript_episode_counting_logic(self):
+ """Test the logic for counting episodes from dictionary structure."""
+ # Test various episode dictionary structures
+
+ test_cases = [
+ {
+ "name": "Single season with multiple episodes",
+ "episode_dict": {"1": [1, 2, 3, 4, 5]},
+ "expected_count": 5
+ },
+ {
+ "name": "Multiple seasons",
+ "episode_dict": {"1": [1, 2, 3], "2": [1, 2], "3": [1]},
+ "expected_count": 6
+ },
+ {
+ "name": "Season with large episode count",
+ "episode_dict": {"1": list(range(1, 25))}, # 24 episodes
+ "expected_count": 24
+ },
+ {
+ "name": "Empty dictionary",
+ "episode_dict": {},
+ "expected_count": 0
+ },
+ {
+ "name": "Season with empty array",
+ "episode_dict": {"1": []},
+ "expected_count": 0
+ },
+ ]
+
+ for test_case in test_cases:
+ episode_dict = test_case["episode_dict"]
+ expected = test_case["expected_count"]
+
+ # Simulate JavaScript logic:
+ # Object.values(episodeDict).reduce((sum, episodes) => sum + episodes.length, 0)
+ actual = sum(len(eps) for eps in episode_dict.values())
+
+ assert actual == expected, (
+ f"Failed for {test_case['name']}: "
+ f"expected {expected}, got {actual}"
+ )
+
+ async def test_websocket_payload_structure(self):
+ """Test the complete WebSocket payload structure sent by backend."""
+ from datetime import timezone
+
+ # This is the structure sent by _broadcast_series_updated
+ websocket_payload = {
+ "type": "series_updated",
+ "key": "test-series",
+ "data": {
+ "key": "test-series",
+ "name": "Test Series",
+ "folder": "Test Series (2024)",
+ "site": "aniworld.to",
+ "missing_episodes": {"1": [1, 2, 3]},
+ "has_missing": True,
+ "has_nfo": True,
+ "nfo_created_at": "2026-01-23T20:14:31.565228",
+ "nfo_updated_at": None,
+ "tmdb_id": "12345",
+ "tvdb_id": "67890",
+ },
+ "message": "Series episodes updated",
+ "timestamp": datetime.now(timezone.utc).isoformat()
+ }
+
+ # Verify top-level structure
+ assert "type" in websocket_payload
+ assert "key" in websocket_payload
+ assert "data" in websocket_payload
+ assert "message" in websocket_payload
+ assert "timestamp" in websocket_payload
+
+ # Verify event type
+ assert websocket_payload["type"] == "series_updated"
+
+ # Verify data structure
+ data = websocket_payload["data"]
+ required_fields = [
+ "key", "name", "folder", "site",
+ "missing_episodes", "has_missing",
+ "has_nfo", "nfo_created_at", "nfo_updated_at",
+ "tmdb_id", "tvdb_id"
+ ]
+
+ for field in required_fields:
+ assert field in data, f"Missing required field: {field}"
+
+ # Verify data types
+ assert isinstance(data["key"], str)
+ assert isinstance(data["name"], str)
+ assert isinstance(data["folder"], str)
+ assert isinstance(data["site"], str)
+ assert isinstance(data["missing_episodes"], dict)
+ assert isinstance(data["has_missing"], bool)
+ assert isinstance(data["has_nfo"], bool)
+
+ # Verify missing_episodes structure
+ for season, episodes in data["missing_episodes"].items():
+ assert isinstance(season, str), "Season keys should be strings"
+ assert isinstance(episodes, list), "Episode values should be lists"
+ for ep in episodes:
+ assert isinstance(ep, int), "Episodes should be integers"
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git a/tests/unit/test_add_series_episodes.py b/tests/unit/test_add_series_episodes.py
new file mode 100644
index 0000000..f11f1f5
--- /dev/null
+++ b/tests/unit/test_add_series_episodes.py
@@ -0,0 +1,439 @@
+"""Unit tests for adding series with episode scanning.
+
+This module tests the complete flow of adding a series:
+1. Series is added to database
+2. Episodes are scanned
+3. Episodes are saved to database
+4. GUI is updated via WebSocket
+
+All tests use mocks to avoid network traffic.
+"""
+from datetime import datetime
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
+
+import pytest
+
+from src.core.entities.series import Serie
+from src.server.database.models import AnimeSeries, Episode
+
+
+@pytest.fixture
+def mock_series_app():
+ """Create a mock SeriesApp with scanner."""
+ app = MagicMock()
+
+ # Mock serie_scanner
+ app.serie_scanner = MagicMock()
+ app.serie_scanner.keyDict = {}
+
+ # Mock list
+ app.list = MagicMock()
+ app.list.keyDict = {}
+
+ # Mock loader
+ app.loader = MagicMock()
+ app.loader.get_year = MagicMock(return_value=2024)
+
+ return app
+
+
+@pytest.fixture
+def mock_db_session():
+ """Create a mock database session."""
+ session = AsyncMock()
+ session.commit = AsyncMock()
+ session.rollback = AsyncMock()
+ session.close = AsyncMock()
+ session.flush = AsyncMock()
+ session.refresh = AsyncMock()
+ return session
+
+
+@pytest.fixture
+def mock_anime_service(mock_series_app):
+ """Create a mock AnimeService."""
+ from src.server.services.anime_service import AnimeService
+
+ service = AnimeService(mock_series_app)
+ return service
+
+
+@pytest.mark.asyncio
+class TestAddSeriesWithEpisodes:
+ """Test suite for adding series with episode scanning."""
+
+ async def test_scan_single_series_updates_scanner_keydict(
+ self,
+ mock_series_app
+ ):
+ """Test that scan_single_series updates serie_scanner.keyDict."""
+ # Arrange
+ key = "test-anime"
+ folder = "Test Anime (2024)"
+
+ # Mock scan_single_series to update keyDict
+ def mock_scan(key, folder):
+ # Create Serie with episodes
+ serie = Serie(
+ key=key,
+ name="Test Anime",
+ site="aniworld.to",
+ folder=folder,
+ episodeDict={1: [1, 2, 3]},
+ year=2024
+ )
+ # Update scanner's keyDict
+ mock_series_app.serie_scanner.keyDict[key] = serie
+ return {1: [1, 2, 3]}
+
+ mock_series_app.serie_scanner.scan_single_series = mock_scan
+
+ # Act
+ result = mock_series_app.serie_scanner.scan_single_series(key, folder)
+
+ # Assert
+ assert key in mock_series_app.serie_scanner.keyDict
+ serie = mock_series_app.serie_scanner.keyDict[key]
+ assert serie.episodeDict == {1: [1, 2, 3]}
+ assert len(serie.episodeDict[1]) == 3
+
+ async def test_sync_single_series_gets_from_scanner_keydict(
+ self,
+ mock_series_app,
+ mock_anime_service
+ ):
+ """Test that sync_single_series_after_scan gets Serie from scanner.keyDict."""
+ # Arrange
+ key = "test-anime"
+
+ # Create Serie in scanner's keyDict with episodes
+ serie = Serie(
+ key=key,
+ name="Test Anime",
+ site="aniworld.to",
+ folder="Test Anime (2024)",
+ episodeDict={1: [1, 2, 3], 2: [1, 2]},
+ year=2024
+ )
+ mock_series_app.serie_scanner.keyDict[key] = serie
+
+ # Mock the database save method
+ with patch.object(
+ mock_anime_service,
+ '_save_scan_results_to_db',
+ new_callable=AsyncMock
+ ) as mock_save:
+ mock_save.return_value = 1
+
+ with patch.object(
+ mock_anime_service,
+ '_load_series_from_db',
+ new_callable=AsyncMock
+ ):
+ with patch.object(
+ mock_anime_service,
+ '_broadcast_series_updated',
+ new_callable=AsyncMock
+ ):
+ # Act
+ await mock_anime_service.sync_single_series_after_scan(key)
+
+ # Assert
+ mock_save.assert_called_once()
+ series_list = mock_save.call_args[0][0]
+ assert len(series_list) == 1
+ saved_serie = series_list[0]
+ assert saved_serie.key == key
+ assert saved_serie.episodeDict == {1: [1, 2, 3], 2: [1, 2]}
+
+ async def test_save_scan_results_creates_episodes_in_db(
+ self,
+ mock_anime_service,
+ mock_db_session
+ ):
+ """Test that _save_scan_results_to_db creates episodes."""
+ # Arrange
+ serie = Serie(
+ key="test-anime",
+ name="Test Anime",
+ site="aniworld.to",
+ folder="Test Anime (2024)",
+ episodeDict={1: [1, 2, 3], 2: [1, 2]},
+ year=2024
+ )
+
+ # Mock database services
+ with patch('src.server.database.connection.get_db_session') as mock_get_db:
+ # Setup context manager for database session
+ mock_get_db.return_value.__aenter__.return_value = mock_db_session
+ mock_get_db.return_value.__aexit__.return_value = None
+
+ with patch('src.server.database.service.AnimeSeriesService') as mock_series_service:
+ with patch('src.server.database.service.EpisodeService') as mock_episode_service:
+ # Series doesn't exist - will create new
+ mock_series_service.get_by_key = AsyncMock(return_value=None)
+
+ # Mock create to return a series with ID
+ mock_db_series = MagicMock()
+ mock_db_series.id = 1
+ mock_db_series.key = "test-anime"
+ mock_series_service.create = AsyncMock(return_value=mock_db_series)
+
+ # Mock episode creation
+ episode_create_calls = []
+ async def track_episode_create(db, series_id, season, episode_number):
+ episode_create_calls.append((series_id, season, episode_number))
+ ep = MagicMock()
+ ep.id = len(episode_create_calls)
+ ep.series_id = series_id
+ ep.season = season
+ ep.episode_number = episode_number
+ return ep
+
+ mock_episode_service.create = AsyncMock(side_effect=track_episode_create)
+
+ # Act
+ result = await mock_anime_service._save_scan_results_to_db([serie])
+
+ # Assert
+ assert result == 1 # One series saved
+
+ # Verify episodes were created
+ assert len(episode_create_calls) == 5 # 3 from season 1, 2 from season 2
+
+ # Check season 1 episodes
+ assert (1, 1, 1) in episode_create_calls
+ assert (1, 1, 2) in episode_create_calls
+ assert (1, 1, 3) in episode_create_calls
+
+ # Check season 2 episodes
+ assert (1, 2, 1) in episode_create_calls
+ assert (1, 2, 2) in episode_create_calls
+
+ async def test_update_series_adds_missing_episodes(
+ self,
+ mock_anime_service,
+ mock_db_session
+ ):
+ """Test that _update_series_in_db adds new missing episodes."""
+ # Arrange
+ serie = Serie(
+ key="test-anime",
+ name="Test Anime",
+ site="aniworld.to",
+ folder="Test Anime (2024)",
+ episodeDict={1: [1, 2, 3, 4]}, # 4 episodes
+ year=2024
+ )
+
+ # Existing series in DB with only 2 episodes
+ existing_db_series = MagicMock()
+ existing_db_series.id = 1
+ existing_db_series.key = "test-anime"
+ existing_db_series.folder = "Test Anime (2024)"
+
+ # Mock existing episodes in DB
+ existing_episode_1 = MagicMock()
+ existing_episode_1.id = 1
+ existing_episode_1.series_id = 1
+ existing_episode_1.season = 1
+ existing_episode_1.episode_number = 1
+
+ existing_episode_2 = MagicMock()
+ existing_episode_2.id = 2
+ existing_episode_2.series_id = 1
+ existing_episode_2.season = 1
+ existing_episode_2.episode_number = 2
+
+ existing_episodes = [existing_episode_1, existing_episode_2]
+
+ with patch('src.server.database.connection.get_db_session') as mock_get_db:
+ mock_get_db.return_value.__aenter__.return_value = mock_db_session
+ mock_get_db.return_value.__aexit__.return_value = None
+
+ with patch('src.server.database.service.AnimeSeriesService') as mock_series_service:
+ with patch('src.server.database.service.EpisodeService') as mock_episode_service:
+ # Series exists
+ mock_series_service.get_by_key = AsyncMock(return_value=existing_db_series)
+
+ # Mock get_by_series to return existing episodes
+ mock_episode_service.get_by_series = AsyncMock(return_value=existing_episodes)
+
+ # Track new episodes created
+ new_episodes_created = []
+ async def track_episode_create(db, series_id, season, episode_number):
+ new_episodes_created.append((series_id, season, episode_number))
+ return MagicMock()
+
+ mock_episode_service.create = AsyncMock(side_effect=track_episode_create)
+ mock_episode_service.delete = AsyncMock()
+
+ # Act
+ result = await mock_anime_service._save_scan_results_to_db([serie])
+
+ # Assert
+ assert result == 1
+
+ # Should create 2 new episodes (episode 3 and 4)
+ assert len(new_episodes_created) == 2
+ assert (1, 1, 3) in new_episodes_created
+ assert (1, 1, 4) in new_episodes_created
+
+ async def test_complete_add_series_flow(
+ self,
+ mock_series_app
+ ):
+ """Integration test for complete add series flow."""
+ from src.server.services.anime_service import AnimeService
+
+ # Arrange
+ key = "test-anime"
+ folder = "Test Anime (2024)"
+
+ # Setup mock scanner to populate keyDict
+ def mock_scan(key, folder):
+ serie = Serie(
+ key=key,
+ name="Test Anime",
+ site="aniworld.to",
+ folder=folder,
+ episodeDict={1: [1, 2, 3]},
+ year=2024
+ )
+ mock_series_app.serie_scanner.keyDict[key] = serie
+ return {1: [1, 2, 3]}
+
+ mock_series_app.serie_scanner.scan_single_series = mock_scan
+
+ # Create service
+ anime_service = AnimeService(mock_series_app)
+
+ # Mock database operations
+ with patch('src.server.database.connection.get_db_session') as mock_get_db:
+ mock_db = AsyncMock()
+ mock_get_db.return_value.__aenter__.return_value = mock_db
+ mock_get_db.return_value.__aexit__.return_value = None
+
+ with patch('src.server.database.service.AnimeSeriesService') as mock_series_service:
+ with patch('src.server.database.service.EpisodeService') as mock_episode_service:
+ # Series doesn't exist
+ mock_series_service.get_by_key = AsyncMock(return_value=None)
+
+ # Mock series creation
+ mock_db_series = MagicMock()
+ mock_db_series.id = 1
+ mock_series_service.create = AsyncMock(return_value=mock_db_series)
+
+ # Track episodes
+ episodes_created = []
+ async def track_create(db, series_id, season, episode_number):
+ episodes_created.append((season, episode_number))
+ return MagicMock()
+
+ mock_episode_service.create = AsyncMock(side_effect=track_create)
+
+ # Mock other methods
+ with patch.object(anime_service, '_load_series_from_db', new_callable=AsyncMock):
+ with patch.object(anime_service, '_broadcast_series_updated', new_callable=AsyncMock):
+ # Act
+ # 1. Scan episodes
+ result = mock_series_app.serie_scanner.scan_single_series(key, folder)
+
+ # 2. Sync to database
+ await anime_service.sync_single_series_after_scan(key)
+
+ # Assert
+ # Episodes were scanned
+ assert result == {1: [1, 2, 3]}
+
+ # Serie was added to scanner keyDict
+ assert key in mock_series_app.serie_scanner.keyDict
+
+ # Episodes were saved to DB
+ assert len(episodes_created) == 3
+ assert (1, 1) in episodes_created
+ assert (1, 2) in episodes_created
+ assert (1, 3) in episodes_created
+
+ async def test_websocket_broadcast_on_series_update(
+ self,
+ mock_series_app
+ ):
+ """Test that WebSocket broadcasts series_updated event with complete data including NFO fields."""
+ from unittest.mock import AsyncMock, MagicMock, patch
+
+ from src.server.database.models import AnimeSeries
+ from src.server.services.anime_service import AnimeService
+
+ # Arrange
+ key = "test-anime"
+
+ # Create Serie in list.keyDict with episodes
+ serie = Serie(
+ key=key,
+ name="Test Anime",
+ site="aniworld.to",
+ folder="Test Anime (2024)",
+ episodeDict={1: [1, 2, 3]},
+ year=2024
+ )
+ mock_series_app.list.keyDict[key] = serie
+
+ # Mock database AnimeSeries with NFO data
+ mock_db_series = AnimeSeries(
+ key=key,
+ name="Test Anime",
+ folder="Test Anime (2024)",
+ site="aniworld.to",
+ year=2024,
+ has_nfo=True,
+ tmdb_id="12345",
+ tvdb_id="67890",
+ nfo_created_at=datetime(2024, 1, 1, 12, 0, 0),
+ nfo_updated_at=datetime(2024, 1, 2, 12, 0, 0)
+ )
+
+ # Create service with mocked WebSocket
+ anime_service = AnimeService(mock_series_app)
+ mock_websocket = AsyncMock()
+ anime_service._websocket_service = mock_websocket
+
+ # Mock database session and service
+ mock_db_session = AsyncMock()
+ mock_db_session.__aenter__ = AsyncMock(return_value=mock_db_session)
+ mock_db_session.__aexit__ = AsyncMock()
+
+ with patch('src.server.database.connection.get_db_session', return_value=mock_db_session):
+ with patch('src.server.database.service.AnimeSeriesService') as MockAnimeSeriesService:
+ MockAnimeSeriesService.get_by_key = AsyncMock(return_value=mock_db_series)
+
+ # Act
+ await anime_service._broadcast_series_updated(key)
+
+ # Assert
+ mock_websocket.broadcast.assert_called_once()
+ call_args = mock_websocket.broadcast.call_args[0][0]
+
+ # Verify payload structure
+ assert call_args["type"] == "series_updated"
+ assert call_args["key"] == key
+ assert "data" in call_args
+
+ # Verify basic series data
+ assert call_args["data"]["key"] == key
+ assert call_args["data"]["name"] == "Test Anime"
+ assert call_args["data"]["missing_episodes"] == {"1": [1, 2, 3]}
+ assert call_args["data"]["has_missing"] is True
+
+ # Verify NFO metadata fields are included
+ assert call_args["data"]["has_nfo"] is True
+ assert call_args["data"]["tmdb_id"] == "12345"
+ assert call_args["data"]["tvdb_id"] == "67890"
+ assert call_args["data"]["nfo_created_at"] == "2024-01-01T12:00:00"
+ assert call_args["data"]["nfo_updated_at"] == "2024-01-02T12:00:00"
+
+ assert "timestamp" in call_args
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])