Fix NFO service year extraction from series names

This commit is contained in:
2026-01-19 20:42:04 +01:00
parent 6d40ddbfe5
commit 01f828c799
3 changed files with 287 additions and 90 deletions

View File

@@ -120,85 +120,65 @@ For each task completed:
## TODO List: ## TODO List:
fix: 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 = <sqlalchemy.ext.asyncio.session.AsyncSession object at 0x736aa1e0b770> │ │
│ │ e = TMDBAPIError('No results found for: The Dreaming Boy is a Realist (2023)') │ │
│ │ self = <src.server.services.background_loader_service.BackgroundLoaderService object at │ │
│ │ 0x736aa27556a0> │ │
│ │ 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=<LoadingStatus.LOADING_NFO: 'loading_nfo'>, │ │
│ │ │ 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 = <src.core.services.nfo_service.NFOService object at 0x736aa273ce10> │ │
│ │ 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 2026-01-19 20:37:37 [debug ] Message broadcast failed_count=0 message_type=series_loading_update recipient_count=0
INFO: 127.0.0.1:34916 - "POST /api/anime/add HTTP/1.1" 500 2026-01-19 20:37:37 [info ] Successfully loaded all data for series: the-dreaming-boy-is-a-realist
ERROR: Exception in ASGI application 2026-01-19 20:37:37 [info ] Processing loading task for series: bel-blatt
Traceback (most recent call last): 2026-01-19 20:37:37 [debug ] Message broadcast failed_count=0 message_type=series_loading_update recipient_count=0
File "/home/lukas/miniconda3/envs/AniWorld/lib/python3.13/site-packages/anyio/streams/memory.py", line 98, in receive INFO: Creating NFO for Übel Blatt (2025) (year: None)
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()

View File

@@ -9,8 +9,9 @@ Example:
""" """
import logging import logging
import re
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Tuple
from lxml import etree from lxml import etree
@@ -58,6 +59,44 @@ class NFOService:
self.image_size = image_size self.image_size = image_size
self.auto_create = auto_create 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: async def check_nfo_exists(self, serie_folder: str) -> bool:
"""Check if tvshow.nfo exists for a series. """Check if tvshow.nfo exists for a series.
@@ -82,9 +121,10 @@ class NFOService:
"""Create tvshow.nfo by scraping TMDB. """Create tvshow.nfo by scraping TMDB.
Args: 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 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_poster: Whether to download poster.jpg
download_logo: Whether to download logo.png download_logo: Whether to download logo.png
download_fanart: Whether to download fanart.jpg download_fanart: Whether to download fanart.jpg
@@ -96,7 +136,16 @@ class NFOService:
TMDBAPIError: If TMDB API fails TMDBAPIError: If TMDB API fails
FileNotFoundError: If series folder doesn't exist 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 folder_path = self.anime_directory / serie_folder
if not folder_path.exists(): if not folder_path.exists():
@@ -104,15 +153,15 @@ class NFOService:
folder_path.mkdir(parents=True, exist_ok=True) folder_path.mkdir(parents=True, exist_ok=True)
async with self.tmdb_client: async with self.tmdb_client:
# Search for TV show # Search for TV show with clean name (without year)
logger.debug(f"Searching TMDB for: {serie_name}") logger.debug(f"Searching TMDB for: {search_name}")
search_results = await self.tmdb_client.search_tv_show(serie_name) search_results = await self.tmdb_client.search_tv_show(search_name)
if not search_results.get("results"): 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) # 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"] tv_id = tv_show["id"]
logger.info(f"Found match: {tv_show['name']} (ID: {tv_id})") logger.info(f"Found match: {tv_show['name']} (ID: {tv_id})")

View File

@@ -146,6 +146,70 @@ class TestFSKRatingExtraction:
assert fsk is None 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: class TestTMDBToNFOModel:
"""Test conversion of TMDB data to NFO model.""" """Test conversion of TMDB data to NFO model."""
@@ -221,6 +285,110 @@ class TestTMDBToNFOModel:
class TestCreateTVShowNFO: class TestCreateTVShowNFO:
"""Test NFO creation workflow.""" """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 @pytest.mark.asyncio
async def test_create_nfo_with_fsk(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de): 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.""" """Test NFO creation includes FSK rating."""