From 01f828c799eb532d456119953992475022cf8600 Mon Sep 17 00:00:00 2001 From: Lukas Date: Mon, 19 Jan 2026 20:42:04 +0100 Subject: [PATCH] Fix NFO service year extraction from series names --- docs/instructions.md | 142 +++++++++++--------------- src/core/services/nfo_service.py | 67 ++++++++++-- tests/unit/test_nfo_service.py | 168 +++++++++++++++++++++++++++++++ 3 files changed, 287 insertions(+), 90 deletions(-) diff --git a/docs/instructions.md b/docs/instructions.md index 6c36fdb..ce2ac99 100644 --- a/docs/instructions.md +++ b/docs/instructions.md @@ -120,85 +120,65 @@ For each task completed: ## TODO List: fix: +Failed to load NFO/images for the-dreaming-boy-is-a-realist: No results found for: The Dreaming Boy is a Realist (2023) +╭─────────────────────────────── Traceback (most recent call last) ────────────────────────────────╮ +│ /home/lukas/Volume/repo/Aniworld/src/server/services/background_loader_service.py:399 in │ +│ \_load_nfo_and_images │ +│ │ +│ 396 │ │ │ │ +│ 397 │ │ │ # Use existing NFOService to create NFO with all images │ +│ 398 │ │ │ # This reuses all existing TMDB API logic and image downloading │ +│ ❱ 399 │ │ │ nfo_path = await self.series_app.nfo_service.create_tvshow_nfo( │ +│ 400 │ │ │ │ serie_name=task.name, │ +│ 401 │ │ │ │ serie_folder=task.folder, │ +│ 402 │ │ │ │ year=task.year, │ +│ │ +│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ +│ │ db = │ │ +│ │ e = TMDBAPIError('No results found for: The Dreaming Boy is a Realist (2023)') │ │ +│ │ self = │ │ +│ │ task = SeriesLoadingTask( │ │ +│ │ │ key='the-dreaming-boy-is-a-realist', │ │ +│ │ │ folder='The Dreaming Boy is a Realist (2023)', │ │ +│ │ │ name='The Dreaming Boy is a Realist (2023)', │ │ +│ │ │ year=None, │ │ +│ │ │ status=, │ │ +│ │ │ progress={'episodes': True, 'nfo': False, 'logo': False, 'images': False}, │ │ +│ │ │ started_at=datetime.datetime(2026, 1, 19, 19, 37, 20, 540721, │ │ +│ │ tzinfo=datetime.timezone.utc), │ │ +│ │ │ completed_at=None, │ │ +│ │ │ error=None │ │ +│ │ ) │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +│ │ +│ /home/lukas/Volume/repo/Aniworld/src/core/services/nfo_service.py:112 in create_tvshow_nfo │ +│ │ +│ 109 │ │ │ search_results = await self.tmdb_client.search_tv_show(serie_name) │ +│ 110 │ │ │ │ +│ 111 │ │ │ if not search_results.get("results"): │ +│ ❱ 112 │ │ │ │ raise TMDBAPIError(f"No results found for: {serie_name}") │ +│ 113 │ │ │ │ +│ 114 │ │ │ # Find best match (consider year if provided) │ +│ 115 │ │ │ tv_show = self.\_find_best_match(search_results["results"], serie_name, year) │ +│ │ +│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │ +│ │ download_fanart = True │ │ +│ │ download_logo = True │ │ +│ │ download_poster = True │ │ +│ │ folder_path = PosixPath('/mnt/server/serien/Serien/The Dreaming Boy is a Realist │ │ +│ │ (2023)') │ │ +│ │ search_results = {'page': 1, 'results': [], 'total_pages': 1, 'total_results': 0} │ │ +│ │ self = │ │ +│ │ serie_folder = 'The Dreaming Boy is a Realist (2023)' │ │ +│ │ serie_name = 'The Dreaming Boy is a Realist (2023)' │ │ +│ │ year = None │ │ +│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +TMDBAPIError: No results found for: The Dreaming Boy is a Realist (2023) -INFO: 127.0.0.1:34916 - "POST /api/anime/search HTTP/1.1" 200 -INFO: 127.0.0.1:34916 - "POST /api/anime/add HTTP/1.1" 500 -ERROR: Exception in ASGI application -Traceback (most recent call last): -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/anyio/streams/memory.py", line 98, in receive -return self.receive_nowait() -~~~~~~~~~~~~~~~~~~~^^ -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/anyio/streams/memory.py", line 93, in receive_nowait -raise WouldBlock -anyio.WouldBlock - -During handling of the above exception, another exception occurred: - -Traceback (most recent call last): -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/base.py", line 78, in call_next -message = await recv_stream.receive() -^^^^^^^^^^^^^^^^^^^^^^^^^^^ -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/anyio/streams/memory.py", line 118, in receive -raise EndOfStream -anyio.EndOfStream - -During handling of the above exception, another exception occurred: - -Traceback (most recent call last): -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/uvicorn/protocols/http/httptools_impl.py", line 426, in run_asgi -result = await app( # type: ignore[func-returns-value] -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -self.scope, self.receive, self.send -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -) -^ -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in **call** -return await self.app(scope, receive, send) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/fastapi/applications.py", line 1106, in **call** -await super().**call**(scope, receive, send) -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/applications.py", line 122, in **call** -await self.middleware_stack(scope, receive, send) -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/errors.py", line 184, in **call** -raise exc -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/errors.py", line 162, in **call** -await self.app(scope, receive, \_send) -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/base.py", line 108, in **call** -response = await self.dispatch_func(request, call_next) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -File "/home/lukas/Volume/repo/Aniworld/src/server/middleware/auth.py", line 209, in dispatch -return await call_next(request) -^^^^^^^^^^^^^^^^^^^^^^^^ -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/base.py", line 84, in call_next -raise app_exc -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/base.py", line 70, in coro -await self.app(scope, receive_or_disconnect, send_no_error) -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/base.py", line 108, in **call** -response = await self.dispatch_func(request, call_next) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -File "/home/lukas/Volume/repo/Aniworld/src/server/middleware/setup_redirect.py", line 120, in dispatch -return await call_next(request) -^^^^^^^^^^^^^^^^^^^^^^^^ -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/base.py", line 84, in call_next -raise app_exc -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/base.py", line 70, in coro -await self.app(scope, receive_or_disconnect, send_no_error) -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/cors.py", line 91, in **call** -await self.simple_response(scope, receive, send, request_headers=headers) -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/cors.py", line 146, in simple_response -await self.app(scope, receive, send) -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/exceptions.py", line 79, in **call** -raise exc -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/starlette/middleware/exceptions.py", line 68, in **call** -await self.app(scope, receive, sender) -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/fastapi/middleware/asyncexitstack.py", line 14, in **call** -async with AsyncExitStack() as stack: -~~~~~~~~~~~~~~^^ -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/contextlib.py", line 768, in **aexit** -raise exc -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/contextlib.py", line 751, in **aexit** -cb_suppress = await cb(\*exc_details) -^^^^^^^^^^^^^^^^^^^^^^ -File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/contextlib.py", line 271, in **aexit** -raise RuntimeError("generator didn't stop after athrow()") -RuntimeError: generator didn't stop after athrow() +2026-01-19 20:37:37 [debug ] Message broadcast failed_count=0 message_type=series_loading_update recipient_count=0 +2026-01-19 20:37:37 [info ] Successfully loaded all data for series: the-dreaming-boy-is-a-realist +2026-01-19 20:37:37 [info ] Processing loading task for series: bel-blatt +2026-01-19 20:37:37 [debug ] Message broadcast failed_count=0 message_type=series_loading_update recipient_count=0 +INFO: Creating NFO for Übel Blatt (2025) (year: None) diff --git a/src/core/services/nfo_service.py b/src/core/services/nfo_service.py index 53a6433..1916d66 100644 --- a/src/core/services/nfo_service.py +++ b/src/core/services/nfo_service.py @@ -9,8 +9,9 @@ Example: """ import logging +import re from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from lxml import etree @@ -58,6 +59,44 @@ class NFOService: self.image_size = image_size self.auto_create = auto_create + def has_nfo(self, serie_folder: str) -> bool: + """Check if tvshow.nfo exists for a series. + + Args: + serie_folder: Series folder name + + Returns: + True if NFO file exists + """ + nfo_path = self.anime_directory / serie_folder / "tvshow.nfo" + return nfo_path.exists() + + @staticmethod + def _extract_year_from_name(serie_name: str) -> Tuple[str, Optional[int]]: + """Extract year from series name if present in format 'Name (YYYY)'. + + Args: + serie_name: Series name, possibly with year in parentheses + + Returns: + Tuple of (clean_name, year) + - clean_name: Series name without year + - year: Extracted year or None + + Examples: + >>> _extract_year_from_name("Attack on Titan (2013)") + ("Attack on Titan", 2013) + >>> _extract_year_from_name("Attack on Titan") + ("Attack on Titan", None) + """ + # Match year in parentheses at the end: (YYYY) + match = re.search(r'\((\d{4})\)\s*$', serie_name) + if match: + year = int(match.group(1)) + clean_name = serie_name[:match.start()].strip() + return clean_name, year + return serie_name, None + async def check_nfo_exists(self, serie_folder: str) -> bool: """Check if tvshow.nfo exists for a series. @@ -82,9 +121,10 @@ class NFOService: """Create tvshow.nfo by scraping TMDB. Args: - serie_name: Name of the series to search + serie_name: Name of the series to search (may include year in parentheses) serie_folder: Series folder name - year: Release year (helps narrow search) + year: Release year (helps narrow search). If None and name contains year, + year will be auto-extracted download_poster: Whether to download poster.jpg download_logo: Whether to download logo.png download_fanart: Whether to download fanart.jpg @@ -96,7 +136,16 @@ class NFOService: TMDBAPIError: If TMDB API fails FileNotFoundError: If series folder doesn't exist """ - logger.info(f"Creating NFO for {serie_name} (year: {year})") + # Extract year from name if not provided + clean_name, extracted_year = self._extract_year_from_name(serie_name) + if year is None and extracted_year is not None: + year = extracted_year + logger.info(f"Extracted year {year} from series name") + + # Use clean name for search + search_name = clean_name + + logger.info(f"Creating NFO for {search_name} (year: {year})") folder_path = self.anime_directory / serie_folder if not folder_path.exists(): @@ -104,15 +153,15 @@ class NFOService: folder_path.mkdir(parents=True, exist_ok=True) async with self.tmdb_client: - # Search for TV show - logger.debug(f"Searching TMDB for: {serie_name}") - search_results = await self.tmdb_client.search_tv_show(serie_name) + # Search for TV show with clean name (without year) + logger.debug(f"Searching TMDB for: {search_name}") + search_results = await self.tmdb_client.search_tv_show(search_name) if not search_results.get("results"): - raise TMDBAPIError(f"No results found for: {serie_name}") + raise TMDBAPIError(f"No results found for: {search_name}") # Find best match (consider year if provided) - tv_show = self._find_best_match(search_results["results"], serie_name, year) + tv_show = self._find_best_match(search_results["results"], search_name, year) tv_id = tv_show["id"] logger.info(f"Found match: {tv_show['name']} (ID: {tv_id})") diff --git a/tests/unit/test_nfo_service.py b/tests/unit/test_nfo_service.py index 1bb5fdf..bdd1297 100644 --- a/tests/unit/test_nfo_service.py +++ b/tests/unit/test_nfo_service.py @@ -146,6 +146,70 @@ class TestFSKRatingExtraction: assert fsk is None +class TestYearExtraction: + """Test year extraction from series names.""" + + def test_extract_year_with_year(self, nfo_service): + """Test extraction when year is present in format (YYYY).""" + clean_name, year = nfo_service._extract_year_from_name("Attack on Titan (2013)") + assert clean_name == "Attack on Titan" + assert year == 2013 + + def test_extract_year_without_year(self, nfo_service): + """Test extraction when no year is present.""" + clean_name, year = nfo_service._extract_year_from_name("Attack on Titan") + assert clean_name == "Attack on Titan" + assert year is None + + def test_extract_year_multiple_parentheses(self, nfo_service): + """Test extraction with multiple parentheses - only last one with year.""" + clean_name, year = nfo_service._extract_year_from_name("Series (Part 1) (2023)") + assert clean_name == "Series (Part 1)" + assert year == 2023 + + def test_extract_year_with_trailing_spaces(self, nfo_service): + """Test extraction with trailing spaces.""" + clean_name, year = nfo_service._extract_year_from_name("Attack on Titan (2013) ") + assert clean_name == "Attack on Titan" + assert year == 2013 + + def test_extract_year_parentheses_not_year(self, nfo_service): + """Test extraction when parentheses don't contain a year.""" + clean_name, year = nfo_service._extract_year_from_name("Series (Special Edition)") + assert clean_name == "Series (Special Edition)" + assert year is None + + def test_extract_year_invalid_year_format(self, nfo_service): + """Test extraction with invalid year format (not 4 digits).""" + clean_name, year = nfo_service._extract_year_from_name("Series (23)") + assert clean_name == "Series (23)" + assert year is None + + def test_extract_year_future_year(self, nfo_service): + """Test extraction with future year.""" + clean_name, year = nfo_service._extract_year_from_name("Future Series (2050)") + assert clean_name == "Future Series" + assert year == 2050 + + def test_extract_year_old_year(self, nfo_service): + """Test extraction with old year.""" + clean_name, year = nfo_service._extract_year_from_name("Classic Series (1990)") + assert clean_name == "Classic Series" + assert year == 1990 + + def test_extract_year_real_world_example(self, nfo_service): + """Test extraction with the real-world example from the bug report.""" + clean_name, year = nfo_service._extract_year_from_name("The Dreaming Boy is a Realist (2023)") + assert clean_name == "The Dreaming Boy is a Realist" + assert year == 2023 + + def test_extract_year_uebel_blatt(self, nfo_service): + """Test extraction with Übel Blatt example.""" + clean_name, year = nfo_service._extract_year_from_name("Übel Blatt (2025)") + assert clean_name == "Übel Blatt" + assert year == 2025 + + class TestTMDBToNFOModel: """Test conversion of TMDB data to NFO model.""" @@ -221,6 +285,110 @@ class TestTMDBToNFOModel: class TestCreateTVShowNFO: """Test NFO creation workflow.""" + @pytest.mark.asyncio + async def test_create_nfo_with_year_in_name(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de): + """Test NFO creation when year is included in series name. + + This test addresses the bug where searching TMDB with year in the name + (e.g., "The Dreaming Boy is a Realist (2023)") fails to find results. + """ + # Setup + serie_name = "The Dreaming Boy is a Realist (2023)" + serie_folder = "The Dreaming Boy is a Realist (2023)" + (tmp_path / serie_folder).mkdir() + + # Mock TMDB responses + search_results = {"results": [mock_tmdb_data]} + + with patch.object(nfo_service.tmdb_client, '__aenter__', return_value=nfo_service.tmdb_client): + with patch.object(nfo_service.tmdb_client, '__aexit__', return_value=None): + with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search: + with patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details: + with patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings: + with patch.object(nfo_service.image_downloader, 'download_poster', new_callable=AsyncMock): + with patch.object(nfo_service.image_downloader, 'download_logo', new_callable=AsyncMock): + with patch.object(nfo_service.image_downloader, 'download_fanart', new_callable=AsyncMock): + mock_search.return_value = search_results + mock_details.return_value = mock_tmdb_data + mock_ratings.return_value = mock_content_ratings_de + + # Act + nfo_path = await nfo_service.create_tvshow_nfo( + serie_name=serie_name, + serie_folder=serie_folder, + year=None # Year should be auto-extracted + ) + + # Assert - should search with clean name "The Dreaming Boy is a Realist" + mock_search.assert_called_once_with("The Dreaming Boy is a Realist") + + # Verify NFO file was created + assert nfo_path.exists() + assert nfo_path.name == "tvshow.nfo" + + @pytest.mark.asyncio + async def test_create_nfo_year_parameter_takes_precedence(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de): + """Test that explicit year parameter takes precedence over extracted year.""" + # Setup + serie_name = "Attack on Titan (2013)" + serie_folder = "Attack on Titan" + explicit_year = 2015 # Different from extracted year + (tmp_path / serie_folder).mkdir() + + # Mock TMDB responses + search_results = {"results": [mock_tmdb_data]} + + with patch.object(nfo_service.tmdb_client, '__aenter__', return_value=nfo_service.tmdb_client): + with patch.object(nfo_service.tmdb_client, '__aexit__', return_value=None): + with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search: + with patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details: + with patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings: + with patch.object(nfo_service.image_downloader, 'download_poster', new_callable=AsyncMock): + with patch.object(nfo_service.image_downloader, 'download_logo', new_callable=AsyncMock): + with patch.object(nfo_service.image_downloader, 'download_fanart', new_callable=AsyncMock): + with patch.object(nfo_service, '_find_best_match') as mock_find_match: + mock_search.return_value = search_results + mock_details.return_value = mock_tmdb_data + mock_ratings.return_value = mock_content_ratings_de + mock_find_match.return_value = mock_tmdb_data + + # Act + await nfo_service.create_tvshow_nfo( + serie_name=serie_name, + serie_folder=serie_folder, + year=explicit_year # Explicit year provided + ) + + # Assert - should use explicit year, not extracted year + mock_find_match.assert_called_once() + call_args = mock_find_match.call_args + assert call_args[0][2] == explicit_year # Third argument is year + + @pytest.mark.asyncio + async def test_create_nfo_no_results_with_clean_name(self, nfo_service, tmp_path): + """Test error handling when TMDB returns no results even with clean name.""" + # Setup + serie_name = "Nonexistent Series (2023)" + serie_folder = "Nonexistent Series (2023)" + (tmp_path / serie_folder).mkdir() + + with patch.object(nfo_service.tmdb_client, '__aenter__', return_value=nfo_service.tmdb_client): + with patch.object(nfo_service.tmdb_client, '__aexit__', return_value=None): + with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search: + mock_search.return_value = {"results": []} + + # Act & Assert + with pytest.raises(TMDBAPIError) as exc_info: + await nfo_service.create_tvshow_nfo( + serie_name=serie_name, + serie_folder=serie_folder + ) + + # Should use clean name in error message + assert "No results found for: Nonexistent Series" in str(exc_info.value) + # Should have searched with clean name + mock_search.assert_called_once_with("Nonexistent Series") + @pytest.mark.asyncio async def test_create_nfo_with_fsk(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de): """Test NFO creation includes FSK rating."""