refactored callback
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user