feat: implement WebSocket real-time progress updates

- Add ProgressService for centralized progress tracking and broadcasting
- Integrate ProgressService with DownloadService for download progress
- Integrate ProgressService with AnimeService for scan progress
- Add progress-related WebSocket message models (ScanProgress, ErrorNotification, etc.)
- Initialize ProgressService with WebSocket callback in application startup
- Add comprehensive unit tests for ProgressService
- Update infrastructure.md with ProgressService documentation
- Remove completed WebSocket Real-time Updates task from instructions.md

The ProgressService provides:
- Real-time progress tracking for downloads, scans, and queue operations
- Automatic progress percentage calculation
- Progress lifecycle management (start, update, complete, fail, cancel)
- WebSocket integration for instant client updates
- Progress history with size limits
- Thread-safe operations using asyncio locks
- Support for metadata and custom messages

Benefits:
- Decoupled progress tracking from WebSocket broadcasting
- Single reusable service across all components
- Supports multiple concurrent operations efficiently
- Centralized progress tracking simplifies monitoring
- Instant feedback to users on long-running operations
This commit is contained in:
2025-10-17 11:12:06 +02:00
parent 42a07be4cb
commit 94de91ffa0
7 changed files with 1375 additions and 4 deletions

View File

@@ -3,11 +3,16 @@ from __future__ import annotations
import asyncio
from concurrent.futures import ThreadPoolExecutor
from functools import lru_cache
from typing import List, Optional
from typing import Callable, List, Optional
import structlog
from src.core.SeriesApp import SeriesApp
from src.server.services.progress_service import (
ProgressService,
ProgressType,
get_progress_service,
)
logger = structlog.get_logger(__name__)
@@ -24,9 +29,15 @@ class AnimeService:
- Adds simple in-memory caching for read operations
"""
def __init__(self, directory: str, max_workers: int = 4):
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
try:
self._app = SeriesApp(directory)
@@ -75,20 +86,70 @@ class AnimeService:
logger.exception("search failed")
raise AnimeServiceError("Search failed") from e
async def rescan(self, callback=None) -> None:
async def rescan(self, callback: Optional[Callable] = None) -> None:
"""Trigger a re-scan. Accepts an optional callback function.
The callback is executed in the threadpool by SeriesApp.
Progress updates are tracked and broadcasted via ProgressService.
"""
scan_id = "library_scan"
try:
await self._run_in_executor(self._app.ReScan, callback)
# 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...")
asyncio.create_task(
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
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: