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('