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"])