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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user