refactored callback

This commit is contained in:
2025-11-02 10:34:49 +01:00
parent 8a49db2a10
commit e414a1a358
5 changed files with 241 additions and 254 deletions

View File

@@ -1,9 +1,8 @@
from __future__ import annotations
import asyncio
from concurrent.futures import ThreadPoolExecutor
from functools import lru_cache
from typing import Callable, List, Optional
from typing import List, Optional
import structlog
@@ -22,9 +21,10 @@ class AnimeServiceError(Exception):
class AnimeService:
"""Wraps the blocking SeriesApp for use in the FastAPI web layer.
"""Wraps SeriesApp for use in the FastAPI web layer.
- Runs blocking operations in a threadpool
- SeriesApp methods are now async, no need for threadpool
- Subscribes to SeriesApp events for progress tracking
- Exposes async methods
- Adds simple in-memory caching for read operations
"""
@@ -32,152 +32,208 @@ class AnimeService:
def __init__(
self,
directory: str,
max_workers: int = 4,
progress_service: Optional[ProgressService] = None,
):
self._directory = directory
self._executor = ThreadPoolExecutor(max_workers=max_workers)
self._progress_service = progress_service or get_progress_service()
# SeriesApp is blocking; instantiate per-service
# Initialize SeriesApp with async methods
try:
self._app = SeriesApp(directory)
# Subscribe to SeriesApp events
self._app.download_status += self._on_download_status
self._app.scan_status += self._on_scan_status
except Exception as e:
logger.exception("Failed to initialize SeriesApp")
raise AnimeServiceError("Initialization failed") from e
async def _run_in_executor(self, func, *args, **kwargs):
loop = asyncio.get_event_loop()
def _on_download_status(self, args) -> None:
"""Handle download status events from SeriesApp.
Args:
args: DownloadStatusEventArgs from SeriesApp
"""
try:
return await loop.run_in_executor(self._executor, lambda: func(*args, **kwargs))
except Exception as e:
logger.exception("Executor task failed")
raise AnimeServiceError(str(e)) from e
# Map SeriesApp download events to progress service
if args.status == "started":
asyncio.create_task(
self._progress_service.start_progress(
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
progress_type=ProgressType.DOWNLOAD,
title=f"Downloading {args.serie_folder}",
message=f"S{args.season:02d}E{args.episode:02d}",
)
)
elif args.status == "progress":
asyncio.create_task(
self._progress_service.update_progress(
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
current=int(args.progress),
total=100,
message=args.message or "Downloading...",
)
)
elif args.status == "completed":
asyncio.create_task(
self._progress_service.complete_progress(
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
message="Download completed",
)
)
elif args.status == "failed":
asyncio.create_task(
self._progress_service.fail_progress(
progress_id=f"download_{args.serie_folder}_{args.season}_{args.episode}", # noqa: E501
error_message=args.message or str(args.error),
)
)
except Exception as exc:
logger.error(
"Error handling download status event",
error=str(exc)
)
def _on_scan_status(self, args) -> None:
"""Handle scan status events from SeriesApp.
Args:
args: ScanStatusEventArgs from SeriesApp
"""
try:
scan_id = "library_scan"
# Map SeriesApp scan events to progress service
if args.status == "started":
asyncio.create_task(
self._progress_service.start_progress(
progress_id=scan_id,
progress_type=ProgressType.SCAN,
title="Scanning anime library",
message=args.message or "Initializing scan...",
)
)
elif args.status == "progress":
asyncio.create_task(
self._progress_service.update_progress(
progress_id=scan_id,
current=args.current,
total=args.total,
message=args.message or f"Scanning: {args.folder}",
)
)
elif args.status == "completed":
asyncio.create_task(
self._progress_service.complete_progress(
progress_id=scan_id,
message=args.message or "Scan completed",
)
)
elif args.status == "failed":
asyncio.create_task(
self._progress_service.fail_progress(
progress_id=scan_id,
error_message=args.message or str(args.error),
)
)
elif args.status == "cancelled":
asyncio.create_task(
self._progress_service.fail_progress(
progress_id=scan_id,
error_message=args.message or "Scan cancelled",
)
)
except Exception as exc:
logger.error("Error handling scan status event", error=str(exc))
@lru_cache(maxsize=128)
def _cached_list_missing(self) -> List[dict]:
# Synchronous cached call used by async wrapper
# Synchronous cached call - SeriesApp.series_list is populated
# during initialization
try:
series = self._app.series_list
# normalize to simple dicts
return [s.to_dict() if hasattr(s, "to_dict") else s for s in series]
except Exception as e:
return [
s.to_dict() if hasattr(s, "to_dict") else s
for s in series
]
except Exception:
logger.exception("Failed to get missing episodes list")
raise
async def list_missing(self) -> List[dict]:
"""Return list of series with missing episodes."""
try:
return await self._run_in_executor(self._cached_list_missing)
# series_list is already populated, just access it
return self._cached_list_missing()
except AnimeServiceError:
raise
except Exception as e:
except Exception as exc:
logger.exception("list_missing failed")
raise AnimeServiceError("Failed to list missing series") from e
raise AnimeServiceError("Failed to list missing series") from exc
async def search(self, query: str) -> List[dict]:
"""Search for series using underlying loader.Search."""
"""Search for series using underlying loader.
Args:
query: Search query string
Returns:
List of search results as dictionaries
"""
if not query:
return []
try:
result = await self._run_in_executor(self._app.search, query)
# result may already be list of dicts or objects
# SeriesApp.search is now async
result = await self._app.search(query)
return result
except Exception as e:
except Exception as exc:
logger.exception("search failed")
raise AnimeServiceError("Search failed") from e
raise AnimeServiceError("Search failed") from exc
async def rescan(self, callback: Optional[Callable] = None) -> None:
"""Trigger a re-scan. Accepts an optional callback function.
async def rescan(self) -> None:
"""Trigger a re-scan.
The callback is executed in the threadpool by SeriesApp.
Progress updates are tracked and broadcasted via ProgressService.
The SeriesApp now handles progress tracking via events which are
forwarded to the ProgressService through event handlers.
"""
scan_id = "library_scan"
try:
# Start progress tracking
await self._progress_service.start_progress(
progress_id=scan_id,
progress_type=ProgressType.SCAN,
title="Scanning anime library",
message="Initializing scan...",
)
# Create wrapped callback for progress updates
def progress_callback(progress_data: dict) -> None:
"""Update progress during scan."""
try:
if callback:
callback(progress_data)
# Update progress service
current = progress_data.get("current", 0)
total = progress_data.get("total", 0)
message = progress_data.get("message", "Scanning...")
# Schedule the coroutine without waiting for it
# This is safe because we don't need the result
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.ensure_future(
self._progress_service.update_progress(
progress_id=scan_id,
current=current,
total=total,
message=message,
)
)
except Exception as e:
logger.error("Scan progress callback error", error=str(e))
# Run scan
await self._run_in_executor(self._app.ReScan, progress_callback)
# SeriesApp.re_scan is now async and handles events internally
await self._app.re_scan()
# invalidate cache
try:
self._cached_list_missing.cache_clear()
except Exception:
pass
# Complete progress tracking
await self._progress_service.complete_progress(
progress_id=scan_id,
message="Scan completed successfully",
)
except Exception as e:
logger.exception("rescan failed")
# Fail progress tracking
await self._progress_service.fail_progress(
progress_id=scan_id,
error_message=str(e),
)
raise AnimeServiceError("Rescan failed") from e
async def download(self, serie_folder: str, season: int, episode: int, key: str, callback=None) -> bool:
"""Start a download via the underlying loader.
except Exception as exc:
logger.exception("rescan failed")
raise AnimeServiceError("Rescan failed") from exc
async def download(
self,
serie_folder: str,
season: int,
episode: int,
key: str,
) -> bool:
"""Start a download.
The SeriesApp now handles progress tracking via events which are
forwarded to the ProgressService through event handlers.
Returns True on success or raises AnimeServiceError on failure.
"""
try:
result = await self._run_in_executor(
self._app.download, serie_folder, season, episode,
key, callback
# SeriesApp.download is now async and handles events internally
return await self._app.download(
serie_folder=serie_folder,
season=season,
episode=episode,
key=key,
)
# OperationResult has a success attribute
if hasattr(result, 'success'):
logger.debug(
"Download result",
success=result.success,
message=result.message
)
return result.success
return bool(result)
except Exception as e:
except Exception as exc:
logger.exception("download failed")
raise AnimeServiceError("Download failed") from e
raise AnimeServiceError("Download failed") from exc
def get_anime_service(directory: str = "./") -> AnimeService: