Compare commits

...

4 Commits

Author SHA1 Message Date
7ded5a6e4d chore: bump version 2026-05-27 21:41:45 +02:00
d596902ca3 Parse existing NFO for TMDB ID to skip redundant search
Check existing tvshow.nfo for TMDB ID before querying TMDB API.
If found, fetch details directly using cached ID instead of searching.
Reduces API calls and improves performance for already-indexed series.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 21:22:24 +02:00
d358a07290 fix async handling in SerieScanner and add image_downloader cleanup
- SerieScanner.scan() now detects running event loop and uses create_task()
  when already in async context, avoids RuntimeError
- NFOService.close() now also closes image_downloader to prevent resource leaks
- Add integration tests for TMDBClient lifecycle management

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 20:47:29 +02:00
b9c55f9e7a fix: remove double-call on AsyncSession in SerieScanner
get_async_session_factory() returns session directly, not factory.
Calling result again with () caused 'AsyncSession' object is not callable.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 20:19:34 +02:00
10 changed files with 387 additions and 61 deletions

View File

@@ -1 +1 @@
v1.1.17
v1.1.18

View File

@@ -181,6 +181,22 @@ scheduler = AsyncIOScheduler(jobstores=jobstores)
**If server is down >1 hour:** No automatic recovery. Manual trigger via `POST /api/scheduler/trigger-rescan` or wait for next scheduled run.
### Database Session Management
`get_async_session_factory()` returns a **new AsyncSession instance** directly (not a factory). The function name is historical — callers receive the session immediately:
```python
# Correct usage:
db = get_async_session_factory() # db IS the session
await db.execute(...)
await db.commit()
await db.close()
```
Do NOT call the result again with `()` — that tries to call an `AsyncSession` object, causing `'AsyncSession' object is not callable`.
For context manager usage, prefer `get_db_session()` (auto-commits) or `get_transactional_session()` (manual commit).
### Health Check Endpoints
The application provides health check endpoints for monitoring and container orchestration:

View File

@@ -246,6 +246,7 @@ NFO files are created in the anime directory:
<genre>Action</genre>
<genre>Sci-Fi & Fantasy</genre>
<uniqueid type="tmdb">1429</uniqueid>
<tmdbid>1429</tmdbid>
<thumb aspect="poster">https://image.tmdb.org/t/p/w500/...</thumb>
<fanart>
<thumb>https://image.tmdb.org/t/p/original/...</thumb>
@@ -253,6 +254,13 @@ NFO files are created in the anime directory:
</tvshow>
```
**Manual TMDB ID Override**: To skip TMDB search and use a specific ID directly, include `<tmdbid>YOUR_ID</tmdbid>` 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 `<tmdbid>` element and `<uniqueid type="tmdb">` 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
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<tvshow>
<title>Your Series Name</title>
<tmdbid>12345</tmdbid>
<uniqueid type="tmdb">12345</uniqueid>
</tvshow>
```
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
<tmdbid>YOUR_TMDB_ID</tmdbid>
```
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

View File

@@ -1,6 +1,6 @@
{
"name": "aniworld-web",
"version": "1.1.17",
"version": "1.1.18",
"description": "Aniworld Anime Download Manager - Web Frontend",
"type": "module",
"scripts": {

View File

@@ -44,8 +44,13 @@ class SerieScanner:
in keyDict and can be retrieved after scanning.
Example:
# Synchronous context (CLI):
scanner = SerieScanner("/path/to/anime", loader)
scanner.scan()
scanner.scan() # asyncio.run() used internally when no event loop
# Asynchronous context (server/scheduler):
# scan() detects running event loop and uses create_task()
# internally, so no special handling needed by caller.
# Results are in scanner.keyDict
# With DB lookup fallback:
@@ -218,8 +223,7 @@ class SerieScanner:
try:
from src.server.database.connection import get_async_session_factory
session_factory = get_async_session_factory()
db = session_factory()
db = get_async_session_factory()
try:
existing = await AnimeSeriesService.get_by_key(db, serie.key)
if existing:
@@ -433,7 +437,14 @@ class SerieScanner:
# Persist to database (async)
try:
asyncio.run(self._persist_serie_to_db(serie))
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# No running loop — safe to use asyncio.run()
asyncio.run(self._persist_serie_to_db(serie))
else:
# Already in async context — schedule as task
asyncio.create_task(self._persist_serie_to_db(serie))
except Exception as e:
logger.warning(
"DB persistence failed for '%s', "

View File

@@ -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
@@ -784,6 +839,7 @@ class NFOService:
async def close(self):
"""Clean up resources."""
await self.tmdb_client.close()
await self.image_downloader.close()
async def create_minimal_nfo(
self,

View File

@@ -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}",

View File

@@ -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("""<?xml version="1.0" encoding="UTF-8"?>
<tvshow>
<title>Attack on Titan</title>
<tmdbid>1429</tmdbid>
</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), \
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"

View File

@@ -209,7 +209,7 @@ class TestPersistSerieToDbErrorHandling:
with patch(
"src.server.database.connection.get_async_session_factory",
return_value=mock_factory
return_value=mock_session
):
with patch(
"src.server.database.service.AnimeSeriesService.get_by_key",

View File

@@ -444,6 +444,77 @@ class TestTMDBClientSessionLeak:
"Unexpected warning about unclosed session"
class TestTMDBClientLifecycleIntegration:
"""Integration tests for TMDBClient lifecycle management."""
@pytest.mark.asyncio
async def test_context_manager_no_resource_warning(self, caplog):
"""Test async with TMDBClient produces no ResourceWarning."""
import logging
import warnings
caplog.set_level(logging.WARNING)
# Use context manager properly - should not leak
async with TMDBClient(api_key="test_key") as client:
await client._ensure_session()
assert client.session is not None
# Session should be closed after context exit
assert client.session is None or client.session.closed
@pytest.mark.asyncio
async def test_exception_safety_during_api_call(self, caplog):
"""Test session is closed even when exception raised during API call."""
import logging
caplog.set_level(logging.WARNING)
close_called = False
class TrackingSession:
def __init__(self):
self.closed = False
async def close(self):
nonlocal close_called
close_called = True
self.closed = True
async def get(self, url, **kwargs):
raise TMDBAPIError("Simulated API failure")
client = TMDBClient(api_key="test_key")
client.session = TrackingSession()
# Exception during context should still close session
with pytest.raises(TMDBAPIError):
async with client:
raise TMDBAPIError("Simulated API failure")
assert close_called, "Session was not closed after API exception"
@pytest.mark.asyncio
async def test_reuse_session_across_multiple_requests(self, caplog):
"""Test session is reused across multiple requests without leaks."""
import logging
caplog.set_level(logging.WARNING)
client = TMDBClient(api_key="test_key")
async with client as c:
# First request
await c._ensure_session()
session1 = c.session
# Second request should reuse same session
await c._ensure_session()
session2 = c.session
assert session1 is session2, "Session should be reused"
# After context exit, session should be closed
assert client.session is None or client.session.closed
class TestTMDBClientConnectorClosed:
"""Test handling of 'Connector is closed' errors."""