feat: add ImageLoadingService for downloading series artwork

- Create ImageLoadingService that downloads poster.jpg, fanart.jpg, and
  logo.png from TMDB when anime is added or during scheduler rescan
- Integrate into BackgroundLoaderService._load_nfo_and_images() to trigger
  image downloads when new anime is added
- Add image_scan_after_rescan config option to scheduler (default: true)
- Add _run_image_scan() to scheduler rescan flow, processing series in
  batches of 10 to respect TMDB rate limits
- Fix SearchResult model missing folder, snippet, and score fields
- Update background_loader tests to match new image loading behavior
This commit is contained in:
2026-06-14 20:51:57 +02:00
parent 6dc3cda810
commit 7a1b2e565e
6 changed files with 617 additions and 70 deletions

View File

@@ -512,14 +512,16 @@ class TestScanSeriesEpisodes:
class TestLoadNfoAndImages:
"""Tests for loading NFO files and images."""
"""Tests for loading NFO files and images via ImageLoadingService.
These tests verify the behavior of _load_nfo_and_images by mocking
the external dependencies at the correct import locations.
"""
@pytest.mark.asyncio
async def test_load_nfo_creates_new_nfo(self, background_loader_service, mock_websocket_service):
"""Test creating new NFO file - NFO service removed, stub returns False."""
async def test_load_nfo_and_images_success(self, background_loader_service):
"""Test successful image loading via ImageLoadingService."""
mock_db = AsyncMock()
mock_series = MagicMock()
mock_series.has_nfo = False
task = SeriesLoadingTask(
key="test",
@@ -528,51 +530,78 @@ class TestLoadNfoAndImages:
year=2020
)
# NFO service removed, _load_nfo_and_images is now a stub that returns False
result = await background_loader_service._load_nfo_and_images(task, mock_db)
# Mock get_tmdb_client and init_image_loading_service at source
with patch("src.server.nfo.tmdb_client.get_tmdb_client") as mock_get_tmdb:
mock_tmdb = MagicMock()
mock_get_tmdb.return_value = mock_tmdb
with patch("src.server.services.image_loading_service.ImageLoadingService") as mock_service_class:
mock_service = MagicMock()
mock_service.load_series_images = AsyncMock(return_value={
"poster": True,
"fanart": True,
"logo": True,
})
mock_service_class.return_value = mock_service
result = await background_loader_service._load_nfo_and_images(task, mock_db)
assert result is True
assert task.progress["nfo"] is True
assert task.progress["logo"] is True
assert task.progress["images"] is True
@pytest.mark.asyncio
async def test_load_nfo_and_images_partial(self, background_loader_service):
"""Test partial image loading (some images fail)."""
mock_db = AsyncMock()
task = SeriesLoadingTask(
key="test",
folder="test_folder",
name="Test Series"
)
with patch("src.server.nfo.tmdb_client.get_tmdb_client") as mock_get_tmdb:
mock_tmdb = MagicMock()
mock_get_tmdb.return_value = mock_tmdb
with patch("src.server.services.image_loading_service.ImageLoadingService") as mock_service_class:
mock_service = MagicMock()
mock_service.load_series_images = AsyncMock(return_value={
"poster": True,
"fanart": False,
"logo": True,
})
mock_service_class.return_value = mock_service
result = await background_loader_service._load_nfo_and_images(task, mock_db)
assert result is True
assert task.progress["nfo"] is True
assert task.progress["logo"] is True
assert task.progress["images"] is False
@pytest.mark.asyncio
async def test_load_nfo_and_images_service_error(self, background_loader_service):
"""Test graceful handling when ImageLoadingService throws exception."""
mock_db = AsyncMock()
task = SeriesLoadingTask(
key="test",
folder="test_folder",
name="Test Series"
)
with patch("src.server.nfo.tmdb_client.get_tmdb_client") as mock_get_tmdb:
mock_get_tmdb.side_effect = Exception("TMDB API error")
result = await background_loader_service._load_nfo_and_images(task, mock_db)
# Stub returns False since NFO service was removed
assert result is False
assert task.progress["nfo"] is False
assert task.progress["nfo"] is True
assert task.progress["logo"] is False
assert task.progress["images"] is False
@pytest.mark.asyncio
async def test_load_nfo_uses_existing(self, background_loader_service):
"""Test using existing NFO file - NFO service removed, stub returns False."""
mock_db = AsyncMock()
mock_series = MagicMock()
mock_series.has_nfo = True
task = SeriesLoadingTask(
key="test",
folder="test_folder",
name="Test Series"
)
# NFO service removed, _load_nfo_and_images is now a stub that returns False
result = await background_loader_service._load_nfo_and_images(task, mock_db)
# Stub returns False since NFO service was removed
assert result is False
@pytest.mark.asyncio
async def test_load_nfo_without_nfo_service(self, background_loader_service):
"""Test graceful handling when NFO service not available."""
background_loader_service.series_app.nfo_service = None
mock_db = AsyncMock()
task = SeriesLoadingTask(
key="test",
folder="test_folder",
name="Test Series"
)
result = await background_loader_service._load_nfo_and_images(task, mock_db)
assert result is False
assert task.progress["nfo"] is False
assert task.progress["logo"] is False
class TestScanMissingEpisodes:
@@ -813,23 +842,21 @@ class TestErrorHandling:
@pytest.mark.asyncio
async def test_load_nfo_error_handling(self, background_loader_service):
"""Test error handling during NFO creation."""
background_loader_service.series_app.nfo_service.has_nfo = MagicMock(return_value=False)
background_loader_service.series_app.nfo_service.create_tvshow_nfo = AsyncMock(
side_effect=Exception("API error")
)
"""Test error handling when ImageLoadingService fails."""
mock_db = AsyncMock()
task = SeriesLoadingTask(
key="test",
folder="test_folder",
name="Test Series"
)
result = await background_loader_service._load_nfo_and_images(task, mock_db)
with patch("src.server.nfo.tmdb_client.get_tmdb_client") as mock_get_tmdb:
mock_get_tmdb.side_effect = Exception("TMDB API error")
result = await background_loader_service._load_nfo_and_images(task, mock_db)
assert result is False
assert task.progress["nfo"] is False
assert task.progress["nfo"] is True # Optimistic - NFO created elsewhere
assert task.progress["logo"] is False
assert task.progress["images"] is False