Show total items to scan in progress overlay

- Add total_items parameter to broadcast_scan_started and broadcast_scan_progress
- Pass total from SeriesApp to WebSocket broadcasts in AnimeService
- Update JS overlay to show progress bar and current/total count
- Add CSS for progress bar styling
- Add unit tests for new total_items parameter
- All 1024 tests passing
This commit is contained in:
Lukas 2025-12-24 20:54:27 +01:00
parent a24f07a36e
commit 72ac201153
10 changed files with 212 additions and 16 deletions

Binary file not shown.

Binary file not shown.

View File

@ -17,8 +17,7 @@
"keep_days": 30
},
"other": {
"master_password_hash": "$pbkdf2-sha256$29000$o1RqTSnFWKt1TknpHQOgdA$ZYtZ.NZkQbLYYhbQNJXUl7NOotcBza58uEIrhnP9M9Q",
"anime_directory": "/mnt/server/serien/Serien/"
"master_password_hash": "$pbkdf2-sha256$29000$bY3xHiPkPCckJMT4H8PY2w$s7wlQnFrLpXdGE4GhX5hgZGSHka4SsuAchcFN5qBx3k"
},
"version": "1.0.0"
}

View File

@ -17,8 +17,7 @@
"keep_days": 30
},
"other": {
"master_password_hash": "$pbkdf2-sha256$29000$RYgRIoQwBuC8N.bcO0eoNQ$6Cdc9sZvqy8li/43B0NcXYlysYrj/lIqy2E7gBtN4dk",
"anime_directory": "/mnt/server/serien/Serien/"
"master_password_hash": "$pbkdf2-sha256$29000$CMG4t9a6t9Y6J2TMOYfQ2g$bUIhqeewMMSj2Heh07vIhAGjvzDVijHb62aes4JuJhw"
},
"version": "1.0.0"
}

View File

