refactored callback

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

View File

@ -17,7 +17,7 @@
"keep_days": 30 "keep_days": 30
}, },
"other": { "other": {
"master_password_hash": "$pbkdf2-sha256$29000$MkbonbMWolTKOUfIOcc4Jw$8Aza9RknTXDSwQ1/mc.EwerqRrZ4Yo6tQlust.Nm/kQ", "master_password_hash": "$pbkdf2-sha256$29000$GiPkvJeS8j4HwBjDmNOaMw$8k4ShYlk51ZsxoiQBZGjXCsvl0xbbiXIFYI/EWlqVrI",
"anime_directory": "/home/lukas/Volume/serien/" "anime_directory": "/home/lukas/Volume/serien/"
}, },
"version": "1.0.0" "version": "1.0.0"

View File

@ -129,3 +129,6 @@ For each task completed:
- WebSocket infrastructure remains unchanged - WebSocket infrastructure remains unchanged
# Tasks # Tasks
[] check method from SeriesApp are used in a correct way. SeriesApp method changed. make sure that classes that use SeriesApp take the latest interface.
[] SeriesApp no have events make sure services and api use them

View File

@ -197,7 +197,7 @@ class SeriesApp:
results = await asyncio.to_thread(self.loader.search, words) results = await asyncio.to_thread(self.loader.search, words)
logger.info("Found %d results", len(results)) logger.info("Found %d results", len(results))
return results return results
async def download( async def download(
self, self,
serie_folder: str, serie_folder: str,

View File

@ -1,9 +1,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from concurrent.futures import ThreadPoolExecutor
from functools import lru_cache from functools import lru_cache
from typing import Callable, List, Optional from typing import List, Optional
import structlog import structlog
@ -22,9 +21,10 @@ class AnimeServiceError(Exception):
class AnimeService: 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 - Exposes async methods
- Adds simple in-memory caching for read operations - Adds simple in-memory caching for read operations
""" """
@ -32,152 +32,208 @@ class AnimeService:
def __init__( def __init__(
self, self,
directory: str, directory: str,
max_workers: int = 4,
progress_service: Optional[ProgressService] = None, progress_service: Optional[ProgressService] = None,
): ):
self._directory = directory self._directory = directory
self._executor = ThreadPoolExecutor(max_workers=max_workers)
self._progress_service = progress_service or get_progress_service() self._progress_service = progress_service or get_progress_service()
# SeriesApp is blocking; instantiate per-service # Initialize SeriesApp with async methods
try: try:
self._app = SeriesApp(directory) 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: except Exception as e:
logger.exception("Failed to initialize SeriesApp") logger.exception("Failed to initialize SeriesApp")
raise AnimeServiceError("Initialization failed") from e raise AnimeServiceError("Initialization failed") from e
async def _run_in_executor(self, func, *args, **kwargs): def _on_download_status(self, args) -> None:
loop = asyncio.get_event_loop() """Handle download status events from SeriesApp.
Args:
args: DownloadStatusEventArgs from SeriesApp
"""
try: try:
return await loop.run_in_executor(self._executor, lambda: func(*args, **kwargs)) # Map SeriesApp download events to progress service
except Exception as e: if args.status == "started":
logger.exception("Executor task failed") asyncio.create_task(
raise AnimeServiceError(str(e)) from e 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) @lru_cache(maxsize=128)
def _cached_list_missing(self) -> List[dict]: 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: try:
series = self._app.series_list series = self._app.series_list
# normalize to simple dicts # normalize to simple dicts
return [s.to_dict() if hasattr(s, "to_dict") else s for s in series] return [
except Exception as e: s.to_dict() if hasattr(s, "to_dict") else s
for s in series
]
except Exception:
logger.exception("Failed to get missing episodes list") logger.exception("Failed to get missing episodes list")
raise raise
async def list_missing(self) -> List[dict]: async def list_missing(self) -> List[dict]:
"""Return list of series with missing episodes.""" """Return list of series with missing episodes."""
try: 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: except AnimeServiceError:
raise raise
except Exception as e: except Exception as exc:
logger.exception("list_missing failed") 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]: 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: if not query:
return [] return []
try: try:
result = await self._run_in_executor(self._app.search, query) # SeriesApp.search is now async
# result may already be list of dicts or objects result = await self._app.search(query)
return result return result
except Exception as e: except Exception as exc:
logger.exception("search failed") 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: async def rescan(self) -> None:
"""Trigger a re-scan. Accepts an optional callback function. """Trigger a re-scan.
The callback is executed in the threadpool by SeriesApp. The SeriesApp now handles progress tracking via events which are
Progress updates are tracked and broadcasted via ProgressService. forwarded to the ProgressService through event handlers.
""" """
scan_id = "library_scan"
try: try:
# Start progress tracking # SeriesApp.re_scan is now async and handles events internally
await self._progress_service.start_progress( await self._app.re_scan()
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)
# invalidate cache # invalidate cache
try: try:
self._cached_list_missing.cache_clear() self._cached_list_missing.cache_clear()
except Exception: except Exception:
pass 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: except Exception as exc:
"""Start a download via the underlying loader. 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. Returns True on success or raises AnimeServiceError on failure.
""" """
try: try:
result = await self._run_in_executor( # SeriesApp.download is now async and handles events internally
self._app.download, serie_folder, season, episode, return await self._app.download(
key, callback serie_folder=serie_folder,
season=season,
episode=episode,
key=key,
) )
# OperationResult has a success attribute except Exception as exc:
if hasattr(result, 'success'):
logger.debug(
"Download result",
success=result.success,
message=result.message
)
return result.success
return bool(result)
except Exception as e:
logger.exception("download failed") logger.exception("download failed")
raise AnimeServiceError("Download failed") from e raise AnimeServiceError("Download failed") from exc
def get_anime_service(directory: str = "./") -> AnimeService: def get_anime_service(directory: str = "./") -> AnimeService:

View File

@ -27,11 +27,7 @@ from src.server.models.download import (
QueueStatus, QueueStatus,
) )
from src.server.services.anime_service import AnimeService, AnimeServiceError from src.server.services.anime_service import AnimeService, AnimeServiceError
from src.server.services.progress_service import ( from src.server.services.progress_service import ProgressService, get_progress_service
ProgressService,
ProgressType,
get_progress_service,
)
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@ -92,10 +88,18 @@ class DownloadService:
# Statistics tracking # Statistics tracking
self._total_downloaded_mb: float = 0.0 self._total_downloaded_mb: float = 0.0
self._download_speeds: deque[float] = deque(maxlen=10) self._download_speeds: deque[float] = deque(maxlen=10)
# Subscribe to SeriesApp download events for progress tracking
if hasattr(anime_service, '_app') and hasattr(
anime_service._app, 'download_status'
):
anime_service._app.download_status += (
self._on_seriesapp_download_status
)
# Load persisted queue # Load persisted queue
self._load_queue() self._load_queue()
logger.info( logger.info(
"DownloadService initialized", "DownloadService initialized",
max_retries=max_retries, max_retries=max_retries,
@ -146,6 +150,69 @@ class DownloadService:
self._broadcast_callback = callback self._broadcast_callback = callback
logger.debug("Broadcast callback registered") logger.debug("Broadcast callback registered")
def _on_seriesapp_download_status(self, args) -> None:
"""Handle download status events from SeriesApp.
Updates the active download item with progress information.
Args:
args: DownloadStatusEventArgs from SeriesApp
"""
try:
# Only process if we have an active download
if not self._active_download:
return
# Match the event to the active download item
# SeriesApp events include serie_folder, season, episode
if (
self._active_download.serie_folder == args.serie_folder
and self._active_download.episode.season == args.season
and self._active_download.episode.episode == args.episode
):
if args.status == "progress":
# Update item progress
self._active_download.progress = DownloadProgress(
percent=args.progress,
downloaded_mb=(
args.progress * args.mbper_sec / 100
if args.mbper_sec
else 0.0
),
total_mb=None, # Not provided by SeriesApp
speed_mbps=args.mbper_sec,
eta_seconds=args.eta,
)
# Track speed
if args.mbper_sec:
self._download_speeds.append(args.mbper_sec)
# Broadcast update
asyncio.create_task(
self._broadcast_update(
"download_progress",
{
"download_id": self._active_download.id,
"item_id": self._active_download.id,
"serie_name": self._active_download.serie_name,
"season": args.season,
"episode": args.episode,
"progress": (
self._active_download.progress.model_dump(
mode="json"
)
),
},
)
)
except Exception as exc:
logger.error(
"Error handling SeriesApp download status",
error=str(exc)
)
async def _broadcast_update(self, update_type: str, data: dict) -> None: async def _broadcast_update(self, update_type: str, data: dict) -> None:
"""Broadcast update to connected WebSocket clients. """Broadcast update to connected WebSocket clients.
@ -689,107 +756,6 @@ class DownloadService:
f"Failed to retry: {str(e)}" f"Failed to retry: {str(e)}"
) from e ) from e
def _create_progress_callback(self, item: DownloadItem) -> Callable:
"""Create a progress callback for a download item.
Args:
item: Download item to track progress for
Returns:
Callback function for progress updates
"""
logger.info(
f"Creating progress callback for item {item.id}"
)
def progress_callback(progress_data: dict) -> None:
"""Update progress and broadcast to clients."""
try:
logger.debug(
f"Progress callback received: {progress_data}"
)
# Update item progress
item.progress = DownloadProgress(
percent=progress_data.get("percent", 0.0),
downloaded_mb=progress_data.get("downloaded_mb", 0.0),
total_mb=progress_data.get("total_mb"),
speed_mbps=progress_data.get("speed_mbps"),
eta_seconds=progress_data.get("eta_seconds"),
)
logger.debug(
f"Updated item progress: percent={item.progress.percent:.1f}%, "
f"downloaded={item.progress.downloaded_mb:.1f}MB, "
f"total={item.progress.total_mb}MB, "
f"speed={item.progress.speed_mbps}MB/s"
)
# Track speed for statistics
if item.progress.speed_mbps:
self._download_speeds.append(item.progress.speed_mbps)
# Update progress service
# Schedule coroutines in a thread-safe manner
# (callback may be called from executor thread)
if item.progress.total_mb and item.progress.total_mb > 0:
current_mb = int(item.progress.downloaded_mb)
total_mb = int(item.progress.total_mb)
logger.debug(
f"Updating progress service: current={current_mb}MB, "
f"total={total_mb}MB"
)
try:
loop = asyncio.get_event_loop()
asyncio.run_coroutine_threadsafe(
self._progress_service.update_progress(
progress_id=f"download_{item.id}",
current=current_mb,
total=total_mb,
metadata={
"speed_mbps": item.progress.speed_mbps,
"eta_seconds": item.progress.eta_seconds,
},
),
loop
)
except RuntimeError as e:
logger.warning(
f"Could not schedule progress update: {e}"
)
# Broadcast update (fire and forget)
logger.debug(
f"Broadcasting download_progress event for item {item.id}"
)
try:
loop = asyncio.get_event_loop()
asyncio.run_coroutine_threadsafe(
self._broadcast_update(
"download_progress",
{
"download_id": item.id,
"item_id": item.id,
"serie_name": item.serie_name,
"season": item.episode.season,
"episode": item.episode.episode,
"progress": item.progress.model_dump(mode="json"),
},
),
loop
)
except RuntimeError as e:
logger.warning(
f"Could not schedule broadcast: {e}"
)
except Exception as e:
logger.error("Progress callback error", error=str(e))
return progress_callback
async def _process_download(self, item: DownloadItem) -> None: async def _process_download(self, item: DownloadItem) -> None:
"""Process a single download item. """Process a single download item.
@ -809,31 +775,10 @@ class DownloadService:
season=item.episode.season, season=item.episode.season,
episode=item.episode.episode, episode=item.episode.episode,
) )
# Start progress tracking
await self._progress_service.start_progress(
progress_id=f"download_{item.id}",
progress_type=ProgressType.DOWNLOAD,
title=f"Downloading {item.serie_name}",
message=(
f"S{item.episode.season:02d}E{item.episode.episode:02d}"
),
metadata={
"item_id": item.id,
"serie_name": item.serie_name,
"season": item.episode.season,
"episode": item.episode.episode,
},
)
# Create progress callback
progress_callback = self._create_progress_callback(item)
logger.info(
f"Passing callback {progress_callback} to anime_service for "
f"item {item.id}"
)
# Execute download via anime service # Execute download via anime service
# Note: AnimeService handles progress via SeriesApp events
# Progress updates received via _on_seriesapp_download_status
# Use serie_folder if available, otherwise fall back to serie_id # Use serie_folder if available, otherwise fall back to serie_id
# for backwards compatibility with old queue items # for backwards compatibility with old queue items
folder = item.serie_folder if item.serie_folder else item.serie_id folder = item.serie_folder if item.serie_folder else item.serie_id
@ -842,7 +787,6 @@ class DownloadService:
season=item.episode.season, season=item.episode.season,
episode=item.episode.episode, episode=item.episode.episode,
key=item.serie_id, key=item.serie_id,
callback=progress_callback,
) )
# Handle result # Handle result
@ -860,17 +804,7 @@ class DownloadService:
"Download completed successfully", item_id=item.id "Download completed successfully", item_id=item.id
) )
# Complete progress tracking # Broadcast completion (progress already handled by events)
await self._progress_service.complete_progress(
progress_id=f"download_{item.id}",
message="Download completed successfully",
metadata={
"downloaded_mb": item.progress.downloaded_mb
if item.progress
else 0,
},
)
await self._broadcast_update( await self._broadcast_update(
"download_complete", "download_complete",
{ {
@ -901,13 +835,7 @@ class DownloadService:
retry_count=item.retry_count, retry_count=item.retry_count,
) )
# Fail progress tracking # Broadcast failure (progress already handled by events)
await self._progress_service.fail_progress(
progress_id=f"download_{item.id}",
error_message=str(e),
metadata={"retry_count": item.retry_count},
)
await self._broadcast_update( await self._broadcast_update(
"download_failed", "download_failed",
{ {