- 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
1537 lines
64 KiB
Python
1537 lines
64 KiB
Python
"""Unit tests for NFO service."""
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from src.core.services.nfo_service import NFOService
|
|
from src.core.services.tmdb_client import TMDBAPIError
|
|
from src.core.utils.nfo_mapper import _extract_fsk_rating, tmdb_to_nfo_model
|
|
|
|
|
|
@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 = _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 = _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 = _extract_fsk_rating({})
|
|
assert fsk is None
|
|
|
|
def test_extract_fsk_rating_none(self, nfo_service):
|
|
"""Test extraction with None input."""
|
|
fsk = _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 = _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 = _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 = _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 = _extract_fsk_rating(content_ratings)
|
|
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."""
|
|
|
|
@patch('src.core.utils.nfo_mapper._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 = tmdb_to_nfo_model(
|
|
mock_tmdb_data, mock_content_ratings_de,
|
|
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
|
|
)
|
|
|
|
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 = tmdb_to_nfo_model(
|
|
mock_tmdb_data, None,
|
|
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
|
|
)
|
|
|
|
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 = tmdb_to_nfo_model(
|
|
mock_tmdb_data, None,
|
|
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
|
|
)
|
|
|
|
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 = tmdb_to_nfo_model(
|
|
mock_tmdb_data, None,
|
|
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
|
|
)
|
|
|
|
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 = tmdb_to_nfo_model(
|
|
mock_tmdb_data, None,
|
|
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
|
|
)
|
|
|
|
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 = tmdb_to_nfo_model(
|
|
mock_tmdb_data, None,
|
|
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
|
|
)
|
|
|
|
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 = tmdb_to_nfo_model(
|
|
mock_tmdb_data, None,
|
|
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
|
|
)
|
|
|
|
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_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."""
|
|
# 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 TestEnrichDetailsWithFallback:
|
|
"""Tests for English fallback when German overview is empty."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_nfo_uses_english_fallback_for_empty_overview(
|
|
self, nfo_service, tmp_path
|
|
):
|
|
"""When the German overview is empty, create_tvshow_nfo should
|
|
fetch the English overview from TMDB and include it as <plot>."""
|
|
series_folder = tmp_path / "Basilisk"
|
|
series_folder.mkdir()
|
|
|
|
# German TMDB data with empty overview
|
|
de_data = {
|
|
"id": 35014, "name": "Basilisk",
|
|
"original_name": "甲賀忍法帖", "first_air_date": "2005-04-13",
|
|
"overview": "", # <-- empty German overview
|
|
"vote_average": 7.2, "vote_count": 200,
|
|
"status": "Ended", "episode_run_time": [24],
|
|
"genres": [{"id": 16, "name": "Animation"}],
|
|
"networks": [{"id": 1, "name": "MBS"}],
|
|
"production_countries": [{"name": "Japan"}],
|
|
"poster_path": "/poster.jpg", "backdrop_path": "/backdrop.jpg",
|
|
"external_ids": {"imdb_id": "tt0464064", "tvdb_id": 79604},
|
|
"credits": {"cast": []},
|
|
"images": {"logos": []},
|
|
}
|
|
|
|
# English TMDB data with overview
|
|
en_data = {
|
|
"id": 35014,
|
|
"overview": "The year is 1614 and two warring ninja clans collide.",
|
|
"tagline": "Blood spills when ninja clans clash.",
|
|
}
|
|
|
|
async def side_effect(tv_id, **kwargs):
|
|
if kwargs.get("language") == "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": 35014, "name": "Basilisk", "first_air_date": "2005-04-13"}]
|
|
}
|
|
mock_details.side_effect = side_effect
|
|
mock_ratings.return_value = {"results": []}
|
|
|
|
nfo_path = await nfo_service.create_tvshow_nfo(
|
|
"Basilisk", "Basilisk", year=2005,
|
|
download_poster=False, download_logo=False, download_fanart=False,
|
|
)
|
|
|
|
content = nfo_path.read_text(encoding="utf-8")
|
|
assert "<plot>The year is 1614" in content
|
|
# Details called twice: once for de-DE, once for en-US fallback
|
|
assert mock_details.call_count == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_nfo_uses_english_fallback_for_empty_overview(
|
|
self, nfo_service, tmp_path
|
|
):
|
|
"""update_tvshow_nfo should also use the English fallback."""
|
|
series_folder = tmp_path / "Basilisk"
|
|
series_folder.mkdir()
|
|
nfo_path = series_folder / "tvshow.nfo"
|
|
nfo_path.write_text(
|
|
'<?xml version="1.0"?>\n<tvshow><title>Basilisk</title>'
|
|
"<tmdbid>35014</tmdbid></tvshow>",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
de_data = {
|
|
"id": 35014, "name": "Basilisk",
|
|
"original_name": "甲賀忍法帖", "first_air_date": "2005-04-13",
|
|
"overview": "",
|
|
"vote_average": 7.2, "vote_count": 200,
|
|
"status": "Ended", "episode_run_time": [24],
|
|
"genres": [{"id": 16, "name": "Animation"}],
|
|
"networks": [{"id": 1, "name": "MBS"}],
|
|
"production_countries": [{"name": "Japan"}],
|
|
"poster_path": "/poster.jpg", "backdrop_path": "/backdrop.jpg",
|
|
"external_ids": {"imdb_id": "tt0464064", "tvdb_id": 79604},
|
|
"credits": {"cast": []},
|
|
"images": {"logos": []},
|
|
}
|
|
en_data = {
|
|
"id": 35014,
|
|
"overview": "English fallback overview for Basilisk.",
|
|
}
|
|
|
|
async def side_effect(tv_id, **kwargs):
|
|
if kwargs.get("language") == "en-US":
|
|
return en_data
|
|
return de_data
|
|
|
|
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.side_effect = side_effect
|
|
mock_ratings.return_value = {"results": []}
|
|
|
|
updated_path = await nfo_service.update_tvshow_nfo(
|
|
"Basilisk", download_media=False,
|
|
)
|
|
|
|
content = updated_path.read_text(encoding="utf-8")
|
|
assert "<plot>English fallback overview" in content
|
|
assert mock_details.call_count == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_fallback_when_german_overview_exists(
|
|
self, nfo_service, tmp_path
|
|
):
|
|
"""No English fallback call when German overview is present."""
|
|
series_folder = tmp_path / "Attack on Titan"
|
|
series_folder.mkdir()
|
|
|
|
de_data = {
|
|
"id": 1429, "name": "Attack on Titan",
|
|
"original_name": "進撃の巨人", "first_air_date": "2013-04-07",
|
|
"overview": "Vor mehreren hundert Jahren...",
|
|
"vote_average": 8.6, "vote_count": 5000,
|
|
"status": "Ended", "episode_run_time": [24],
|
|
"genres": [], "networks": [], "production_countries": [],
|
|
"poster_path": None, "backdrop_path": None,
|
|
"external_ids": {}, "credits": {"cast": []},
|
|
"images": {"logos": []},
|
|
}
|
|
|
|
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 = de_data
|
|
mock_ratings.return_value = {"results": []}
|
|
|
|
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,
|
|
)
|
|
|
|
content = nfo_path.read_text(encoding="utf-8")
|
|
assert "<plot>Vor mehreren hundert Jahren...</plot>" in content
|
|
# Only one detail call (German), no fallback needed
|
|
mock_details.assert_called_once_with(1429, append_to_response="credits,external_ids,images")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plot_tag_always_present_even_if_empty(
|
|
self, nfo_service, tmp_path
|
|
):
|
|
"""<plot> tag should always be present, even when overview is missing
|
|
from both German and English TMDB data."""
|
|
series_folder = tmp_path / "Unknown Show"
|
|
series_folder.mkdir()
|
|
|
|
empty_data = {
|
|
"id": 99999, "name": "Unknown Show",
|
|
"original_name": "Unknown", "first_air_date": "2020-01-01",
|
|
"overview": "",
|
|
"vote_average": 0, "vote_count": 0,
|
|
"status": "Ended", "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):
|
|
# English also empty
|
|
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):
|
|
|
|
mock_search.return_value = {
|
|
"results": [{"id": 99999, "name": "Unknown Show", "first_air_date": "2020-01-01"}]
|
|
}
|
|
mock_details.side_effect = side_effect
|
|
mock_ratings.return_value = {"results": []}
|
|
|
|
nfo_path = await nfo_service.create_tvshow_nfo(
|
|
"Unknown Show", "Unknown Show",
|
|
download_poster=False, download_logo=False, download_fanart=False,
|
|
)
|
|
|
|
content = nfo_path.read_text(encoding="utf-8")
|
|
# <plot/> (self-closing) or <plot></plot> should be present
|
|
assert "<plot" in content
|
|
|
|
|
|
class TestNFOServiceEdgeCases:
|
|
"""Test edge cases in NFO service."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_nfo_with_valid_path(self, nfo_service, tmp_path):
|
|
"""Test NFO creation succeeds with valid path."""
|
|
series_folder = tmp_path / "Test Series"
|
|
series_folder.mkdir()
|
|
|
|
# Mock all necessary 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):
|
|
|
|
tmdb_data = {
|
|
"id": 1, "name": "Series", "first_air_date": "2020-01-01",
|
|
"original_name": "Original", "overview": "Test", "vote_average": 8.0,
|
|
"vote_count": 100, "status": "Continuing", "episode_run_time": [24],
|
|
"genres": [], "networks": [], "production_countries": [],
|
|
"external_ids": {}, "credits": {"cast": []}, "images": {"logos": []},
|
|
"poster_path": None, "backdrop_path": None
|
|
}
|
|
|
|
mock_search.return_value = {"results": [{"id": 1, "name": "Series", "first_air_date": "2020-01-01"}]}
|
|
mock_details.return_value = tmdb_data
|
|
mock_ratings.return_value = {"results": []}
|
|
|
|
nfo_path = await nfo_service.create_tvshow_nfo(
|
|
"Test Series",
|
|
"Test Series",
|
|
download_poster=False,
|
|
download_logo=False,
|
|
download_fanart=False
|
|
)
|
|
|
|
assert nfo_path.exists()
|
|
|
|
@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
|
|
|
|
|
|
class TestMediaDownloads:
|
|
"""Test media file (poster, logo, fanart) download functionality."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_media_all_enabled(self, nfo_service, tmp_path, mock_tmdb_data):
|
|
"""Test downloading all media files when enabled."""
|
|
series_folder = tmp_path / "Test Series"
|
|
series_folder.mkdir()
|
|
|
|
with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
|
|
mock_download.return_value = {
|
|
"poster": True,
|
|
"logo": True,
|
|
"fanart": True
|
|
}
|
|
|
|
results = await nfo_service._download_media_files(
|
|
mock_tmdb_data,
|
|
series_folder,
|
|
download_poster=True,
|
|
download_logo=True,
|
|
download_fanart=True
|
|
)
|
|
|
|
assert results["poster"] is True
|
|
assert results["logo"] is True
|
|
assert results["fanart"] is True
|
|
mock_download.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_media_poster_only(self, nfo_service, tmp_path, mock_tmdb_data):
|
|
"""Test downloading only poster."""
|
|
series_folder = tmp_path / "Test Series"
|
|
series_folder.mkdir()
|
|
|
|
with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
|
|
mock_download.return_value = {"poster": True}
|
|
|
|
results = await nfo_service._download_media_files(
|
|
mock_tmdb_data,
|
|
series_folder,
|
|
download_poster=True,
|
|
download_logo=False,
|
|
download_fanart=False
|
|
)
|
|
|
|
# Verify only poster URL was passed
|
|
call_args = mock_download.call_args
|
|
assert call_args.kwargs['poster_url'] is not None
|
|
assert call_args.kwargs['logo_url'] is None
|
|
assert call_args.kwargs['fanart_url'] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_media_with_image_size(self, nfo_service, tmp_path, mock_tmdb_data):
|
|
"""Test that image size configuration is used."""
|
|
nfo_service.image_size = "w500"
|
|
series_folder = tmp_path / "Test Series"
|
|
series_folder.mkdir()
|
|
|
|
with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
|
|
mock_download.return_value = {"poster": True}
|
|
|
|
await nfo_service._download_media_files(
|
|
mock_tmdb_data,
|
|
series_folder,
|
|
download_poster=True,
|
|
download_logo=False,
|
|
download_fanart=False
|
|
)
|
|
|
|
# Verify image size was used for poster
|
|
call_args = mock_download.call_args
|
|
poster_url = call_args.kwargs['poster_url']
|
|
assert "w500" in poster_url
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_media_missing_poster_path(self, nfo_service, tmp_path):
|
|
"""Test media download when poster path is missing."""
|
|
series_folder = tmp_path / "Test Series"
|
|
series_folder.mkdir()
|
|
|
|
tmdb_data_no_poster = {
|
|
"id": 1,
|
|
"name": "Test",
|
|
"poster_path": None,
|
|
"backdrop_path": "/backdrop.jpg",
|
|
"images": {"logos": [{"file_path": "/logo.png"}]}
|
|
}
|
|
|
|
with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
|
|
mock_download.return_value = {}
|
|
|
|
await nfo_service._download_media_files(
|
|
tmdb_data_no_poster,
|
|
series_folder,
|
|
download_poster=True,
|
|
download_logo=True,
|
|
download_fanart=True
|
|
)
|
|
|
|
# Poster URL should be None
|
|
call_args = mock_download.call_args
|
|
assert call_args.kwargs['poster_url'] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_media_no_logo_available(self, nfo_service, tmp_path):
|
|
"""Test media download when logo is not available."""
|
|
series_folder = tmp_path / "Test Series"
|
|
series_folder.mkdir()
|
|
|
|
tmdb_data_no_logo = {
|
|
"id": 1,
|
|
"name": "Test",
|
|
"poster_path": "/poster.jpg",
|
|
"backdrop_path": "/backdrop.jpg",
|
|
"images": {"logos": []} # Empty logos array
|
|
}
|
|
|
|
with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
|
|
mock_download.return_value = {"poster": True, "fanart": True}
|
|
|
|
await nfo_service._download_media_files(
|
|
tmdb_data_no_logo,
|
|
series_folder,
|
|
download_poster=True,
|
|
download_logo=True,
|
|
download_fanart=True
|
|
)
|
|
|
|
# Logo URL should be None
|
|
call_args = mock_download.call_args
|
|
assert call_args.kwargs['logo_url'] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_media_all_disabled(self, nfo_service, tmp_path, mock_tmdb_data):
|
|
"""Test that no downloads occur when all disabled."""
|
|
series_folder = tmp_path / "Test Series"
|
|
series_folder.mkdir()
|
|
|
|
with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
|
|
mock_download.return_value = {}
|
|
|
|
await nfo_service._download_media_files(
|
|
mock_tmdb_data,
|
|
series_folder,
|
|
download_poster=False,
|
|
download_logo=False,
|
|
download_fanart=False
|
|
)
|
|
|
|
# All URLs should be None
|
|
call_args = mock_download.call_args
|
|
assert call_args.kwargs['poster_url'] is None
|
|
assert call_args.kwargs['logo_url'] is None
|
|
assert call_args.kwargs['fanart_url'] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_media_fanart_uses_original_size(self, nfo_service, tmp_path, mock_tmdb_data):
|
|
"""Test that fanart always uses original size regardless of config."""
|
|
nfo_service.image_size = "w500"
|
|
series_folder = tmp_path / "Test Series"
|
|
series_folder.mkdir()
|
|
|
|
with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
|
|
mock_download.return_value = {"fanart": True}
|
|
|
|
await nfo_service._download_media_files(
|
|
mock_tmdb_data,
|
|
series_folder,
|
|
download_poster=False,
|
|
download_logo=False,
|
|
download_fanart=True
|
|
)
|
|
|
|
# Fanart should use original size
|
|
call_args = mock_download.call_args
|
|
fanart_url = call_args.kwargs['fanart_url']
|
|
assert "original" in fanart_url
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_media_logo_uses_original_size(self, nfo_service, tmp_path, mock_tmdb_data):
|
|
"""Test that logo always uses original size."""
|
|
nfo_service.image_size = "w500"
|
|
series_folder = tmp_path / "Test Series"
|
|
series_folder.mkdir()
|
|
|
|
with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
|
|
mock_download.return_value = {"logo": True}
|
|
|
|
await nfo_service._download_media_files(
|
|
mock_tmdb_data,
|
|
series_folder,
|
|
download_poster=False,
|
|
download_logo=True,
|
|
download_fanart=False
|
|
)
|
|
|
|
# Logo should use original size
|
|
call_args = mock_download.call_args
|
|
logo_url = call_args.kwargs['logo_url']
|
|
assert "original" in logo_url
|
|
|
|
|
|
class TestNFOServiceConfiguration:
|
|
"""Test NFO service with various configuration settings."""
|
|
|
|
def test_nfo_service_default_config(self, tmp_path):
|
|
"""Test NFO service initialization with default config."""
|
|
service = NFOService(
|
|
tmdb_api_key="test_key",
|
|
anime_directory=str(tmp_path)
|
|
)
|
|
|
|
assert service.image_size == "original"
|
|
assert service.auto_create is True
|
|
|
|
def test_nfo_service_custom_config(self, tmp_path):
|
|
"""Test NFO service initialization with custom config."""
|
|
service = NFOService(
|
|
tmdb_api_key="test_key",
|
|
anime_directory=str(tmp_path),
|
|
image_size="w500",
|
|
auto_create=False
|
|
)
|
|
|
|
assert service.image_size == "w500"
|
|
assert service.auto_create is False
|
|
|
|
def test_nfo_service_image_sizes(self, tmp_path):
|
|
"""Test NFO service with various image sizes."""
|
|
sizes = ["original", "w500", "w780", "w342"]
|
|
|
|
for size in sizes:
|
|
service = NFOService(
|
|
tmdb_api_key="test_key",
|
|
anime_directory=str(tmp_path),
|
|
image_size=size
|
|
)
|
|
assert service.image_size == size
|
|
|
|
|
|
class TestHasNFOMethod:
|
|
"""Test the has_nfo method."""
|
|
|
|
def test_has_nfo_true(self, nfo_service, tmp_path):
|
|
"""Test has_nfo returns True when NFO exists."""
|
|
series_folder = tmp_path / "Test Series"
|
|
series_folder.mkdir()
|
|
nfo_path = series_folder / "tvshow.nfo"
|
|
nfo_path.write_text("<tvshow></tvshow>")
|
|
|
|
assert nfo_service.has_nfo("Test Series") is True
|
|
|
|
def test_has_nfo_false(self, nfo_service, tmp_path):
|
|
"""Test has_nfo returns False when NFO doesn't exist."""
|
|
series_folder = tmp_path / "Test Series"
|
|
series_folder.mkdir()
|
|
|
|
assert nfo_service.has_nfo("Test Series") is False
|
|
|
|
def test_has_nfo_missing_folder(self, nfo_service):
|
|
"""Test has_nfo returns False when folder doesn't exist."""
|
|
assert nfo_service.has_nfo("Nonexistent Series") is False
|
|
|
|
|
|
class TestFindBestMatchEdgeCases:
|
|
"""Test edge cases in _find_best_match."""
|
|
|
|
def test_find_best_match_no_year_multiple_results(self, nfo_service):
|
|
"""Test finding best match returns first result when no year."""
|
|
results = [
|
|
{"id": 1, "name": "Series", "first_air_date": "2010-01-01"},
|
|
{"id": 2, "name": "Series", "first_air_date": "2020-01-01"},
|
|
]
|
|
|
|
match = nfo_service._find_best_match(results, "Series", year=None)
|
|
assert match["id"] == 1
|
|
|
|
def test_find_best_match_year_no_match(self, nfo_service):
|
|
"""Test finding best match with year when no exact match returns first."""
|
|
results = [
|
|
{"id": 1, "name": "Series", "first_air_date": "2010-01-01"},
|
|
{"id": 2, "name": "Series", "first_air_date": "2020-01-01"},
|
|
]
|
|
|
|
match = nfo_service._find_best_match(results, "Series", year=2025)
|
|
# Should return first result as no year match found
|
|
assert match["id"] == 1
|
|
|
|
def test_find_best_match_empty_results(self, nfo_service):
|
|
"""Test finding best match with empty results raises error."""
|
|
with pytest.raises(TMDBAPIError, match="No search results"):
|
|
nfo_service._find_best_match([], "Series")
|
|
|
|
def test_find_best_match_no_first_air_date(self, nfo_service):
|
|
"""Test finding best match when result has no first_air_date."""
|
|
results = [
|
|
{"id": 1, "name": "Series"}, # No first_air_date
|
|
{"id": 2, "name": "Series", "first_air_date": "2020-01-01"},
|
|
]
|
|
|
|
# With year, should check for first_air_date existence
|
|
match = nfo_service._find_best_match(results, "Series", year=2020)
|
|
assert match["id"] == 2
|
|
|
|
|
|
class TestParseNFOIDsEdgeCases:
|
|
"""Test edge cases in parse_nfo_ids."""
|
|
|
|
def test_parse_nfo_ids_malformed_ids(self, nfo_service, tmp_path):
|
|
"""Test parsing IDs with malformed values."""
|
|
nfo_path = tmp_path / "tvshow.nfo"
|
|
nfo_path.write_text(
|
|
'<tvshow>'
|
|
'<uniqueid type="tmdb">not_a_number</uniqueid>'
|
|
'<uniqueid type="tvdb">abc123</uniqueid>'
|
|
'</tvshow>'
|
|
)
|
|
|
|
ids = nfo_service.parse_nfo_ids(nfo_path)
|
|
|
|
# Malformed values should be None
|
|
assert ids["tmdb_id"] is None
|
|
assert ids["tvdb_id"] is None
|
|
|
|
def test_parse_nfo_ids_multiple_uniqueid(self, nfo_service, tmp_path):
|
|
"""Test parsing when multiple uniqueid elements exist."""
|
|
nfo_path = tmp_path / "tvshow.nfo"
|
|
nfo_path.write_text(
|
|
'<tvshow>'
|
|
'<uniqueid type="tmdb">1429</uniqueid>'
|
|
'<uniqueid type="tvdb">79168</uniqueid>'
|
|
'<uniqueid type="imdb">tt2560140</uniqueid>'
|
|
'</tvshow>'
|
|
)
|
|
|
|
ids = nfo_service.parse_nfo_ids(nfo_path)
|
|
|
|
assert ids["tmdb_id"] == 1429
|
|
assert ids["tvdb_id"] == 79168
|
|
|
|
def test_parse_nfo_ids_empty_uniqueid(self, nfo_service, tmp_path):
|
|
"""Test parsing with empty uniqueid elements."""
|
|
nfo_path = tmp_path / "tvshow.nfo"
|
|
nfo_path.write_text(
|
|
'<tvshow>'
|
|
'<uniqueid type="tmdb"></uniqueid>'
|
|
'<uniqueid type="tvdb"></uniqueid>'
|
|
'</tvshow>'
|
|
)
|
|
|
|
ids = nfo_service.parse_nfo_ids(nfo_path)
|
|
|
|
assert ids["tmdb_id"] is None
|
|
assert ids["tvdb_id"] is None
|
|
|
|
|
|
class TestTMDBToNFOModelEdgeCases:
|
|
"""Test edge cases in _tmdb_to_nfo_model."""
|
|
|
|
def test_tmdb_to_nfo_minimal_data(self, nfo_service):
|
|
"""Test conversion with minimal TMDB data."""
|
|
minimal_data = {
|
|
"id": 1,
|
|
"name": "Series",
|
|
"original_name": "Original"
|
|
}
|
|
|
|
nfo_model = tmdb_to_nfo_model(
|
|
minimal_data, None,
|
|
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
|
|
)
|
|
|
|
assert nfo_model.title == "Series"
|
|
assert nfo_model.originaltitle == "Original"
|
|
assert nfo_model.year is None
|
|
assert nfo_model.tmdbid == 1
|
|
|
|
def test_tmdb_to_nfo_with_all_cast(self, nfo_service, mock_tmdb_data):
|
|
"""Test conversion includes cast members."""
|
|
nfo_model = tmdb_to_nfo_model(
|
|
mock_tmdb_data, None,
|
|
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
|
|
)
|
|
|
|
assert len(nfo_model.actors) >= 1
|
|
assert nfo_model.actors[0].name == "Yuki Kaji"
|
|
assert nfo_model.actors[0].role == "Eren Yeager"
|
|
|
|
def test_tmdb_to_nfo_multiple_genres(self, nfo_service, mock_tmdb_data):
|
|
"""Test conversion with multiple genres."""
|
|
nfo_model = tmdb_to_nfo_model(
|
|
mock_tmdb_data, None,
|
|
nfo_service.tmdb_client.get_image_url, nfo_service.image_size
|
|
)
|
|
|
|
assert "Animation" in nfo_model.genre
|
|
assert "Sci-Fi & Fantasy" in nfo_model.genre
|
|
|
|
|
|
class TestExtractFSKRatingEdgeCases:
|
|
"""Test edge cases in _extract_fsk_rating."""
|
|
|
|
def test_extract_fsk_with_suffix(self, nfo_service):
|
|
"""Test extraction when rating has suffix like 'Ab 16 Jahren'."""
|
|
content_ratings = {
|
|
"results": [{"iso_3166_1": "DE", "rating": "Ab 16 Jahren"}]
|
|
}
|
|
|
|
fsk = _extract_fsk_rating(content_ratings)
|
|
assert fsk == "FSK 16"
|
|
|
|
def test_extract_fsk_multiple_numbers(self, nfo_service):
|
|
"""Test extraction with multiple numbers - should pick highest."""
|
|
content_ratings = {
|
|
"results": [{"iso_3166_1": "DE", "rating": "Rating 6 or 12"}]
|
|
}
|
|
|
|
fsk = _extract_fsk_rating(content_ratings)
|
|
# Should find 12 first in the search order
|
|
assert fsk == "FSK 12"
|
|
|
|
def test_extract_fsk_empty_results_list(self, nfo_service):
|
|
"""Test extraction with empty results list."""
|
|
content_ratings = {"results": []}
|
|
|
|
fsk = _extract_fsk_rating(content_ratings)
|
|
assert fsk is None
|
|
|
|
def test_extract_fsk_none_input(self, nfo_service):
|
|
"""Test extraction with None input."""
|
|
fsk = _extract_fsk_rating(None)
|
|
assert fsk is None
|
|
|
|
def test_extract_fsk_missing_results_key(self, nfo_service):
|
|
"""Test extraction when results key is missing."""
|
|
fsk = _extract_fsk_rating({})
|
|
assert fsk is None
|
|
|
|
|
|
class TestDownloadMediaFilesEdgeCases:
|
|
"""Test edge cases in _download_media_files."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_media_empty_tmdb_data(self, nfo_service, tmp_path):
|
|
"""Test media download with empty TMDB data."""
|
|
series_folder = tmp_path / "Test Series"
|
|
series_folder.mkdir()
|
|
|
|
with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
|
|
mock_download.return_value = {}
|
|
|
|
results = await nfo_service._download_media_files(
|
|
{},
|
|
series_folder,
|
|
download_poster=True,
|
|
download_logo=True,
|
|
download_fanart=True
|
|
)
|
|
|
|
# Should call download with all None URLs
|
|
call_args = mock_download.call_args
|
|
assert call_args.kwargs['poster_url'] is None
|
|
assert call_args.kwargs['logo_url'] is None
|
|
assert call_args.kwargs['fanart_url'] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_media_only_poster_available(self, nfo_service, tmp_path):
|
|
"""Test media download when only poster is available."""
|
|
series_folder = tmp_path / "Test Series"
|
|
series_folder.mkdir()
|
|
|
|
tmdb_data = {
|
|
"id": 1,
|
|
"poster_path": "/poster.jpg",
|
|
"backdrop_path": None,
|
|
"images": {"logos": []}
|
|
}
|
|
|
|
with patch.object(nfo_service.image_downloader, 'download_all_media', new_callable=AsyncMock) as mock_download:
|
|
mock_download.return_value = {"poster": True}
|
|
|
|
await nfo_service._download_media_files(
|
|
tmdb_data,
|
|
series_folder,
|
|
download_poster=True,
|
|
download_logo=True,
|
|
download_fanart=True
|
|
)
|
|
|
|
call_args = mock_download.call_args
|
|
assert call_args.kwargs['poster_url'] is not None
|
|
assert call_args.kwargs['fanart_url'] is None
|
|
assert call_args.kwargs['logo_url'] is None
|
|
|
|
|
|
class TestUpdateNFOEdgeCases:
|
|
"""Test edge cases in update_tvshow_nfo."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_nfo_without_media_download(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de):
|
|
"""Test NFO update without re-downloading media."""
|
|
series_folder = tmp_path / "Attack on Titan"
|
|
series_folder.mkdir()
|
|
nfo_path = series_folder / "tvshow.nfo"
|
|
nfo_path.write_text(
|
|
'<tvshow><uniqueid type="tmdb">1429</uniqueid></tvshow>',
|
|
encoding="utf-8"
|
|
)
|
|
|
|
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) as mock_download:
|
|
|
|
mock_details.return_value = mock_tmdb_data
|
|
mock_ratings.return_value = mock_content_ratings_de
|
|
|
|
await nfo_service.update_tvshow_nfo("Attack on Titan", download_media=False)
|
|
|
|
# Verify download was not called
|
|
mock_download.assert_not_called()
|
|
|
|
|
|
class TestNFOServiceClose:
|
|
"""Test NFO service cleanup and close."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nfo_service_close(self, nfo_service):
|
|
"""Test NFO service close."""
|
|
with patch.object(nfo_service.tmdb_client, 'close', new_callable=AsyncMock) as mock_close:
|
|
await nfo_service.close()
|
|
mock_close.assert_called_once()
|
|
|
|
|
|
class TestYearExtractionComprehensive:
|
|
"""Comprehensive tests for year extraction."""
|
|
|
|
def test_extract_year_with_leading_spaces(self, nfo_service):
|
|
"""Test extraction with leading spaces - they get stripped."""
|
|
clean_name, year = nfo_service._extract_year_from_name(" Attack on Titan (2013)")
|
|
assert clean_name == "Attack on Titan" # Leading spaces are stripped
|
|
assert year == 2013
|
|
|
|
def test_extract_year_with_year_in_middle(self, nfo_service):
|
|
"""Test that year in middle doesn't get extracted."""
|
|
clean_name, year = nfo_service._extract_year_from_name("Attack on Titan 2013")
|
|
assert clean_name == "Attack on Titan 2013"
|
|
assert year is None
|
|
|
|
def test_extract_year_three_digit(self, nfo_service):
|
|
"""Test that 3-digit number is not extracted."""
|
|
clean_name, year = nfo_service._extract_year_from_name("Series (123)")
|
|
assert clean_name == "Series (123)"
|
|
assert year is None
|
|
|
|
def test_extract_year_five_digit(self, nfo_service):
|
|
"""Test that 5-digit number is not extracted."""
|
|
clean_name, year = nfo_service._extract_year_from_name("Series (12345)")
|
|
assert clean_name == "Series (12345)"
|
|
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
|
|
|