@ -446,12 +446,17 @@ class SeriesApp:
try:
# Get total items to scan
logger.info("Getting total items to scan...")
total_to_scan = await asyncio.to_thread(
self.serie_scanner.get_total_to_scan
)
logger.info("Total folders to scan: %d", total_to_scan)
# Fire scan started event
logger.info(
"Firing scan_status 'started' event, handler=%s",
self._events.scan_status
)
self._events.scan_status(
ScanStatusEventArgs(
current=0,
@ -502,6 +507,10 @@ class SeriesApp:
logger.info("Directory rescan completed successfully")
# Fire scan completed event
logger.info(
"Firing scan_status 'completed' event, handler=%s",
self._events.scan_status
)
self._events.scan_status(
ScanStatusEventArgs(
current=total_to_scan,

View File

@ -53,12 +53,17 @@ class AnimeService:
self._scan_start_time: Optional[float] = None
self._scan_directories_count: int = 0
self._scan_files_count: int = 0
self._scan_total_items: int = 0
# Subscribe to SeriesApp events
# Note: Events library uses assignment (=), not += operator
try:
self._app.download_status = self._on_download_status
self._app.scan_status = self._on_scan_status
logger.debug("Successfully subscribed to SeriesApp events")
logger.info(
"Subscribed to SeriesApp events",
scan_status_handler=str(self._app.scan_status),
series_app_id=id(self._app),
)
except Exception as e:
logger.exception("Failed to subscribe to SeriesApp events")
raise AnimeServiceError("Initialization failed") from e
@ -173,27 +178,47 @@ class AnimeService:
try:
scan_id = "library_scan"
logger.info(
"Scan status event received",
status=args.status,
current=args.current,
total=args.total,
folder=args.folder,
)
# Get event loop - try running loop first, then stored loop
loop = None
try:
loop = asyncio.get_running_loop()
logger.debug("Using running event loop for scan status")
except RuntimeError:
# No running loop in this thread - use stored loop
loop = self._event_loop
logger.debug(
"Using stored event loop for scan status",
has_loop=loop is not None
)
if not loop:
logger.debug(
logger.warning(
"No event loop available for scan status event",
status=args.status
)
return
logger.info(
"Processing scan status event",
status=args.status,
loop_id=id(loop),
)
# 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
self._scan_total_items = args.total
asyncio.run_coroutine_threadsafe(
self._progress_service.start_progress(
@ -204,9 +229,9 @@ class AnimeService:
),
loop
)
# Broadcast scan started via WebSocket
# Broadcast scan started via WebSocket with total items
asyncio.run_coroutine_threadsafe(
self._broadcast_scan_started_safe(),
self._broadcast_scan_started_safe(total_items=args.total),
loop
)
elif args.status == "progress":
@ -224,12 +249,13 @@ class AnimeService:
),
loop
)
# Broadcast scan progress via WebSocket (throttled - every update)
# Broadcast scan progress via WebSocket
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 "",
total_items=args.total,
),
loop
)
@ -274,16 +300,26 @@ 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:
async def _broadcast_scan_started_safe(self, total_items: int = 0) -> None:
"""Safely broadcast scan started event via WebSocket.
Wraps the WebSocket broadcast in try/except to ensure scan
continues even if WebSocket fails.
Args:
total_items: Total number of items to scan
"""
try:
await self._websocket_service.broadcast_scan_started(
directory=self._directory
logger.info(
"Broadcasting scan_started via WebSocket",
directory=self._directory,
total_items=total_items,
)
await self._websocket_service.broadcast_scan_started(
directory=self._directory,
total_items=total_items,
)
logger.info("scan_started broadcast sent successfully")
except Exception as exc:
logger.warning(
"Failed to broadcast scan_started via WebSocket",
@ -295,6 +331,7 @@ class AnimeService:
directories_scanned: int,
files_found: int,
current_directory: str,
total_items: int = 0,
) -> None:
"""Safely broadcast scan progress event via WebSocket.
@ -305,12 +342,14 @@ class AnimeService:
directories_scanned: Number of directories scanned so far
files_found: Number of files found so far
current_directory: Current directory being scanned
total_items: Total number of items to scan
"""
try:
await self._websocket_service.broadcast_scan_progress(
directories_scanned=directories_scanned,
files_found=files_found,
current_directory=current_directory,
total_items=total_items,
)
except Exception as exc:
logger.warning(
@ -418,6 +457,12 @@ class AnimeService:
try:
# Store event loop for event handlers
self._event_loop = asyncio.get_running_loop()
logger.info(
"Rescan started, event loop stored",
loop_id=id(self._event_loop),
series_app_id=id(self._app),
scan_handler=str(self._app.scan_status),
)
# SeriesApp.rescan returns scanned series list
scanned_series = await self._app.rescan()

View File

@ -498,27 +498,36 @@ class WebSocketService:
}
await self._manager.send_personal_message(message, connection_id)
async def broadcast_scan_started(self, directory: str) -> None:
async def broadcast_scan_started(
self, directory: str, total_items: int = 0
) -> None:
"""Broadcast that a library scan has started.
Args:
directory: The root directory path being scanned
total_items: Total number of items to scan (for progress display)
"""
message = {
"type": "scan_started",
"timestamp": datetime.now(timezone.utc).isoformat(),
"data": {
"directory": directory,
"total_items": total_items,
},
}
await self._manager.broadcast(message)
logger.info("Broadcast scan_started", directory=directory)
logger.info(
"Broadcast scan_started",
directory=directory,
total_items=total_items,
)
async def broadcast_scan_progress(
self,
directories_scanned: int,
files_found: int,
current_directory: str,
total_items: int = 0,
) -> None:
"""Broadcast scan progress update to all clients.
@ -526,6 +535,7 @@ class WebSocketService:
directories_scanned: Number of directories scanned so far
files_found: Number of MP4 files found so far
current_directory: Current directory being scanned
total_items: Total number of items to scan (for progress display)
"""
message = {
"type": "scan_progress",
@ -534,6 +544,7 @@ class WebSocketService:
"directories_scanned": directories_scanned,
"files_found": files_found,
"current_directory": current_directory,
"total_items": total_items,
},
}
await self._manager.broadcast(message)

View File

@ -1978,6 +1978,47 @@ body {
}
}
/* Progress bar for scan */
.scan-progress-bar-container {
width: 100%;
height: 8px;
background-color: var(--color-bg-tertiary);
border-radius: 4px;
overflow: hidden;
margin-bottom: var(--spacing-sm);
}
.scan-progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--color-accent), var(--color-accent-hover, var(--color-accent)));
border-radius: 4px;
transition: width 0.3s ease;
}
.scan-progress-container.completed .scan-progress-bar {
background: linear-gradient(90deg, var(--color-success), var(--color-success));
}
.scan-progress-text {
font-size: var(--font-size-body);
color: var(--color-text-secondary);
margin-bottom: var(--spacing-md);
}
.scan-progress-text #scan-current-count {
font-weight: 600;
color: var(--color-accent);
}
.scan-progress-text #scan-total-count {
font-weight: 600;
color: var(--color-text-primary);
}
.scan-progress-container.completed .scan-progress-text #scan-current-count {
color: var(--color-success);
}
.scan-progress-stats {
display: flex;
justify-content: space-around;

View File

@ -1085,10 +1085,16 @@ class AniWorldApp {
// Remove existing overlay if present
this.removeScanProgressOverlay();
// Store total items for progress calculation
this.scanTotalItems = data?.total_items || 0;
// Create overlay element
const overlay = document.createElement('div');
overlay.id = 'scan-progress-overlay';
overlay.className = 'scan-progress-overlay';
const totalDisplay = this.scanTotalItems > 0 ? this.scanTotalItems : '...';
overlay.innerHTML = `
<div class="scan-progress-container">
<div class="scan-progress-header">
@ -1098,10 +1104,16 @@ class AniWorldApp {
<span class="scan-title-text">Scanning Library</span>
</h3>
</div>
<div class="scan-progress-bar-container">
<div class="scan-progress-bar" id="scan-progress-bar" style="width: 0%"></div>
</div>
<div class="scan-progress-text" id="scan-progress-text">
<span id="scan-current-count">0</span> / <span id="scan-total-count">${totalDisplay}</span> directories
</div>
<div class="scan-progress-stats">
<div class="scan-stat">
<span class="scan-stat-value" id="scan-directories-count">0</span>
<span class="scan-stat-label">Directories</span>
<span class="scan-stat-label">Scanned</span>
</div>
<div class="scan-stat">
<span class="scan-stat-value" id="scan-files-count">0</span>
@ -1109,7 +1121,7 @@ class AniWorldApp {
</div>
</div>
<div class="scan-current-directory" id="scan-current-directory">
<span class="scan-current-directory-label">Scanning:</span>
<span class="scan-current-directory-label">Current:</span>
<span id="scan-current-path">${this.escapeHtml(data?.directory || 'Initializing...')}</span>
</div>
<div class="scan-elapsed-time hidden" id="scan-elapsed-time">
@ -1135,6 +1147,28 @@ class AniWorldApp {
const overlay = document.getElementById('scan-progress-overlay');
if (!overlay) return;
// Update total items if provided (in case it wasn't available at start)
if (data.total_items && data.total_items > 0) {
this.scanTotalItems = data.total_items;
const totalCount = document.getElementById('scan-total-count');
if (totalCount) {
totalCount.textContent = this.scanTotalItems;
}
}
// Update progress bar
const progressBar = document.getElementById('scan-progress-bar');
if (progressBar && this.scanTotalItems > 0 && data.directories_scanned !== undefined) {
const percentage = Math.min(100, (data.directories_scanned / this.scanTotalItems) * 100);
progressBar.style.width = `${percentage}%`;
}
// Update current/total count display
const currentCount = document.getElementById('scan-current-count');
if (currentCount && data.directories_scanned !== undefined) {
currentCount.textContent = data.directories_scanned;
}
// Update directories count
const dirCount = document.getElementById('scan-directories-count');
if (dirCount && data.directories_scanned !== undefined) {
@ -1179,6 +1213,12 @@ class AniWorldApp {
titleText.textContent = 'Scan Complete';
}
// Complete the progress bar
const progressBar = document.getElementById('scan-progress-bar');
if (progressBar) {
progressBar.style.width = '100%';
}
// Update final stats
if (data) {
const dirCount = document.getElementById('scan-directories-count');
@ -1191,6 +1231,16 @@ class AniWorldApp {
filesCount.textContent = data.total_files;
}
// Update progress text to show final count
const currentCount = document.getElementById('scan-current-count');
const totalCount = document.getElementById('scan-total-count');
if (currentCount && data.total_directories !== undefined) {
currentCount.textContent = data.total_directories;
}
if (totalCount && data.total_directories !== undefined) {
totalCount.textContent = data.total_directories;
}
// Show elapsed time
const elapsedTimeEl = document.getElementById('scan-elapsed-time');
const elapsedValueEl = document.getElementById('scan-elapsed-value');

View File

@ -438,6 +438,23 @@ class TestWebSocketService:
"""Test broadcasting scan started event."""
connection_id = "test-conn"
directory = "/home/user/anime"
total_items = 42
await service.connect(mock_websocket, connection_id)
await service.broadcast_scan_started(directory, total_items)
assert mock_websocket.send_json.called
call_args = mock_websocket.send_json.call_args[0][0]
assert call_args["type"] == "scan_started"
assert call_args["data"]["directory"] == directory
assert call_args["data"]["total_items"] == total_items
assert "timestamp" in call_args
@pytest.mark.asyncio
async def test_broadcast_scan_started_default_total(self, service, mock_websocket):
"""Test broadcasting scan started event with default total_items."""
connection_id = "test-conn"
directory = "/home/user/anime"
await service.connect(mock_websocket, connection_id)
await service.broadcast_scan_started(directory)
@ -446,6 +463,7 @@ class TestWebSocketService:
call_args = mock_websocket.send_json.call_args[0][0]
assert call_args["type"] == "scan_started"
assert call_args["data"]["directory"] == directory
assert call_args["data"]["total_items"] == 0
assert "timestamp" in call_args
@pytest.mark.asyncio
@ -455,6 +473,29 @@ class TestWebSocketService:
directories_scanned = 25
files_found = 150
current_directory = "/home/user/anime/Attack on Titan"
total_items = 100
await service.connect(mock_websocket, connection_id)
await service.broadcast_scan_progress(
directories_scanned, files_found, current_directory, total_items
)
assert mock_websocket.send_json.called
call_args = mock_websocket.send_json.call_args[0][0]
assert call_args["type"] == "scan_progress"
assert call_args["data"]["directories_scanned"] == directories_scanned
assert call_args["data"]["files_found"] == files_found
assert call_args["data"]["current_directory"] == current_directory
assert call_args["data"]["total_items"] == total_items
assert "timestamp" in call_args
@pytest.mark.asyncio
async def test_broadcast_scan_progress_default_total(self, service, mock_websocket):
"""Test broadcasting scan progress event with default total_items."""
connection_id = "test-conn"
directories_scanned = 25
files_found = 150
current_directory = "/home/user/anime/Attack on Titan"
await service.connect(mock_websocket, connection_id)
await service.broadcast_scan_progress(
@ -467,6 +508,7 @@ class TestWebSocketService:
assert call_args["data"]["directories_scanned"] == directories_scanned
assert call_args["data"]["files_found"] == files_found
assert call_args["data"]["current_directory"] == current_directory
assert call_args["data"]["total_items"] == 0
assert "timestamp" in call_args
@pytest.mark.asyncio