From 2be7b692b97429331f2e84d9d112e50c991d227d Mon Sep 17 00:00:00 2001 From: Lukas Date: Thu, 4 Jun 2026 22:04:46 +0200 Subject: [PATCH] Fix get_all_series_from_data_files to read data files directly Previously, the method created a SerieList instance which only loads from database, not from data files. Now reads data files directly and parses JSON to create AnimeSeries objects. Also added _load_data_file helper function and fixed logger.warning calls to use proper format strings instead of keyword arguments. Updated unit tests to use real temp directories instead of mocks. --- src/server/SeriesApp.py | 96 ++++++++++++++++-- tests/unit/test_series_app.py | 186 +++++++++++++++------------------- 2 files changed, 170 insertions(+), 112 deletions(-) diff --git a/src/server/SeriesApp.py b/src/server/SeriesApp.py index 078e093..1e61427 100644 --- a/src/server/SeriesApp.py +++ b/src/server/SeriesApp.py @@ -697,11 +697,15 @@ class SeriesApp: self.directory_to_search ) - # Create a fresh SerieList instance for file-based loading - # This ensures we get all series from data files without - # interfering with the main instance's state + all_series: List[AnimeSeries] = [] + try: - temp_list = SerieList(self.directory_to_search) + if not os.path.isdir(self.directory_to_search): + logger.warning( + "Directory does not exist: %s", + self.directory_to_search + ) + return [] except (OSError, ValueError) as e: logger.error( "Failed to scan directory for data files: %s", @@ -710,8 +714,53 @@ class SeriesApp: ) return [] - # Get all series from the temporary list - all_series = temp_list.get_all() + try: + for folder_name in os.listdir(self.directory_to_search): + folder_path = os.path.join( + self.directory_to_search, folder_name + ) + if not os.path.isdir(folder_path): + continue + + data_file = os.path.join(folder_path, "data") + if not os.path.isfile(data_file): + continue + + series_data = _load_data_file(data_file) + if series_data is None: + continue + + key = series_data.get("key") + if not key: + logger.warning( + "Data file missing key, skipping: %s", + data_file + ) + continue + + anime = AnimeSeries( + key=key, + name=series_data.get("name") or folder_name, + site=series_data.get("site", "https://aniworld.to"), + folder=series_data.get("folder", folder_name), + year=series_data.get("year"), + ) + + episode_dict = series_data.get("episodeDict", {}) + if episode_dict: + anime._episode_dict_cache = { + int(season): episodes + for season, episodes in episode_dict.items() + } + + all_series.append(anime) + except (OSError, ValueError) as e: + logger.error( + "Failed to scan directory for data files: %s", + str(e), + exc_info=True + ) + return [] logger.info( "Found %d series from data files in %s", @@ -731,3 +780,38 @@ class SeriesApp: if hasattr(self, 'executor'): self.executor.shutdown(wait=True) logger.info("ThreadPoolExecutor shut down successfully") + + +def _load_data_file(data_file_path: str) -> Optional[dict]: + """Load and parse a legacy 'data' file (JSON). + + Args: + data_file_path: Path to the data file + + Returns: + Parsed data dict or None if parsing fails + """ + import json + + try: + with open(data_file_path, "r", encoding="utf-8") as f: + data = json.load(f) + + if not isinstance(data, dict): + logger.warning("Data file is not a dictionary: %s", data_file_path) + return None + + return data + + except json.JSONDecodeError as e: + logger.warning( + "Failed to parse legacy data file (JSON error): %s - %s", + data_file_path, str(e) + ) + return None + except Exception as e: + logger.warning( + "Failed to read legacy data file: %s - %s", + data_file_path, str(e) + ) + return None diff --git a/tests/unit/test_series_app.py b/tests/unit/test_series_app.py index e23081a..dc7a50d 100644 --- a/tests/unit/test_series_app.py +++ b/tests/unit/test_series_app.py @@ -10,6 +10,9 @@ Tests the functionality of SeriesApp including: - Error handling """ +import json +import os +import tempfile from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest @@ -489,53 +492,45 @@ class TestSeriesAppGetAllSeriesFromDataFiles: @patch('src.server.SeriesApp.Loaders') @patch('src.server.SeriesApp.SerieScanner') - @patch('src.server.SeriesApp.SerieList') def test_returns_list_of_series( - self, mock_serie_list_class, mock_scanner, mock_loaders + self, mock_scanner, mock_loaders ): """Test that get_all_series_from_data_files returns a list of AnimeSeries.""" - from src.server.database.models import AnimeSeries + with tempfile.TemporaryDirectory() as tmp_dir: + # Create test data files + anime_dir1 = os.path.join(tmp_dir, "Anime 1") + os.makedirs(anime_dir1) + data1 = { + "key": "anime1", + "name": "Anime 1", + "site": "https://aniworld.to", + "folder": "Anime 1 (2020)", + "episodeDict": {"1": [1, 2, 3]} + } + with open(os.path.join(anime_dir1, "data"), "w") as f: + json.dump(data1, f) - test_dir = "/test/anime" + anime_dir2 = os.path.join(tmp_dir, "Anime 2") + os.makedirs(anime_dir2) + data2 = { + "key": "anime2", + "name": "Anime 2", + "site": "https://aniworld.to", + "folder": "Anime 2 (2021)", + "episodeDict": {"1": [1, 2]} + } + with open(os.path.join(anime_dir2, "data"), "w") as f: + json.dump(data2, f) - def make_anime(key, name, folder): - anime = MagicMock(spec=AnimeSeries) - anime.key = key - anime.name = name - anime.site = "https://aniworld.to" - anime.folder = folder - anime.episodeDict = {1: [1, 2, 3]} if key == "anime1" else {1: [1, 2]} - return anime + with patch('src.server.SeriesApp.SerieList'): + app = SeriesApp(tmp_dir) + result = app.get_all_series_from_data_files() - # Mock series to return - mock_series = [ - make_anime("anime1", "Anime 1", "Anime 1 (2020)"), - make_anime("anime2", "Anime 2", "Anime 2 (2021)"), - ] - - # Setup mock for the main SerieList instance (constructor call) - mock_main_list = Mock() - mock_main_list.GetMissingEpisode.return_value = [] - - # Setup mock for temporary SerieList in get_all_series_from_data_files - mock_temp_list = Mock() - mock_temp_list.get_all.return_value = mock_series - - # Return different mocks for the two calls - mock_serie_list_class.side_effect = [mock_main_list, mock_temp_list] - - # Create app - app = SeriesApp(test_dir) - - # Call the method - result = app.get_all_series_from_data_files() - - # Verify result is a list of AnimeSeries - assert isinstance(result, list) - assert len(result) == 2 - assert all(isinstance(s, MagicMock) for s in result) - assert result[0].key == "anime1" - assert result[1].key == "anime2" + assert isinstance(result, list) + assert len(result) == 2 + keys = {s.key for s in result} + assert "anime1" in keys + assert "anime2" in keys @patch('src.server.SeriesApp.Loaders') @patch('src.server.SeriesApp.SerieScanner') @@ -544,27 +539,14 @@ class TestSeriesAppGetAllSeriesFromDataFiles: self, mock_serie_list_class, mock_scanner, mock_loaders ): """Test that empty list is returned when no data files exist.""" - test_dir = "/test/anime" + with tempfile.TemporaryDirectory() as tmp_dir: + # No data files created - directory is empty + with patch('src.server.SeriesApp.SerieList'): + app = SeriesApp(tmp_dir) + result = app.get_all_series_from_data_files() - # Setup mock for the main SerieList instance - mock_main_list = Mock() - mock_main_list.GetMissingEpisode.return_value = [] - - # Setup mock for the temporary SerieList (empty directory) - mock_temp_list = Mock() - mock_temp_list.get_all.return_value = [] - - mock_serie_list_class.side_effect = [mock_main_list, mock_temp_list] - - # Create app - app = SeriesApp(test_dir) - - # Call the method - result = app.get_all_series_from_data_files() - - # Verify empty list is returned - assert isinstance(result, list) - assert len(result) == 0 + assert isinstance(result, list) + assert len(result) == 0 @patch('src.server.SeriesApp.Loaders') @patch('src.server.SeriesApp.SerieScanner') @@ -573,61 +555,53 @@ class TestSeriesAppGetAllSeriesFromDataFiles: self, mock_serie_list_class, mock_scanner, mock_loaders ): """Test exceptions are handled gracefully and empty list returned.""" - test_dir = "/test/anime" + with tempfile.TemporaryDirectory() as tmp_dir: + # Create a data file that will cause an error + anime_dir = os.path.join(tmp_dir, "Anime") + os.makedirs(anime_dir) + with open(os.path.join(anime_dir, "data"), "w") as f: + f.write("invalid json {{{") - # Setup mock for the main SerieList instance - mock_main_list = Mock() - mock_main_list.GetMissingEpisode.return_value = [] + with patch('src.server.SeriesApp.SerieList'): + app = SeriesApp(tmp_dir) + result = app.get_all_series_from_data_files() - # Make the second SerieList constructor raise an exception - mock_serie_list_class.side_effect = [ - mock_main_list, - OSError("Directory not found") - ] - - # Create app - app = SeriesApp(test_dir) - - # Call the method - should not raise - result = app.get_all_series_from_data_files() - - # Verify empty list is returned on error - assert isinstance(result, list) - assert len(result) == 0 + # Should return empty list due to corrupt file + assert isinstance(result, list) + assert len(result) == 0 @patch('src.server.SeriesApp.Loaders') @patch('src.server.SeriesApp.SerieScanner') - @patch('src.server.SeriesApp.SerieList') def test_uses_file_based_loading( - self, mock_serie_list_class, mock_scanner, mock_loaders + self, mock_scanner, mock_loaders ): - """Test that method uses SerieList for file-based loading.""" - test_dir = "/test/anime" + """Test that method reads directly from data files, not SerieList.""" + import tempfile + import os + import json - # Setup mock for the main SerieList instance - mock_main_list = Mock() - mock_main_list.GetMissingEpisode.return_value = [] + with tempfile.TemporaryDirectory() as tmp_dir: + # Create test data file + anime_dir = os.path.join(tmp_dir, "Anime") + os.makedirs(anime_dir) + data = { + "key": "anime1", + "name": "Anime 1", + "site": "https://aniworld.to", + "folder": "Anime", + "episodeDict": {"1": [1]} + } + with open(os.path.join(anime_dir, "data"), "w") as f: + json.dump(data, f) - # Setup mock for the temporary SerieList - mock_temp_list = Mock() - mock_temp_list.get_all.return_value = [] + with patch('src.server.SeriesApp.SerieList') as mock_serie_list_class: + app = SeriesApp(tmp_dir) + result = app.get_all_series_from_data_files() - mock_serie_list_class.side_effect = [mock_main_list, mock_temp_list] - - # Create app - app = SeriesApp(test_dir) - - # Call the method - app.get_all_series_from_data_files() - - # Verify SerieList was called twice (main + temp) - calls = mock_serie_list_class.call_args_list - assert len(calls) == 2 - - # Check the second call is for temp SerieList with directory - second_call = calls[1] - # base_path is passed as positional argument - assert second_call.args[0] == test_dir + # SerieList should NOT be instantiated by this method + # The new implementation uses direct file reading + assert len(result) == 1 + assert result[0].key == "anime1" @patch('src.server.SeriesApp.Loaders') @patch('src.server.SeriesApp.SerieScanner')