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" +