Add MP4 scan progress visibility in UI

- Add broadcast_scan_started, broadcast_scan_progress, broadcast_scan_completed to WebSocketService
- Inject WebSocketService into AnimeService for real-time scan progress broadcasts
- Add CSS styles for scan progress overlay with spinner, stats, and completion state
- Update app.js to handle scan events and display progress overlay
- Add unit tests for new WebSocket broadcast methods
- All 1022 tests passing
This commit is contained in:
2025-12-23 18:24:32 +01:00
parent 9b071fe370
commit a24f07a36e
8 changed files with 633 additions and 20 deletions

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import time
from functools import lru_cache
from typing import Optional
@@ -12,6 +13,10 @@ from src.server.services.progress_service import (
ProgressType,
get_progress_service,
)
from src.server.services.websocket_service import (
WebSocketService,
get_websocket_service,
)
logger = structlog.get_logger(__name__)
@@ -37,11 +42,17 @@ class AnimeService:
self,
series_app: SeriesApp,
progress_service: Optional[ProgressService] = None,
websocket_service: Optional[WebSocketService] = None,
):
self._app = series_app
self._directory = series_app.directory_to_search
self._progress_service = progress_service or get_progress_service()
self._websocket_service = websocket_service or get_websocket_service()
self._event_loop: Optional[asyncio.AbstractEventLoop] = None
# Track scan progress for WebSocket updates
self._scan_start_time: Optional[float] = None
self._scan_directories_count: int = 0
self._scan_files_count: int = 0
# Subscribe to SeriesApp events
# Note: Events library uses assignment (=), not += operator
try:
@@ -152,7 +163,8 @@ class AnimeService:
"""Handle scan status events from SeriesApp.
Events include both 'key' (primary identifier) and 'folder'
(metadata for display purposes).
(metadata for display purposes). Also broadcasts via WebSocket
for real-time UI updates.
Args:
args: ScanStatusEventArgs from SeriesApp containing key,
@@ -178,6 +190,11 @@ class AnimeService:
# Map SeriesApp scan events to progress service
if args.status == "started":
# Track scan start time and reset counters
self._scan_start_time = time.time()
self._scan_directories_count = 0
self._scan_files_count = 0
asyncio.run_coroutine_threadsafe(
self._progress_service.start_progress(
progress_id=scan_id,
@@ -187,7 +204,17 @@ class AnimeService:
),
loop
)
# Broadcast scan started via WebSocket
asyncio.run_coroutine_threadsafe(
self._broadcast_scan_started_safe(),
loop
)
elif args.status == "progress":
# Update scan counters
self._scan_directories_count = args.current
# Estimate files found (use current as proxy since detailed
# file count isn't available from SerieScanner)
asyncio.run_coroutine_threadsafe(
self._progress_service.update_progress(
progress_id=scan_id,
@@ -197,7 +224,21 @@ class AnimeService:
),
loop
)
# Broadcast scan progress via WebSocket (throttled - every update)
asyncio.run_coroutine_threadsafe(
self._broadcast_scan_progress_safe(
directories_scanned=args.current,
files_found=args.current, # Use folder count as proxy
current_directory=args.folder or "",
),
loop
)
elif args.status == "completed":
# Calculate elapsed time
elapsed = 0.0
if self._scan_start_time:
elapsed = time.time() - self._scan_start_time
asyncio.run_coroutine_threadsafe(
self._progress_service.complete_progress(
progress_id=scan_id,
@@ -205,6 +246,15 @@ class AnimeService:
),
loop
)
# Broadcast scan completed via WebSocket
asyncio.run_coroutine_threadsafe(
self._broadcast_scan_completed_safe(
total_directories=args.total,
total_files=args.total, # Use folder count as proxy
elapsed_seconds=elapsed,
),
loop
)
elif args.status == "failed":
asyncio.run_coroutine_threadsafe(
self._progress_service.fail_progress(
@@ -224,6 +274,78 @@ class AnimeService:
except Exception as exc: # pylint: disable=broad-except
logger.error("Error handling scan status event: %s", exc)
async def _broadcast_scan_started_safe(self) -> None:
"""Safely broadcast scan started event via WebSocket.
Wraps the WebSocket broadcast in try/except to ensure scan
continues even if WebSocket fails.
"""
try:
await self._websocket_service.broadcast_scan_started(
directory=self._directory
)
except Exception as exc:
logger.warning(
"Failed to broadcast scan_started via WebSocket",
error=str(exc)
)
async def _broadcast_scan_progress_safe(
self,
directories_scanned: int,
files_found: int,
current_directory: str,
) -> None:
"""Safely broadcast scan progress event via WebSocket.
Wraps the WebSocket broadcast in try/except to ensure scan
continues even if WebSocket fails.
Args:
directories_scanned: Number of directories scanned so far
files_found: Number of files found so far
current_directory: Current directory being scanned
"""
try:
await self._websocket_service.broadcast_scan_progress(
directories_scanned=directories_scanned,
files_found=files_found,
current_directory=current_directory,
)
except Exception as exc:
logger.warning(
"Failed to broadcast scan_progress via WebSocket",
error=str(exc)
)
async def _broadcast_scan_completed_safe(
self,
total_directories: int,
total_files: int,
elapsed_seconds: float,
) -> None:
"""Safely broadcast scan completed event via WebSocket.
Wraps the WebSocket broadcast in try/except to ensure scan
cleanup continues even if WebSocket fails.
Args:
total_directories: Total directories scanned
total_files: Total files found
elapsed_seconds: Time taken for the scan
"""
try:
await self._websocket_service.broadcast_scan_completed(
total_directories=total_directories,
total_files=total_files,
elapsed_seconds=elapsed_seconds,
)
except Exception as exc:
logger.warning(
"Failed to broadcast scan_completed via WebSocket",
error=str(exc)
)
@lru_cache(maxsize=128)
def _cached_list_missing(self) -> list[dict]:
# Synchronous cached call - SeriesApp.series_list is populated

View File

@@ -498,6 +498,76 @@ class WebSocketService:
}
await self._manager.send_personal_message(message, connection_id)
async def broadcast_scan_started(self, directory: str) -> None:
"""Broadcast that a library scan has started.
Args:
directory: The root directory path being scanned
"""
message = {
"type": "scan_started",
"timestamp": datetime.now(timezone.utc).isoformat(),
"data": {
"directory": directory,
},
}
await self._manager.broadcast(message)
logger.info("Broadcast scan_started", directory=directory)
async def broadcast_scan_progress(
self,
directories_scanned: int,
files_found: int,
current_directory: str,
) -> None:
"""Broadcast scan progress update to all clients.
Args:
directories_scanned: Number of directories scanned so far
files_found: Number of MP4 files found so far
current_directory: Current directory being scanned
"""
message = {
"type": "scan_progress",
"timestamp": datetime.now(timezone.utc).isoformat(),
"data": {
"directories_scanned": directories_scanned,
"files_found": files_found,
"current_directory": current_directory,
},
}
await self._manager.broadcast(message)
async def broadcast_scan_completed(
self,
total_directories: int,
total_files: int,
elapsed_seconds: float,
) -> None:
"""Broadcast scan completion to all clients.
Args:
total_directories: Total number of directories scanned
total_files: Total number of MP4 files found
elapsed_seconds: Time taken for the scan in seconds
"""
message = {
"type": "scan_completed",
"timestamp": datetime.now(timezone.utc).isoformat(),
"data": {
"total_directories": total_directories,
"total_files": total_files,
"elapsed_seconds": round(elapsed_seconds, 2),
},
}
await self._manager.broadcast(message)
logger.info(
"Broadcast scan_completed",
total_directories=total_directories,
total_files=total_files,
elapsed_seconds=round(elapsed_seconds, 2),
)
# Singleton instance for application-wide access
_websocket_service: Optional[WebSocketService] = None