fix: ensure all NFO properties are written on creation
- Add showtitle and namedseason to mapper output - Add multi-language fallback (en-US, ja-JP) for empty overview - Use search result overview as last resort fallback - Add tests for new NFO creation behavior
This commit is contained in:
@@ -160,7 +160,7 @@ class NFOService:
|
|||||||
|
|
||||||
logger.info(f"Found match: {tv_show['name']} (ID: {tv_id})")
|
logger.info(f"Found match: {tv_show['name']} (ID: {tv_id})")
|
||||||
|
|
||||||
# Get detailed information
|
# Get detailed information with multi-language image support
|
||||||
details = await self.tmdb_client.get_tv_show_details(
|
details = await self.tmdb_client.get_tv_show_details(
|
||||||
tv_id,
|
tv_id,
|
||||||
append_to_response="credits,external_ids,images"
|
append_to_response="credits,external_ids,images"
|
||||||
@@ -169,8 +169,12 @@ class NFOService:
|
|||||||
# Get content ratings for FSK
|
# Get content ratings for FSK
|
||||||
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tv_id)
|
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tv_id)
|
||||||
|
|
||||||
# Enrich with English fallback for empty overview/tagline
|
# Enrich with fallback languages for empty overview/tagline
|
||||||
details = await self._enrich_details_with_fallback(details)
|
# Pass search result overview as last resort fallback
|
||||||
|
search_overview = tv_show.get("overview") or None
|
||||||
|
details = await self._enrich_details_with_fallback(
|
||||||
|
details, search_overview=search_overview
|
||||||
|
)
|
||||||
|
|
||||||
# Convert TMDB data to TVShowNFO model
|
# Convert TMDB data to TVShowNFO model
|
||||||
nfo_model = tmdb_to_nfo_model(
|
nfo_model = tmdb_to_nfo_model(
|
||||||
@@ -267,9 +271,8 @@ class NFOService:
|
|||||||
# Get content ratings for FSK
|
# Get content ratings for FSK
|
||||||
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tmdb_id)
|
content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tmdb_id)
|
||||||
|
|
||||||
# Enrich with English fallback for empty overview/tagline
|
# Enrich with fallback languages for empty overview/tagline
|
||||||
details = await self._enrich_details_with_fallback(details)
|
details = await self._enrich_details_with_fallback(details)
|
||||||
|
|
||||||
# Convert TMDB data to TVShowNFO model
|
# Convert TMDB data to TVShowNFO model
|
||||||
nfo_model = tmdb_to_nfo_model(
|
nfo_model = tmdb_to_nfo_model(
|
||||||
details,
|
details,
|
||||||
@@ -381,20 +384,26 @@ class NFOService:
|
|||||||
async def _enrich_details_with_fallback(
|
async def _enrich_details_with_fallback(
|
||||||
self,
|
self,
|
||||||
details: Dict[str, Any],
|
details: Dict[str, Any],
|
||||||
|
search_overview: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Enrich TMDB details with English fallback for empty fields.
|
"""Enrich TMDB details with fallback languages for empty fields.
|
||||||
|
|
||||||
When requesting details in ``de-DE``, some anime have an empty
|
When requesting details in ``de-DE``, some anime have an empty
|
||||||
``overview`` (and potentially other translatable fields). This
|
``overview`` (and potentially other translatable fields). This
|
||||||
method detects empty values and fills them from the English
|
method detects empty values and fills them from alternative
|
||||||
(``en-US``) endpoint so that NFO files always contain a ``plot``
|
languages (``en-US``, then ``ja-JP``) so that NFO files always
|
||||||
regardless of whether the German translation exists.
|
contain a ``plot`` regardless of whether the German translation
|
||||||
|
exists. As a last resort, the overview from the search result
|
||||||
|
is used.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
details: TMDB TV show details (language ``de-DE``).
|
details: TMDB TV show details (language ``de-DE``).
|
||||||
|
search_overview: Overview text from the TMDB search result,
|
||||||
|
used as a final fallback if all language-specific
|
||||||
|
requests fail or return empty overviews.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The *same* dict, mutated in-place with English fallbacks
|
The *same* dict, mutated in-place with fallback values
|
||||||
where needed.
|
where needed.
|
||||||
"""
|
"""
|
||||||
overview = details.get("overview") or ""
|
overview = details.get("overview") or ""
|
||||||
@@ -403,32 +412,46 @@ class NFOService:
|
|||||||
# Overview already populated – nothing to do.
|
# Overview already populated – nothing to do.
|
||||||
return details
|
return details
|
||||||
|
|
||||||
|
tmdb_id = details.get("id")
|
||||||
|
fallback_languages = ["en-US", "ja-JP"]
|
||||||
|
|
||||||
|
for lang in fallback_languages:
|
||||||
|
if details.get("overview"):
|
||||||
|
break
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"German overview empty for TMDB ID %s, fetching English fallback",
|
"Trying %s fallback for TMDB ID %s",
|
||||||
details.get("id"),
|
lang, tmdb_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
en_details = await self.tmdb_client.get_tv_show_details(
|
lang_details = await self.tmdb_client.get_tv_show_details(
|
||||||
details["id"],
|
tmdb_id,
|
||||||
language="en-US",
|
language=lang,
|
||||||
)
|
)
|
||||||
|
|
||||||
if en_details.get("overview"):
|
if not details.get("overview") and lang_details.get("overview"):
|
||||||
details["overview"] = en_details["overview"]
|
details["overview"] = lang_details["overview"]
|
||||||
logger.info(
|
logger.info(
|
||||||
"Used English overview fallback for TMDB ID %s",
|
"Used %s overview fallback for TMDB ID %s",
|
||||||
details.get("id"),
|
lang, tmdb_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Also fill tagline if missing
|
# Also fill tagline if missing
|
||||||
if not details.get("tagline") and en_details.get("tagline"):
|
if not details.get("tagline") and lang_details.get("tagline"):
|
||||||
details["tagline"] = en_details["tagline"]
|
details["tagline"] = lang_details["tagline"]
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to fetch English fallback for TMDB ID %s: %s",
|
"Failed to fetch %s fallback for TMDB ID %s: %s",
|
||||||
details.get("id"),
|
lang, tmdb_id, exc,
|
||||||
exc,
|
)
|
||||||
|
|
||||||
|
# Last resort: use search result overview
|
||||||
|
if not details.get("overview") and search_overview:
|
||||||
|
details["overview"] = search_overview
|
||||||
|
logger.info(
|
||||||
|
"Used search result overview fallback for TMDB ID %s",
|
||||||
|
tmdb_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return details
|
return details
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from typing import Any, Callable, Dict, List, Optional
|
|||||||
from src.core.entities.nfo_models import (
|
from src.core.entities.nfo_models import (
|
||||||
ActorInfo,
|
ActorInfo,
|
||||||
ImageInfo,
|
ImageInfo,
|
||||||
|
NamedSeason,
|
||||||
RatingInfo,
|
RatingInfo,
|
||||||
TVShowNFO,
|
TVShowNFO,
|
||||||
UniqueID,
|
UniqueID,
|
||||||
@@ -167,6 +168,17 @@ def tmdb_to_nfo_model(
|
|||||||
tmdbid=member["id"],
|
tmdbid=member["id"],
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# --- Named seasons ---
|
||||||
|
named_seasons: List[NamedSeason] = []
|
||||||
|
for season_info in tmdb_data.get("seasons", []):
|
||||||
|
season_name = season_info.get("name")
|
||||||
|
season_number = season_info.get("season_number")
|
||||||
|
if season_name and season_number is not None:
|
||||||
|
named_seasons.append(NamedSeason(
|
||||||
|
number=season_number,
|
||||||
|
name=season_name,
|
||||||
|
))
|
||||||
|
|
||||||
# --- Unique IDs ---
|
# --- Unique IDs ---
|
||||||
unique_ids: List[UniqueID] = []
|
unique_ids: List[UniqueID] = []
|
||||||
if tmdb_data.get("id"):
|
if tmdb_data.get("id"):
|
||||||
@@ -194,6 +206,7 @@ def tmdb_to_nfo_model(
|
|||||||
return TVShowNFO(
|
return TVShowNFO(
|
||||||
title=title,
|
title=title,
|
||||||
originaltitle=original_title,
|
originaltitle=original_title,
|
||||||
|
showtitle=title,
|
||||||
sorttitle=title,
|
sorttitle=title,
|
||||||
year=year,
|
year=year,
|
||||||
plot=tmdb_data.get("overview") or None,
|
plot=tmdb_data.get("overview") or None,
|
||||||
@@ -215,6 +228,7 @@ def tmdb_to_nfo_model(
|
|||||||
thumb=thumb_images,
|
thumb=thumb_images,
|
||||||
fanart=fanart_images,
|
fanart=fanart_images,
|
||||||
actors=actors,
|
actors=actors,
|
||||||
|
namedseason=named_seasons,
|
||||||
watched=False,
|
watched=False,
|
||||||
dateadded=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
dateadded=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -229,3 +229,79 @@ def test_generate_nfo_writes_mpaa_when_no_fsk() -> None:
|
|||||||
mpaa_elem = root.find(".//mpaa")
|
mpaa_elem = root.find(".//mpaa")
|
||||||
assert mpaa_elem is not None
|
assert mpaa_elem is not None
|
||||||
assert mpaa_elem.text == "TV-14"
|
assert mpaa_elem.text == "TV-14"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# showtitle and namedseason — new coverage
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_tmdb_to_nfo_model_sets_showtitle(nfo_model: TVShowNFO) -> None:
|
||||||
|
"""showtitle must equal the main title."""
|
||||||
|
assert nfo_model.showtitle == "Test Show"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_nfo_writes_showtitle(nfo_model: TVShowNFO) -> None:
|
||||||
|
xml_str = generate_tvshow_nfo(nfo_model)
|
||||||
|
root = _parse_xml(xml_str)
|
||||||
|
elem = root.find(".//showtitle")
|
||||||
|
assert elem is not None
|
||||||
|
assert elem.text == "Test Show"
|
||||||
|
|
||||||
|
|
||||||
|
TMDB_WITH_SEASONS: Dict[str, Any] = {
|
||||||
|
**MINIMAL_TMDB,
|
||||||
|
"seasons": [
|
||||||
|
{"season_number": 0, "name": "Specials"},
|
||||||
|
{"season_number": 1, "name": "Season 1"},
|
||||||
|
{"season_number": 2, "name": "Season 2"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_tmdb_to_nfo_model_sets_namedseasons() -> None:
|
||||||
|
model = tmdb_to_nfo_model(
|
||||||
|
TMDB_WITH_SEASONS, CONTENT_RATINGS_DE_US, _fake_get_image_url,
|
||||||
|
)
|
||||||
|
assert len(model.namedseason) == 3
|
||||||
|
assert model.namedseason[0].number == 0
|
||||||
|
assert model.namedseason[0].name == "Specials"
|
||||||
|
assert model.namedseason[1].number == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_nfo_writes_namedseasons() -> None:
|
||||||
|
model = tmdb_to_nfo_model(
|
||||||
|
TMDB_WITH_SEASONS, CONTENT_RATINGS_DE_US, _fake_get_image_url,
|
||||||
|
)
|
||||||
|
xml_str = generate_tvshow_nfo(model)
|
||||||
|
root = _parse_xml(xml_str)
|
||||||
|
elems = root.findall(".//namedseason")
|
||||||
|
assert len(elems) == 3
|
||||||
|
assert elems[0].get("number") == "0"
|
||||||
|
assert elems[0].text == "Specials"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tmdb_to_nfo_model_no_seasons_key() -> None:
|
||||||
|
"""No 'seasons' key in TMDB data → namedseason list is empty."""
|
||||||
|
model = tmdb_to_nfo_model(
|
||||||
|
MINIMAL_TMDB, CONTENT_RATINGS_DE_US, _fake_get_image_url,
|
||||||
|
)
|
||||||
|
assert model.namedseason == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_tmdb_to_nfo_model_empty_overview_produces_none_plot() -> None:
|
||||||
|
"""When overview is empty the plot field should be None."""
|
||||||
|
data = {**MINIMAL_TMDB, "overview": ""}
|
||||||
|
model = tmdb_to_nfo_model(
|
||||||
|
data, CONTENT_RATINGS_DE_US, _fake_get_image_url,
|
||||||
|
)
|
||||||
|
assert model.plot is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_nfo_always_writes_plot_tag_even_when_none() -> None:
|
||||||
|
"""<plot> must always appear, even when plot is None."""
|
||||||
|
nfo = TVShowNFO(title="No Plot Show")
|
||||||
|
xml_str = generate_tvshow_nfo(nfo)
|
||||||
|
root = _parse_xml(xml_str)
|
||||||
|
plot_elem = root.find(".//plot")
|
||||||
|
assert plot_elem is not None # tag exists (always_write=True)
|
||||||
|
|||||||
@@ -1385,3 +1385,152 @@ class TestYearExtractionComprehensive:
|
|||||||
assert clean_name == "Series (12345)"
|
assert clean_name == "Series (12345)"
|
||||||
assert year is None
|
assert year is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnrichFallbackLanguages:
|
||||||
|
"""Tests for multi-language fallback and search overview fallback."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_japanese_fallback_when_english_also_empty(
|
||||||
|
self, nfo_service, tmp_path,
|
||||||
|
):
|
||||||
|
"""ja-JP fallback is tried when both de-DE and en-US are empty."""
|
||||||
|
series_folder = tmp_path / "Rare Anime"
|
||||||
|
series_folder.mkdir()
|
||||||
|
|
||||||
|
de_data = {
|
||||||
|
"id": 55555, "name": "Rare Anime",
|
||||||
|
"original_name": "レアアニメ", "first_air_date": "2024-01-01",
|
||||||
|
"overview": "",
|
||||||
|
"vote_average": 7.0, "vote_count": 50,
|
||||||
|
"status": "Continuing", "episode_run_time": [24],
|
||||||
|
"genres": [], "networks": [], "production_countries": [],
|
||||||
|
"poster_path": None, "backdrop_path": None,
|
||||||
|
"external_ids": {}, "credits": {"cast": []},
|
||||||
|
"images": {"logos": []},
|
||||||
|
}
|
||||||
|
en_data = {"id": 55555, "overview": ""}
|
||||||
|
ja_data = {"id": 55555, "overview": "日本語のあらすじ"}
|
||||||
|
|
||||||
|
async def side_effect(tv_id, **kwargs):
|
||||||
|
lang = kwargs.get("language")
|
||||||
|
if lang == "ja-JP":
|
||||||
|
return ja_data
|
||||||
|
if lang == "en-US":
|
||||||
|
return en_data
|
||||||
|
return de_data
|
||||||
|
|
||||||
|
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \
|
||||||
|
patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
|
||||||
|
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
|
||||||
|
patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock):
|
||||||
|
|
||||||
|
mock_search.return_value = {
|
||||||
|
"results": [{"id": 55555, "name": "Rare Anime", "first_air_date": "2024-01-01"}],
|
||||||
|
}
|
||||||
|
mock_details.side_effect = side_effect
|
||||||
|
mock_ratings.return_value = {"results": []}
|
||||||
|
|
||||||
|
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||||
|
"Rare Anime", "Rare Anime",
|
||||||
|
download_poster=False, download_logo=False, download_fanart=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
assert "<plot>日本語のあらすじ</plot>" in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_overview_fallback_when_all_languages_empty(
|
||||||
|
self, nfo_service, tmp_path,
|
||||||
|
):
|
||||||
|
"""Search result overview is used as last resort."""
|
||||||
|
series_folder = tmp_path / "Brand New Anime"
|
||||||
|
series_folder.mkdir()
|
||||||
|
|
||||||
|
empty_data = {
|
||||||
|
"id": 77777, "name": "Brand New Anime",
|
||||||
|
"original_name": "新しいアニメ", "first_air_date": "2025-01-01",
|
||||||
|
"overview": "",
|
||||||
|
"vote_average": 0, "vote_count": 0,
|
||||||
|
"status": "Continuing", "episode_run_time": [],
|
||||||
|
"genres": [], "networks": [], "production_countries": [],
|
||||||
|
"poster_path": None, "backdrop_path": None,
|
||||||
|
"external_ids": {}, "credits": {"cast": []},
|
||||||
|
"images": {"logos": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def side_effect(tv_id, **kwargs):
|
||||||
|
return empty_data
|
||||||
|
|
||||||
|
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \
|
||||||
|
patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
|
||||||
|
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
|
||||||
|
patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock):
|
||||||
|
|
||||||
|
# Search result DOES have an overview
|
||||||
|
mock_search.return_value = {
|
||||||
|
"results": [{
|
||||||
|
"id": 77777,
|
||||||
|
"name": "Brand New Anime",
|
||||||
|
"first_air_date": "2025-01-01",
|
||||||
|
"overview": "Search result overview text.",
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
mock_details.side_effect = side_effect
|
||||||
|
mock_ratings.return_value = {"results": []}
|
||||||
|
|
||||||
|
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||||
|
"Brand New Anime", "Brand New Anime",
|
||||||
|
download_poster=False, download_logo=False, download_fanart=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
assert "<plot>Search result overview text.</plot>" in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_japanese_fallback_when_english_succeeds(
|
||||||
|
self, nfo_service, tmp_path,
|
||||||
|
):
|
||||||
|
"""Stop after en-US if it provides the overview."""
|
||||||
|
series_folder = tmp_path / "Test Anime"
|
||||||
|
series_folder.mkdir()
|
||||||
|
|
||||||
|
de_data = {
|
||||||
|
"id": 88888, "name": "Test Anime",
|
||||||
|
"original_name": "テスト", "first_air_date": "2024-01-01",
|
||||||
|
"overview": "",
|
||||||
|
"vote_average": 7.0, "vote_count": 50,
|
||||||
|
"status": "Continuing", "episode_run_time": [24],
|
||||||
|
"genres": [], "networks": [], "production_countries": [],
|
||||||
|
"poster_path": None, "backdrop_path": None,
|
||||||
|
"external_ids": {}, "credits": {"cast": []},
|
||||||
|
"images": {"logos": []},
|
||||||
|
}
|
||||||
|
en_data = {"id": 88888, "overview": "English overview."}
|
||||||
|
|
||||||
|
async def side_effect(tv_id, **kwargs):
|
||||||
|
lang = kwargs.get("language")
|
||||||
|
if lang == "en-US":
|
||||||
|
return en_data
|
||||||
|
return de_data
|
||||||
|
|
||||||
|
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search, \
|
||||||
|
patch.object(nfo_service.tmdb_client, 'get_tv_show_details', new_callable=AsyncMock) as mock_details, \
|
||||||
|
patch.object(nfo_service.tmdb_client, 'get_tv_show_content_ratings', new_callable=AsyncMock) as mock_ratings, \
|
||||||
|
patch.object(nfo_service, '_download_media_files', new_callable=AsyncMock):
|
||||||
|
|
||||||
|
mock_search.return_value = {
|
||||||
|
"results": [{"id": 88888, "name": "Test Anime", "first_air_date": "2024-01-01"}],
|
||||||
|
}
|
||||||
|
mock_details.side_effect = side_effect
|
||||||
|
mock_ratings.return_value = {"results": []}
|
||||||
|
|
||||||
|
nfo_path = await nfo_service.create_tvshow_nfo(
|
||||||
|
"Test Anime", "Test Anime",
|
||||||
|
download_poster=False, download_logo=False, download_fanart=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
assert "<plot>English overview.</plot>" in content
|
||||||
|
# de-DE + en-US = 2 calls (no ja-JP needed)
|
||||||
|
assert mock_details.call_count == 2
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user