From d358a07290658a4a73cc3f25536b4eab44973fba Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 27 May 2026 20:47:29 +0200 Subject: [PATCH] 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> --- src/core/SerieScanner.py | 16 ++++++- src/core/services/nfo_service.py | 1 + tests/unit/test_tmdb_client.py | 71 ++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/core/SerieScanner.py b/src/core/SerieScanner.py index 210c97a..b6d9cb7 100644 --- a/src/core/SerieScanner.py +++ b/src/core/SerieScanner.py @@ -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: @@ -432,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', " diff --git a/src/core/services/nfo_service.py b/src/core/services/nfo_service.py index d1a6774..1c84d6e 100644 --- a/src/core/services/nfo_service.py +++ b/src/core/services/nfo_service.py @@ -784,6 +784,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, diff --git a/tests/unit/test_tmdb_client.py b/tests/unit/test_tmdb_client.py index c6a6406..a899ba7 100644 --- a/tests/unit/test_tmdb_client.py +++ b/tests/unit/test_tmdb_client.py @@ -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."""