diff --git a/docs/NFO_GUIDE.md b/docs/NFO_GUIDE.md
index 03f16e0..7da522e 100644
--- a/docs/NFO_GUIDE.md
+++ b/docs/NFO_GUIDE.md
@@ -246,6 +246,7 @@ NFO files are created in the anime directory:
Action
Sci-Fi & Fantasy
1429
+ 1429
https://image.tmdb.org/t/p/w500/...
https://image.tmdb.org/t/p/original/...
@@ -253,6 +254,13 @@ NFO files are created in the anime directory:
```
+**Manual TMDB ID Override**: To skip TMDB search and use a specific ID directly, include `YOUR_ID` in the NFO. This is useful when:
+- TMDB search fails for your series (e.g., new or obscure anime)
+- You already know the correct TMDB ID
+- You want to avoid rate limiting from repeated searches
+
+Aniworld reads `` element and `` first. If found, it uses the ID directly instead of searching.
+
### 4.3 Episode NFO Format
```xml
@@ -629,6 +637,36 @@ Every poster check action is logged:
4. Check network speed to TMDB servers
5. Verify disk I/O performance
+### 6.7 TMDB Lookup Fails for My Series
+
+**Problem**: TMDB search fails with "No results found" for a valid series.
+
+**Solutions**:
+
+1. **Check if series exists on TMDB**: Visit https://www.themoviedb.org and search for your series
+2. **Use manual ID override**: Add TMDB ID directly to `tvshow.nfo`:
+ ```xml
+
+
+ Your Series Name
+ 12345
+ 12345
+
+ ```
+ Aniworld will use this ID directly instead of searching.
+
+3. **Try alternative titles**: Some anime have different titles (Japanese, romaji, English). If you have access to the folder, rename it to match the TMDB title.
+
+4. **Add to existing NFO**: If `tvshow.nfo` exists but has no TMDB ID, edit it to add:
+ ```xml
+ YOUR_TMDB_ID
+ ```
+ Then use the Update endpoint to refresh metadata.
+
+5. **Check for rate limiting**: If many lookups fail at once, you may be hitting TMDB rate limits. Wait and retry later.
+
+6. **Verify API key**: Ensure your TMDB API key is valid and has not exceeded usage limits.
+
---
## 7. Best Practices
diff --git a/src/core/services/nfo_service.py b/src/core/services/nfo_service.py
index 1c84d6e..698d34c 100644
--- a/src/core/services/nfo_service.py
+++ b/src/core/services/nfo_service.py
@@ -163,58 +163,89 @@ class NFOService:
logger.info("Creating series folder: %s", folder_path)
folder_path.mkdir(parents=True, exist_ok=True)
+ # Check for existing NFO with TMDB ID to skip search
+ nfo_path = folder_path / "tvshow.nfo"
+ existing_ids = None
+ if nfo_path.exists():
+ try:
+ existing_ids = self.parse_nfo_ids(nfo_path)
+ if existing_ids.get("tmdb_id"):
+ logger.info(
+ "Found existing TMDB ID %s in NFO, using directly",
+ existing_ids["tmdb_id"]
+ )
+ except Exception as e:
+ logger.debug("Could not parse existing NFO IDs: %s", e)
+
try:
await self.tmdb_client._ensure_session()
- # Search for TV show - try multiple strategies
- tv_show, search_source = await self._search_with_fallback(
- search_name, year, alt_titles
- )
- tv_id = tv_show["id"]
+ # Use existing TMDB ID if found, otherwise search
+ if existing_ids and existing_ids.get("tmdb_id"):
+ tv_id = existing_ids["tmdb_id"]
+ logger.info("Fetching details directly for TMDB ID: %s", tv_id)
+ details = await self.tmdb_client.get_tv_show_details(
+ tv_id,
+ append_to_response="credits,external_ids,images"
+ )
+ content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tv_id)
+ tv_show = {"id": tv_id, "name": details.get("name", serie_name)}
+ search_source = "nfo_override"
+ else:
+ # Search for TV show - try multiple strategies
+ tv_show, search_source = await self._search_with_fallback(
+ search_name, year, alt_titles
+ )
+ tv_id = tv_show["id"]
logger.info("Found match: %s (ID: %s)", tv_show['name'], tv_id)
# Get detailed information with multi-language image support
- details = await self.tmdb_client.get_tv_show_details(
- tv_id,
- append_to_response="credits,external_ids,images"
- )
+ # Skip if we already fetched details via nfo_override
+ if search_source != "nfo_override":
+ details = await self.tmdb_client.get_tv_show_details(
+ tv_id,
+ append_to_response="credits,external_ids,images"
+ )
- # Get content ratings for FSK
- content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tv_id)
+ # Get content ratings for FSK
+ content_ratings = await self.tmdb_client.get_tv_show_content_ratings(tv_id)
- # Enrich with fallback languages for empty overview/tagline
- # Pass search result overview as last resort fallback
- search_overview = tv_show.get("overview") or None
- if not search_overview:
- try:
- logger.debug(
- "No overview in German search result, trying en-US search fallback for: %s",
- search_name,
- )
- en_search_results = await self.tmdb_client.search_tv_show(
- search_name,
- language="en-US",
- )
- if en_search_results.get("results"):
- en_match = self._find_best_match(
- en_search_results["results"], search_name, year
+ # Enrich with fallback languages for empty overview/tagline
+ # Pass search result overview as last resort fallback
+ search_overview = tv_show.get("overview") or None
+ if not search_overview:
+ try:
+ logger.debug(
+ "No overview in German search result, trying en-US search fallback for: %s",
+ search_name,
)
- search_overview = en_match.get("overview") or None
- if search_overview:
- logger.info(
- "Using en-US search overview fallback for %s",
- search_name,
+ en_search_results = await self.tmdb_client.search_tv_show(
+ search_name,
+ language="en-US",
+ )
+ if en_search_results.get("results"):
+ en_match = self._find_best_match(
+ en_search_results["results"], search_name, year
)
- except (TMDBAPIError, Exception) as exc:
- logger.warning(
- "Failed en-US search fallback for overview: %s",
- exc,
- )
+ search_overview = en_match.get("overview") or None
+ if search_overview:
+ logger.info(
+ "Using en-US search overview fallback for %s",
+ search_name,
+ )
+ except (TMDBAPIError, Exception) as exc:
+ logger.warning(
+ "Failed en-US search fallback for overview: %s",
+ exc,
+ )
- details = await self._enrich_details_with_fallback(
- details, search_overview=search_overview
- )
+ details = await self._enrich_details_with_fallback(
+ details, search_overview=search_overview
+ )
+ else:
+ # When using nfo_override, content_ratings already fetched
+ pass
# Convert TMDB data to TVShowNFO model
nfo_model = tmdb_to_nfo_model(
@@ -646,21 +677,45 @@ class NFOService:
{"query": normalized, "year": year, "lang": "de-DE", "desc": f"normalized:{normalized}"}
)
+ # Strategy 6: Try search/multi for series indexed as movies
+ search_strategies.append(
+ {"query": primary_query, "year": year, "lang": "en-US", "desc": "multi_search", "use_multi": True}
+ )
+
last_error = None
for strategy in search_strategies:
query = strategy["query"]
lang = strategy["lang"]
desc = strategy["desc"]
+ use_multi = strategy.get("use_multi", False)
try:
logger.debug(
"TMDB search attempt: query='%s', lang=%s, year=%s, strategy=%s",
query, lang, strategy["year"], desc
)
- search_results = await self.tmdb_client.search_tv_show(
- query,
- language=lang
- )
+
+ # Use search/multi for multi_search strategy
+ if use_multi:
+ search_results = await self.tmdb_client.search_multi(
+ query,
+ language=lang
+ )
+ # Filter for TV shows only
+ if search_results.get("results"):
+ tv_results = [
+ r for r in search_results["results"]
+ if r.get("media_type") == "tv"
+ ]
+ if tv_results:
+ search_results["results"] = tv_results
+ else:
+ search_results["results"] = []
+ else:
+ search_results = await self.tmdb_client.search_tv_show(
+ query,
+ language=lang
+ )
if search_results.get("results"):
# Apply year filter if we have one
diff --git a/src/core/services/series_manager_service.py b/src/core/services/series_manager_service.py
index fa35746..ee31aa6 100644
--- a/src/core/services/series_manager_service.py
+++ b/src/core/services/series_manager_service.py
@@ -129,6 +129,9 @@ class SeriesManagerService:
if not self.nfo_service:
return
+ nfo_exists = False
+ ids = {}
+
try:
folder_path = Path(self.anime_directory) / serie_folder
nfo_path = folder_path / "tvshow.nfo"
@@ -195,22 +198,49 @@ class SeriesManagerService:
logger.info(
f"Creating NFO for '{serie_name}' ({serie_folder})"
)
- await self.nfo_service.create_tvshow_nfo(
- serie_name=serie_name,
- serie_folder=serie_folder,
- year=year,
- download_poster=self.download_poster,
- download_logo=self.download_logo,
- download_fanart=self.download_fanart
- )
- logger.info("Successfully created NFO for '%s'", serie_name)
+ try:
+ await self.nfo_service.create_tvshow_nfo(
+ serie_name=serie_name,
+ serie_folder=serie_folder,
+ year=year,
+ download_poster=self.download_poster,
+ download_logo=self.download_logo,
+ download_fanart=self.download_fanart
+ )
+ logger.info("Successfully created NFO for '%s'", serie_name)
+ except TMDBAPIError as create_error:
+ # TMDB lookup failed, create minimal NFO to track the series
+ logger.warning(
+ "TMDB lookup failed for '%s', creating minimal NFO: %s",
+ serie_name, create_error
+ )
+ try:
+ await self.nfo_service.create_minimal_nfo(
+ serie_name=serie_name,
+ serie_folder=serie_folder,
+ year=year
+ )
+ logger.info("Created minimal NFO for '%s'", serie_name)
+ except Exception as minimal_error:
+ logger.error(
+ "Failed to create minimal NFO for '%s': %s",
+ serie_name, minimal_error
+ )
elif nfo_exists:
logger.debug(
f"NFO exists for '{serie_name}', skipping download"
)
except TMDBAPIError as e:
- logger.error("TMDB API error processing '%s': %s", serie_name, e)
+ # Only log at ERROR if no NFO exists and we have no IDs
+ # If NFO exists with IDs, this is just a lookup failure, log at DEBUG
+ if nfo_exists and (ids.get("tmdb_id") or ids.get("tvdb_id")):
+ logger.debug(
+ "TMDB API lookup failed for '%s' (has NFO with IDs): %s",
+ serie_name, e
+ )
+ else:
+ logger.error("TMDB API error processing '%s': %s", serie_name, e)
except Exception as e:
logger.error(
f"Unexpected error processing NFO for '{serie_name}': {e}",
diff --git a/tests/unit/test_nfo_service.py b/tests/unit/test_nfo_service.py
index 1c80c8d..d4aa763 100644
--- a/tests/unit/test_nfo_service.py
+++ b/tests/unit/test_nfo_service.py
@@ -1809,3 +1809,107 @@ class TestNegativeCache:
assert "expired_key" not in tmdb_client._negative_cache
assert "valid_key" in tmdb_client._negative_cache
+
+class TestNFOIDOverride:
+ """Tests for manual TMDB ID override via NFO."""
+
+ @pytest.mark.asyncio
+ async def test_create_tvshow_nfo_uses_existing_tmdb_id(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de):
+ """Test that existing TMDB ID in NFO skips search."""
+ # Create series folder with existing NFO containing TMDB ID
+ series_folder = tmp_path / "Attack on Titan"
+ series_folder.mkdir()
+ nfo_path = series_folder / "tvshow.nfo"
+ nfo_path.write_text("""
+
+ Attack on Titan
+ 1429
+
+""", 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), \
+ patch.object(nfo_service.tmdb_client, '_ensure_session', new_callable=AsyncMock):
+
+ mock_details.return_value = mock_tmdb_data
+ mock_ratings.return_value = mock_content_ratings_de
+
+ nfo_path_result = await nfo_service.create_tvshow_nfo(
+ "Attack on Titan",
+ "Attack on Titan",
+ download_poster=False, download_logo=False, download_fanart=False
+ )
+
+ # Verify NFO was created
+ assert nfo_path_result.exists()
+
+ # Verify get_tv_show_details was called directly with the ID (no search)
+ mock_details.assert_called_once_with(1429, append_to_response="credits,external_ids,images")
+
+ # Verify search was NOT called
+ # (we can check by verifying no search_tv_show mock was set up)
+
+ @pytest.mark.asyncio
+ async def test_create_tvshow_nfo_searches_when_no_tmdb_id(self, nfo_service, tmp_path, mock_tmdb_data, mock_content_ratings_de):
+ """Test that search is used when NFO has no TMDB ID."""
+ # Create series folder without existing NFO
+ series_folder = tmp_path / "Test Anime"
+ series_folder.mkdir()
+
+ 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), \
+ patch.object(nfo_service.tmdb_client, '_ensure_session', new_callable=AsyncMock):
+
+ mock_search.return_value = {
+ "results": [{
+ "id": 1429,
+ "name": "Test Anime",
+ "first_air_date": "2024-01-01",
+ "overview": "Test overview"
+ }]
+ }
+ mock_details.return_value = mock_tmdb_data
+ mock_ratings.return_value = mock_content_ratings_de
+
+ nfo_path = await nfo_service.create_tvshow_nfo(
+ "Test Anime",
+ "Test Anime",
+ download_poster=False, download_logo=False, download_fanart=False
+ )
+
+ # Verify search was called
+ mock_search.assert_called()
+
+
+class TestSearchMultiStrategy:
+ """Tests for search/multi fallback strategy."""
+
+ @pytest.mark.asyncio
+ async def test_search_multi_strategy_used_as_fallback(self, nfo_service, mock_tmdb_data):
+ """Test that search/multi is tried after regular search fails."""
+ mock_search = AsyncMock()
+ mock_multi = AsyncMock()
+
+ # First: regular search fails
+ # Second: multi search returns TV result
+ mock_search.return_value = {"results": []}
+ mock_multi.return_value = {
+ "results": [
+ {"media_type": "movie", "id": 123},
+ {"media_type": "tv", "id": 456, "name": "Found Show", "first_air_date": "2024-01-01"}
+ ]
+ }
+
+ with patch.object(nfo_service.tmdb_client, 'search_tv_show', mock_search), \
+ patch.object(nfo_service.tmdb_client, 'search_multi', mock_multi):
+
+ result, source = await nfo_service._search_with_fallback(
+ "Unknown Show", 2024, None
+ )
+
+ assert result["id"] == 456
+ assert source == "multi_search"
+