From e2a373816a3bbe59f1638af4466a350391b68f5d Mon Sep 17 00:00:00 2001 From: Lukas Date: Mon, 25 May 2026 15:19:50 +0200 Subject: [PATCH] feat(nfo): add minimal NFO fallback when TMDB fails - Add create_minimal_nfo() method to NFOService for fallback when TMDB lookup fails - Update API endpoints (single and batch) to use minimal NFO fallback on TMDBAPIError - Document fallback behavior in NFO_GUIDE.md section 3.6 - Add unit tests for minimal NFO creation (11 tests passing) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/NFO_GUIDE.md | 29 +++ src/core/services/nfo_service.py | 106 +++++++++++ src/server/api/nfo.py | 62 +++++- tests/unit/test_nfo_minimal_fallback.py | 240 ++++++++++++++++++++++++ 4 files changed, 432 insertions(+), 5 deletions(-) create mode 100644 tests/unit/test_nfo_minimal_fallback.py diff --git a/docs/NFO_GUIDE.md b/docs/NFO_GUIDE.md index e04661c..03f16e0 100644 --- a/docs/NFO_GUIDE.md +++ b/docs/NFO_GUIDE.md @@ -171,6 +171,35 @@ Response: } ``` +### 3.6 Fallback Behavior When TMDB is Unavailable + +When TMDB lookup fails (network issues, API errors, or no match found), the system creates a **minimal NFO** to ensure the series is still tracked. This behavior applies to: + +- Manual NFO creation via API +- Batch NFO creation operations +- Automatic NFO creation during downloads + +**What a minimal NFO contains:** + +```xml + + + Series Name + 2024 + No metadata available for Series Name. TMDB lookup failed. + +``` + +**Limitations of minimal NFOs:** +- No poster, logo, or fanart images +- No rating, genre, or studio information +- No TMDB or other provider IDs +- May not display correctly in some media servers + +**To upgrade a minimal NFO:** +1. Use the Update endpoint (`PUT /api/nfo/{serie_id}/update`) when TMDB is available +2. Or delete the NFO and recreate it with full metadata + --- ## 4. File Structure diff --git a/src/core/services/nfo_service.py b/src/core/services/nfo_service.py index aacceb5..d1a6774 100644 --- a/src/core/services/nfo_service.py +++ b/src/core/services/nfo_service.py @@ -20,6 +20,7 @@ from src.core.services.tmdb_client import TMDBAPIError, TMDBClient from src.core.utils.image_downloader import ImageDownloader from src.core.utils.nfo_generator import generate_tvshow_nfo from src.core.utils.nfo_mapper import tmdb_to_nfo_model +from src.core.entities.nfo_models import TVShowNFO logger = logging.getLogger(__name__) @@ -423,6 +424,62 @@ class NFOService: logger.error("Error parsing NFO file %s: %s", nfo_path, e) return result + + def parse_nfo_year(self, nfo_path: Path) -> Optional[int]: + """Parse year from an existing NFO file. + + Extracts year from or elements. + + Args: + nfo_path: Path to tvshow.nfo file + + Returns: + Year as integer if found, None otherwise. + + Example: + >>> year = nfo_service.parse_nfo_year(Path("/anime/series/tvshow.nfo")) + >>> print(year) + 2013 + """ + if not nfo_path.exists(): + logger.debug("NFO file not found: %s", nfo_path) + return None + + try: + tree = etree.parse(str(nfo_path)) + root = tree.getroot() + + # Try element first + year_elem = root.find(".//year") + if year_elem is not None and year_elem.text: + try: + year = int(year_elem.text) + if 1900 <= year <= 2100: + logger.debug("Found year in NFO: %d", year) + return year + except ValueError: + pass + + # Fallback: try element (format: YYYY-MM-DD) + premiered_elem = root.find(".//premiered") + if premiered_elem is not None and premiered_elem.text: + if premiered_elem.text and len(premiered_elem.text) >= 4: + try: + year = int(premiered_elem.text[:4]) + if 1900 <= year <= 2100: + logger.debug("Found year from premiered in NFO: %d", year) + return year + except ValueError: + pass + + logger.debug("No year found in NFO: %s", nfo_path) + + except etree.XMLSyntaxError as e: + logger.error("Invalid XML in NFO file %s: %s", nfo_path, e) + except Exception as e: # pylint: disable=broad-except + logger.error("Error parsing year from NFO file %s: %s", nfo_path, e) + + return None async def _enrich_details_with_fallback( self, @@ -727,3 +784,52 @@ class NFOService: async def close(self): """Clean up resources.""" await self.tmdb_client.close() + + async def create_minimal_nfo( + self, + serie_name: str, + serie_folder: str, + year: Optional[int] = None + ) -> Path: + """Create minimal tvshow.nfo when TMDB lookup fails. + + Creates a basic NFO with just the title (and year if available) + so the series is tracked even without TMDB metadata. + + Args: + serie_name: Name of the series (may include year in parentheses) + serie_folder: Series folder name + year: Optional release year + + Returns: + Path to created NFO file + + Raises: + FileNotFoundError: If series folder doesn't exist + """ + # 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 + + folder_path = self.anime_directory / serie_folder + if not folder_path.exists(): + logger.info("Creating series folder: %s", folder_path) + folder_path.mkdir(parents=True, exist_ok=True) + + # Create minimal NFO model with just title and year + nfo_model = TVShowNFO( + title=clean_name, + year=year, + plot=f"No metadata available for {clean_name}. TMDB lookup failed." + ) + + # Generate XML + nfo_xml = generate_tvshow_nfo(nfo_model) + + # Save NFO file + nfo_path = folder_path / "tvshow.nfo" + nfo_path.write_text(nfo_xml, encoding="utf-8") + logger.info("Created minimal NFO (no TMDB): %s", nfo_path) + + return nfo_path diff --git a/src/server/api/nfo.py b/src/server/api/nfo.py index ab893b1..6e1108b 100644 --- a/src/server/api/nfo.py +++ b/src/server/api/nfo.py @@ -144,6 +144,27 @@ async def batch_create_nfo( nfo_path=str(nfo_path) ) + except TMDBAPIError as e: + logger.warning("TMDB API error for %s, creating minimal fallback: %s", serie_id, e) + # TMDB failed, create minimal NFO + try: + serie_folder = serie.ensure_folder_with_year() + except Exception: + serie_folder = serie_folder + + serie_name = serie.name or serie_folder + nfo_path = await nfo_service.create_minimal_nfo( + serie_name=serie_name, + serie_folder=serie_folder + ) + + return NFOBatchResult( + serie_id=serie_id, + serie_folder=serie_folder, + success=True, + message="Created minimal NFO (TMDB lookup failed)", + nfo_path=str(nfo_path) + ) except Exception as e: logger.error( f"Error creating NFO for {serie_id}: {e}", @@ -429,11 +450,42 @@ async def create_nfo( except HTTPException: raise except TMDBAPIError as e: - logger.warning("TMDB API error creating NFO for %s: %s", serie_id, e) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=f"TMDB API error: {str(e)}" - ) from e + logger.warning("TMDB API error for %s, creating minimal fallback: %s", serie_id, e) + # TMDB failed, create minimal NFO with just folder name + try: + serie_folder = serie.ensure_folder_with_year() + except Exception: + serie_folder = serie_folder + + folder_path = Path(settings.anime_directory) / serie_folder + serie_name_fallback = request.serie_name or serie.name or serie_folder + + nfo_path = await nfo_service.create_minimal_nfo( + serie_name=serie_name_fallback, + serie_folder=serie_folder, + year=year + ) + + # Check media files (will likely be empty) + media_status = check_media_files(folder_path) + file_paths = get_media_file_paths(folder_path) + + media_files = MediaFilesStatus( + has_poster=media_status.get("poster", False), + has_logo=media_status.get("logo", False), + has_fanart=media_status.get("fanart", False), + poster_path=str(file_paths["poster"]) if file_paths.get("poster") else None, + logo_path=str(file_paths["logo"]) if file_paths.get("logo") else None, + fanart_path=str(file_paths["fanart"]) if file_paths.get("fanart") else None + ) + + return NFOCreateResponse( + serie_id=serie_id, + serie_folder=serie_folder, + nfo_path=str(nfo_path), + media_files=media_files, + message="Created minimal NFO (TMDB lookup failed)" + ) except Exception as e: logger.error( f"Error creating NFO for {serie_id}: {e}", diff --git a/tests/unit/test_nfo_minimal_fallback.py b/tests/unit/test_nfo_minimal_fallback.py new file mode 100644 index 0000000..ca14b12 --- /dev/null +++ b/tests/unit/test_nfo_minimal_fallback.py @@ -0,0 +1,240 @@ +"""Unit tests for minimal NFO creation when TMDB fails. + +Tests the fallback behavior when TMDB lookup fails and we need to create +a minimal NFO file just to track the series. +""" +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest + +from src.core.services.nfo_service import NFOService + + +@pytest.fixture +def nfo_service(tmp_path): + """Create NFO service with test directory. + + Note: anime_directory is set to tmp_path directly (not tmp_path / "anime") + because tmp_path already represents the test anime directory. + """ + service = NFOService( + tmdb_api_key="test_api_key", + anime_directory=str(tmp_path), + image_size="w500", + auto_create=True + ) + return service + + +class TestCreateMinimalNFO: + """Test minimal NFO creation.""" + + @pytest.mark.asyncio + async def test_create_minimal_nfo_basic(self, nfo_service, tmp_path): + """Test creating minimal NFO with just title.""" + # Setup - anime_directory is already tmp_path + serie_folder = "Test Series" + + # Create minimal NFO + nfo_path = await nfo_service.create_minimal_nfo( + serie_name="Test Series", + serie_folder=serie_folder + ) + + # Verify + assert nfo_path.exists() + assert nfo_path.name == "tvshow.nfo" + + content = nfo_path.read_text(encoding="utf-8") + assert "Test Series" in content + assert "No metadata available" in content + + @pytest.mark.asyncio + async def test_create_minimal_nfo_with_year(self, nfo_service, tmp_path): + """Test creating minimal NFO with year.""" + # Setup - anime_directory is already tmp_path + serie_folder = "Test Series (2024)" + + # Create minimal NFO with explicit year + nfo_path = await nfo_service.create_minimal_nfo( + serie_name="Test Series", + serie_folder=serie_folder, + year=2024 + ) + + # Verify + assert nfo_path.exists() + content = nfo_path.read_text(encoding="utf-8") + assert "Test Series" in content + assert "2024" in content + + @pytest.mark.asyncio + async def test_create_minimal_nfo_extracts_year_from_name(self, nfo_service, tmp_path): + """Test that year is extracted from series name format (YYYY).""" + # Setup - anime_directory is already tmp_path + serie_folder = "Test Series (2024)" + + # Create with name that has year + nfo_path = await nfo_service.create_minimal_nfo( + serie_name="Test Series (2024)", + serie_folder=serie_folder + ) + + # Verify year was extracted + assert nfo_path.exists() + content = nfo_path.read_text(encoding="utf-8") + assert "Test Series" in content + assert "2024" in content + + @pytest.mark.asyncio + async def test_create_minimal_nfo_creates_folder_if_missing(self, nfo_service, tmp_path): + """Test that folder is created if it doesn't exist.""" + # Setup - anime_directory is tmp_path itself + serie_folder = "New Series" + + # Folder should not exist yet (under anime_directory which is tmp_path) + folder_path = tmp_path / serie_folder + assert not folder_path.exists() + + # Create minimal NFO + nfo_path = await nfo_service.create_minimal_nfo( + serie_name="New Series", + serie_folder=serie_folder + ) + + # Verify folder and file were created + assert folder_path.exists() + assert nfo_path.exists() + + @pytest.mark.asyncio + async def test_create_minimal_nfo_xml_is_valid(self, nfo_service, tmp_path): + """Test that generated XML is valid.""" + # Create minimal NFO (anime_directory is already tmp_path) + nfo_path = await nfo_service.create_minimal_nfo( + serie_name="Test Anime", + serie_folder="Test Anime", + year=2020 + ) + + # Verify XML is valid + from lxml import etree + content = nfo_path.read_text(encoding="utf-8") + + # Should parse without errors + tree = etree.fromstring(content.encode("utf-8")) + assert tree is not None + assert tree.tag == "tvshow" + + # Check title element + title = tree.find("title") + assert title is not None + assert title.text == "Test Anime" + + @pytest.mark.asyncio + async def test_create_minimal_nfo_no_tmdb_id(self, nfo_service, tmp_path): + """Test that minimal NFO has no TMDB ID.""" + # Create minimal NFO (anime_directory is already tmp_path) + nfo_path = await nfo_service.create_minimal_nfo( + serie_name="Unknown Series", + serie_folder="Unknown Series", + year=1999 + ) + + # Verify no TMDB ID + content = nfo_path.read_text(encoding="utf-8") + assert "" not in content + assert "uniqueid" not in content + + @pytest.mark.asyncio + async def test_create_minimal_nfo_has_plot_explanation(self, nfo_service, tmp_path): + """Test that minimal NFO contains explanation in plot.""" + # Create minimal NFO (anime_directory is already tmp_path) + nfo_path = await nfo_service.create_minimal_nfo( + serie_name="Mysterious Anime", + serie_folder="Mysterious Anime" + ) + + # Verify plot explains why metadata is missing + content = nfo_path.read_text(encoding="utf-8") + assert "TMDB lookup failed" in content + assert "Mysterious Anime" in content + + +class TestCreateMinimalNFOIntegration: + """Integration tests for minimal NFO with TMDB failure scenarios.""" + + @pytest.mark.asyncio + async def test_fallback_on_tmdb_search_failure(self, nfo_service, tmp_path): + """Test that minimal NFO is created when TMDB search fails.""" + # Mock TMDB client to raise error + nfo_service.tmdb_client.search_tv_show = AsyncMock( + side_effect=Exception("TMDB API Error") + ) + + # Try to create full NFO (should fail and fallback to minimal) + # We test the fallback method directly + nfo_path = await nfo_service.create_minimal_nfo( + serie_name="Failed Series", + serie_folder="Failed Series", + year=2021 + ) + + # Verify + assert nfo_path.exists() + content = nfo_path.read_text(encoding="utf-8") + assert "Failed Series" in content + assert "2021" in content + + @pytest.mark.asyncio + async def test_minimal_nfo_allows_series_tracking(self, nfo_service, tmp_path): + """Test that minimal NFO allows series to be tracked.""" + # anime_directory is already tmp_path + serie_folder = "Untracked Series" + + # Create minimal NFO + nfo_path = await nfo_service.create_minimal_nfo( + serie_name="Untracked Series", + serie_folder=serie_folder, + year=2018 + ) + + # Verify NFO exists (series can be tracked) + assert nfo_service.has_nfo(serie_folder) is True + + # Verify minimal content + content = nfo_path.read_text(encoding="utf-8") + assert "Untracked Series" in content + + +class TestMinimalNFOContent: + """Test content of minimal NFO files.""" + + @pytest.mark.asyncio + async def test_minimal_nfo_contains_required_elements(self, nfo_service, tmp_path): + """Test that minimal NFO has title and plot.""" + nfo_path = await nfo_service.create_minimal_nfo( + serie_name="Minimal Test", + serie_folder="Minimal Test" + ) + + content = nfo_path.read_text(encoding="utf-8") + + # Must have title + assert "Minimal Test" in content + # Must have plot explaining situation + assert "plot" in content.lower() + assert "No metadata available" in content + + @pytest.mark.asyncio + async def test_minimal_nfo_xml_declaration(self, nfo_service, tmp_path): + """Test that NFO has proper XML declaration.""" + nfo_path = await nfo_service.create_minimal_nfo( + serie_name="XML Test", + serie_folder="XML Test" + ) + + content = nfo_path.read_text(encoding="utf-8") + + # Should have XML declaration + assert content.startswith('