Add German FSK rating support for NFO files

- Add optional fsk field to TVShowNFO model
- Implement TMDB content ratings API integration
- Add FSK extraction and mapping (FSK 0/6/12/16/18)
- Update XML generation to prefer FSK over MPAA
- Add nfo_prefer_fsk_rating config setting
- Add 31 comprehensive tests for FSK functionality
- All 112 NFO tests passing
This commit is contained in:
2026-01-17 22:13:34 +01:00
parent fd5e85d5ea
commit 22a41ba93f
10 changed files with 756 additions and 111 deletions

View File

@@ -0,0 +1,410 @@
"""Unit tests for NFO service."""
import pytest
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from src.core.services.nfo_service import NFOService
from src.core.services.tmdb_client import TMDBAPIError
@pytest.fixture
def nfo_service(tmp_path):
"""Create NFO service with test directory."""
service = NFOService(
tmdb_api_key="test_api_key",
anime_directory=str(tmp_path),
image_size="w500",
auto_create=True
)
return service
@pytest.fixture
def mock_tmdb_data():
"""Mock TMDB API response data."""
return {
"id": 1429,
"name": "Attack on Titan",
"original_name": "進撃の巨人",
"first_air_date": "2013-04-07",
"overview": "Several hundred years ago, humans were nearly...",
"vote_average": 8.6,
"vote_count": 5000,
"status": "Ended",
"episode_run_time": [24],
"genres": [{"id": 16, "name": "Animation"}, {"id": 10765, "name": "Sci-Fi & Fantasy"}],
"networks": [{"id": 1, "name": "MBS"}],
"production_countries": [{"name": "Japan"}],
"poster_path": "/poster.jpg",
"backdrop_path": "/backdrop.jpg",
"external_ids": {
"imdb_id": "tt2560140",
"tvdb_id": 267440
},
"credits": {
"cast": [
{
"id": 1,
"name": "Yuki Kaji",
"character": "Eren Yeager",
"profile_path": "/actor.jpg"
}
]
},
"images": {
"logos": [{"file_path": "/logo.png"}]
}
}
@pytest.fixture
def mock_content_ratings_de():
"""Mock TMDB content ratings with German FSK rating."""
return {
"results": [
{"iso_3166_1": "DE", "rating": "16"},
{"iso_3166_1": "US", "rating": "TV-MA"}
]
}
@pytest.fixture
def mock_content_ratings_no_de():
"""Mock TMDB content ratings without German rating."""
return {
"results": [
{"iso_3166_1": "US", "rating": "TV-MA"},
{"iso_3166_1": "GB", "rating": "15"}
]
}
class TestFSKRatingExtraction:
"""Test FSK rating extraction from TMDB content ratings."""
def test_extract_fsk_rating_de(self, nfo_service, mock_content_ratings_de):
"""Test extraction of German FSK rating."""
fsk = nfo_service._extract_fsk_rating(mock_content_ratings_de)
assert fsk == "FSK 16"
def test_extract_fsk_rating_no_de(self, nfo_service, mock_content_ratings_no_de):
"""Test extraction when no German rating available."""
fsk = nfo_service._extract_fsk_rating(mock_content_ratings_no_de)
assert fsk is None
def test_extract_fsk_rating_empty(self, nfo_service):
"""Test extraction with empty content ratings."""
fsk = nfo_service._extract_fsk_rating({})
assert fsk is None
def test_extract_fsk_rating_none(self, nfo_service):
"""Test extraction with None input."""
fsk = nfo_service._extract_fsk_rating(None)
assert fsk is None
def test_extract_fsk_all_values(self, nfo_service):
"""Test extraction of all FSK values."""
fsk_mappings = {
"0": "FSK 0",
"6": "FSK 6",
"12": "FSK 12",
"16": "FSK 16",
"18": "FSK 18"
}
for rating_value, expected_fsk in fsk_mappings.items():
content_ratings = {
"results": [{"iso_3166_1": "DE", "rating": rating_value}]
}
fsk = nfo_service._extract_fsk_rating(content_ratings)
assert fsk == expected_fsk
def test_extract_fsk_already_formatted(self, nfo_service):
"""Test extraction when rating is already in FSK format."""
content_ratings = {
"results": [{"iso_3166_1": "DE", "rating": "FSK 12"}]
}
fsk = nfo_service._extract_fsk_rating(content_ratings)
assert fsk == "FSK 12"
def test_extract_fsk_partial_match(self, nfo_service):
"""Test extraction with partial number match."""
content_ratings = {
"results": [{"iso_3166_1": "DE", "rating": "Ab 16 Jahren"}]
}
fsk = nfo_service._extract_fsk_rating(content_ratings)
assert fsk == "FSK 16"
def test_extract_fsk_unmapped_value(self, nfo_service):
"""Test extraction with unmapped rating value."""
content_ratings = {
"results": [{"iso_3166_1": "DE", "rating": "Unknown"}]
}
fsk = nfo_service._extract_fsk_rating(content_ratings)
assert fsk is None
class TestTMDBToNFOModel:
"""Test conversion of TMDB data to NFO model."""
@patch.object(NFOService, '_extract_fsk_rating')
def test_tmdb_to_nfo_with_fsk(self, mock_extract_fsk, nfo_service, mock_tmdb_data, mock_content_ratings_de):
"""Test conversion includes FSK rating."""
mock_extract_fsk.return_value = "FSK 16"
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data, mock_content_ratings_de)
assert nfo_model.title == "Attack on Titan"
assert nfo_model.fsk == "FSK 16"
assert nfo_model.year == 2013
mock_extract_fsk.assert_called_once_with(mock_content_ratings_de)
def test_tmdb_to_nfo_without_content_ratings(self, nfo_service, mock_tmdb_data):
"""Test conversion without content ratings."""
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data, None)
assert nfo_model.title == "Attack on Titan"
assert nfo_model.fsk is None
assert nfo_model.tmdbid == 1429
def test_tmdb_to_nfo_basic_fields(self, nfo_service, mock_tmdb_data):
"""Test that all basic fields are correctly mapped."""
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data)
assert nfo_model.title == "Attack on Titan"
assert nfo_model.originaltitle == "進撃の巨人"
assert nfo_model.year == 2013
assert nfo_model.plot == "Several hundred years ago, humans were nearly..."
assert nfo_model.status == "Ended"
assert nfo_model.runtime == 24
assert nfo_model.premiered == "2013-04-07"
def test_tmdb_to_nfo_ids(self, nfo_service, mock_tmdb_data):
"""Test that all IDs are correctly mapped."""
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data)
assert nfo_model.tmdbid == 1429
assert nfo_model.imdbid == "tt2560140"
assert nfo_model.tvdbid == 267440
assert len(nfo_model.uniqueid) == 3
def test_tmdb_to_nfo_genres_studios(self, nfo_service, mock_tmdb_data):
"""Test that genres and studios are correctly mapped."""
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data)
assert "Animation" in nfo_model.genre
assert "Sci-Fi & Fantasy" in nfo_model.genre
assert "MBS" in nfo_model.studio
assert "Japan" in nfo_model.country
def test_tmdb_to_nfo_ratings(self, nfo_service, mock_tmdb_data):
"""Test that ratings are correctly mapped."""
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data)
assert len(nfo_model.ratings) == 1
assert nfo_model.ratings[0].name == "themoviedb"
assert nfo_model.ratings[0].value == 8.6
assert nfo_model.ratings[0].votes == 5000
def test_tmdb_to_nfo_cast(self, nfo_service, mock_tmdb_data):
"""Test that cast is correctly mapped."""
nfo_model = nfo_service._tmdb_to_nfo_model(mock_tmdb_data)
assert len(nfo_model.actors) == 1
assert nfo_model.actors[0].name == "Yuki Kaji"
assert nfo_model.actors[0].role == "Eren Yeager"
assert nfo_model.actors[0].tmdbid == 1
class TestCreateTVShowNFO:
"""Test NFO creation workflow."""
@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."""
# Create series folder
series_folder = tmp_path / "Attack on Titan"
series_folder.mkdir()
# Mock TMDB client methods
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": 1429, "name": "Attack on Titan", "first_air_date": "2013-04-07"}]}
mock_details.return_value = mock_tmdb_data
mock_ratings.return_value = mock_content_ratings_de
# Create NFO
nfo_path = await nfo_service.create_tvshow_nfo(
"Attack on Titan",
"Attack on Titan",
year=2013,
download_poster=False,
download_logo=False,
download_fanart=False
)
# Verify NFO was created
assert nfo_path.exists()
nfo_content = nfo_path.read_text(encoding="utf-8")
# Check that FSK rating is in the NFO
assert "<mpaa>FSK 16</mpaa>" in nfo_content
# Verify TMDB methods were called
mock_search.assert_called_once()
mock_details.assert_called_once_with(1429, append_to_response="credits,external_ids,images")
mock_ratings.assert_called_once_with(1429)
@pytest.mark.asyncio
async def test_create_nfo_without_fsk(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_no_de):
"""Test NFO creation fallback when no FSK available."""
# Create series folder
series_folder = tmp_path / "Attack on Titan"
series_folder.mkdir()
# Mock TMDB client methods
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": 1429, "name": "Attack on Titan", "first_air_date": "2013-04-07"}]}
mock_details.return_value = mock_tmdb_data
mock_ratings.return_value = mock_content_ratings_no_de
# Create NFO
nfo_path = await nfo_service.create_tvshow_nfo(
"Attack on Titan",
"Attack on Titan",
year=2013,
download_poster=False,
download_logo=False,
download_fanart=False
)
# Verify NFO was created
assert nfo_path.exists()
nfo_content = nfo_path.read_text(encoding="utf-8")
# FSK should not be in the NFO
assert "FSK" not in nfo_content
@pytest.mark.asyncio
async def test_update_nfo_with_fsk(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de):
"""Test NFO update includes FSK rating."""
# Create series folder with existing NFO
series_folder = tmp_path / "Attack on Titan"
series_folder.mkdir()
nfo_path = series_folder / "tvshow.nfo"
nfo_path.write_text("""<?xml version="1.0" encoding="UTF-8"?>
<tvshow>
<title>Attack on Titan</title>
<tmdbid>1429</tmdbid>
</tvshow>
""", encoding="utf-8")
# Mock TMDB client methods
with 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_details.return_value = mock_tmdb_data
mock_ratings.return_value = mock_content_ratings_de
# Update NFO
updated_path = await nfo_service.update_tvshow_nfo(
"Attack on Titan",
download_media=False
)
# Verify NFO was updated
assert updated_path.exists()
nfo_content = updated_path.read_text(encoding="utf-8")
# Check that FSK rating is in the updated NFO
assert "<mpaa>FSK 16</mpaa>" in nfo_content
# Verify TMDB methods were called
mock_details.assert_called_once_with(1429, append_to_response="credits,external_ids,images")
mock_ratings.assert_called_once_with(1429)
class TestNFOServiceEdgeCases:
"""Test edge cases in NFO service."""
@pytest.mark.asyncio
async def test_create_nfo_series_not_found(self, nfo_service, tmp_path):
"""Test NFO creation when series folder doesn't exist."""
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock):
with pytest.raises(FileNotFoundError):
await nfo_service.create_tvshow_nfo(
"Nonexistent Series",
"nonexistent_folder",
download_poster=False,
download_logo=False,
download_fanart=False
)
@pytest.mark.asyncio
async def test_create_nfo_no_tmdb_results(self, nfo_service, tmp_path):
"""Test NFO creation when TMDB returns no results."""
series_folder = tmp_path / "Test Series"
series_folder.mkdir()
with patch.object(nfo_service.tmdb_client, 'search_tv_show', new_callable=AsyncMock) as mock_search:
mock_search.return_value = {"results": []}
with pytest.raises(TMDBAPIError, match="No results found"):
await nfo_service.create_tvshow_nfo(
"Test Series",
"Test Series",
download_poster=False,
download_logo=False,
download_fanart=False
)
@pytest.mark.asyncio
async def test_update_nfo_missing_nfo(self, nfo_service, tmp_path):
"""Test NFO update when NFO doesn't exist."""
series_folder = tmp_path / "Test Series"
series_folder.mkdir()
with pytest.raises(FileNotFoundError):
await nfo_service.update_tvshow_nfo("Test Series")
@pytest.mark.asyncio
async def test_update_nfo_no_tmdb_id(self, nfo_service, tmp_path):
"""Test NFO update when NFO has no TMDB ID."""
series_folder = tmp_path / "Test Series"
series_folder.mkdir()
nfo_path = series_folder / "tvshow.nfo"
nfo_path.write_text("""<?xml version="1.0" encoding="UTF-8"?>
<tvshow>
<title>Test Series</title>
</tvshow>
""", encoding="utf-8")
with pytest.raises(TMDBAPIError, match="No TMDB ID found"):
await nfo_service.update_tvshow_nfo("Test Series")
@pytest.mark.asyncio
async def test_check_nfo_exists(self, nfo_service, tmp_path):
"""Test checking if NFO exists."""
series_folder = tmp_path / "Test Series"
series_folder.mkdir()
# NFO doesn't exist yet
exists = await nfo_service.check_nfo_exists("Test Series")
assert not exists
# Create NFO
nfo_path = series_folder / "tvshow.nfo"
nfo_path.write_text("<tvshow></tvshow>", encoding="utf-8")
# NFO now exists
exists = await nfo_service.check_nfo_exists("Test Series")
assert exists