diff --git a/core/downloads/status.py b/core/downloads/status.py index 6e11a763..37ae441d 100644 --- a/core/downloads/status.py +++ b/core/downloads/status.py @@ -24,6 +24,7 @@ import logging import threading import time from dataclasses import dataclass +from datetime import datetime from typing import Any, Callable, Optional from core.runtime_state import ( @@ -82,6 +83,7 @@ class StatusDeps: download_orchestrator: Any = None run_async: Optional[Callable] = None on_download_completed: Optional[Callable[[str, str, bool], None]] = None + get_persistent_download_history: Optional[Callable[[int], list[dict]]] = None # Streaming sources the engine fallback applies to. Soulseek goes through @@ -536,6 +538,69 @@ _STATUS_PRIORITY = { 'not_found': 6, 'failed': 7, 'cancelled': 8, } +_PERSISTENT_HISTORY_TAIL_LIMIT = 50 + + +def _normalize_identity_part(value: Any) -> str: + return str(value or '').strip().casefold() + + +def _download_identity(title: Any, artist: Any, album: Any) -> tuple[str, str, str]: + return ( + _normalize_identity_part(title), + _normalize_identity_part(artist), + _normalize_identity_part(album), + ) + + +def _history_timestamp(value: Any) -> float: + if not value: + return 0.0 + if isinstance(value, (int, float)): + return float(value) + text = str(value).strip() + if not text: + return 0.0 + try: + # SQLite CURRENT_TIMESTAMP uses "YYYY-MM-DD HH:MM:SS". + return datetime.fromisoformat(text.replace('Z', '+00:00')).timestamp() + except ValueError: + try: + return datetime.strptime(text[:19], '%Y-%m-%d %H:%M:%S').timestamp() + except ValueError: + return 0.0 + + +def _build_history_download_item(entry: dict) -> dict: + history_id = entry.get('id') or entry.get('history_id') or '' + title = entry.get('title') or entry.get('source_track_title') or '' + artist = entry.get('artist_name') or entry.get('source_artist') or '' + album = entry.get('album_name') or '' + created_at = entry.get('created_at') or entry.get('completed_at') or '' + source = entry.get('download_source') or '' + return { + 'task_id': f'history-{history_id}' if history_id else f'history-{title}-{created_at}', + 'title': title, + 'artist': artist, + 'album': album, + 'artwork': entry.get('thumb_url') or '', + 'status': 'completed', + 'progress': 100, + 'error': None, + 'batch_id': '', + 'batch_name': source, + 'batch_source': source, + 'playlist_id': '', + 'track_index': 0, + 'batch_total': 1, + 'timestamp': _history_timestamp(created_at), + 'created_at': created_at, + 'priority': _STATUS_PRIORITY['completed'], + 'quality': entry.get('quality') or '', + 'file_path': entry.get('file_path') or '', + 'is_persistent_history': True, + } + def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict: """Flat list of every task across batches, sorted active-first then by recency. @@ -543,6 +608,7 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict: Powers /api/downloads/all for the centralized Downloads page. """ items = [] + live_identities = set() with tasks_lock: for task_id, task in download_tasks.items(): track_info = task.get('track_info') or {} @@ -589,6 +655,7 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict: artwork = images[0].get('url', '') if isinstance(images[0], dict) else str(images[0]) status = task.get('status', 'queued') + live_identities.add(_download_identity(title, artist, album)) # Determine download progress percentage progress = 0 if status == 'completed': @@ -625,8 +692,25 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict: 'batch_total': len(batch.get('queue', [])), 'timestamp': task.get('status_change_time', 0), 'priority': _STATUS_PRIORITY.get(status, 9), + 'is_persistent_history': False, }) + if deps.get_persistent_download_history is not None and len(items) < limit: + history_limit = min(limit - len(items), _PERSISTENT_HISTORY_TAIL_LIMIT) + try: + history_entries = deps.get_persistent_download_history(history_limit) or [] + except Exception as exc: + logger.debug("[Downloads] persistent history lookup failed: %s", exc) + history_entries = [] + + for entry in history_entries: + item = _build_history_download_item(entry) + identity = _download_identity(item.get('title'), item.get('artist'), item.get('album')) + if identity in live_identities: + continue + items.append(item) + live_identities.add(identity) + # Sort: active first (by priority), then by timestamp desc within each group items.sort(key=lambda x: (x['priority'], -x['timestamp'])) diff --git a/tests/downloads/test_downloads_status.py b/tests/downloads/test_downloads_status.py index 706f9131..64d41b3d 100644 --- a/tests/downloads/test_downloads_status.py +++ b/tests/downloads/test_downloads_status.py @@ -42,6 +42,7 @@ def _build_deps( cached_transfers=None, download_orchestrator=None, run_async=None, + persistent_history=None, ): submitted = [] @@ -57,6 +58,7 @@ def _build_deps( get_cached_transfer_data=cached_transfers or (lambda: {}), download_orchestrator=download_orchestrator, run_async=run_async, + get_persistent_download_history=persistent_history, ) return deps, submitted @@ -655,3 +657,88 @@ def test_unified_response_respects_limit(): out = st.build_unified_downloads_response(5, deps) assert len(out['downloads']) == 5 assert out['total'] == 20 # total still reflects all + + +def test_unified_response_includes_persistent_download_history(): + deps, _ = _build_deps( + persistent_history=lambda limit: [ + { + 'id': 42, + 'title': 'Persisted Track', + 'artist_name': 'Deezer Artist', + 'album_name': 'Persistent Album', + 'thumb_url': 'http://cover.jpg', + 'download_source': 'Deezer', + 'quality': 'FLAC', + 'created_at': '2026-05-24 12:34:56', + } + ] + ) + + out = st.build_unified_downloads_response(100, deps) + + assert out['total'] == 1 + item = out['downloads'][0] + assert item['task_id'] == 'history-42' + assert item['title'] == 'Persisted Track' + assert item['artist'] == 'Deezer Artist' + assert item['album'] == 'Persistent Album' + assert item['artwork'] == 'http://cover.jpg' + assert item['status'] == 'completed' + assert item['progress'] == 100 + assert item['batch_name'] == 'Deezer' + assert item['is_persistent_history'] is True + + +def test_unified_response_dedupes_history_against_live_task(): + download_tasks['live'] = { + 'track_index': 0, + 'status': 'completed', + 'track_info': { + 'name': 'Same Track', + 'artist': 'Same Artist', + 'album': 'Same Album', + }, + } + deps, _ = _build_deps( + persistent_history=lambda limit: [ + { + 'id': 7, + 'title': 'Same Track', + 'artist_name': 'Same Artist', + 'album_name': 'Same Album', + 'created_at': '2026-05-24 12:34:56', + } + ] + ) + + out = st.build_unified_downloads_response(100, deps) + + assert len(out['downloads']) == 1 + assert out['downloads'][0]['task_id'] == 'live' + assert out['downloads'][0]['is_persistent_history'] is False + + +def test_unified_response_caps_persistent_history_tail(): + requested_limits = [] + + def _history(limit): + requested_limits.append(limit) + return [ + { + 'id': i, + 'title': f'Track {i}', + 'artist_name': 'Artist', + 'album_name': 'Album', + 'created_at': '2026-05-24 12:34:56', + } + for i in range(limit + 10) + ] + + deps, _ = _build_deps(persistent_history=_history) + + out = st.build_unified_downloads_response(300, deps) + + assert requested_limits == [50] + assert len(out['downloads']) == 50 + assert out['total'] == 50 diff --git a/web_server.py b/web_server.py index f3cad76f..993cda4a 100644 --- a/web_server.py +++ b/web_server.py @@ -2557,9 +2557,16 @@ def _build_system_stats(): active_downloads = len([batch_id for batch_id, batch_data in download_batches.items() if batch_data.get('phase') == 'downloading']) - # Count finished downloads (completed this session) - use session counter like dashboard.py - with session_stats_lock: - finished_downloads = session_completed_downloads + # Count finished downloads from persistent history so the dashboard + # survives Docker/container restarts and streaming downloads that leave + # the in-memory task tracker after post-processing. + try: + persistent_finished = int(get_database().get_library_history_stats().get('downloads', 0) or 0) + with session_stats_lock: + finished_downloads = max(persistent_finished, session_completed_downloads) + except Exception: + with session_stats_lock: + finished_downloads = session_completed_downloads # Calculate total download speed from active soulseek transfers # Skip the slskd API call entirely when Soulseek is not the active download @@ -16928,6 +16935,11 @@ def _build_status_deps(): download_orchestrator=download_orchestrator, run_async=run_async, on_download_completed=_on_download_completed, + get_persistent_download_history=lambda limit: get_database().get_library_history( + event_type='download', + page=1, + limit=limit, + )[0], ) diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index d53d0667..75c3f491 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -568,7 +568,7 @@ async function fetchAndUpdateSystemStats() { // Update all stat cards updateStatCard('active-downloads-card', data.active_downloads, 'Currently downloading'); - updateStatCard('finished-downloads-card', data.finished_downloads, 'Completed this session'); + updateStatCard('finished-downloads-card', data.finished_downloads, 'Completed downloads'); updateStatCard('download-speed-card', data.download_speed, 'Combined speed'); updateStatCard('active-syncs-card', data.active_syncs, 'Playlists syncing'); updateStatCard('uptime-card', data.uptime, 'Application runtime'); diff --git a/webui/static/core.js b/webui/static/core.js index 9a1bd1e4..2ba0d6b2 100644 --- a/webui/static/core.js +++ b/webui/static/core.js @@ -628,7 +628,7 @@ function unsubscribeFromDownloadBatch(batchId) { function handleDashboardStats(data) { // Same logic as fetchAndUpdateSystemStats response handler updateStatCard('active-downloads-card', data.active_downloads, 'Currently downloading'); - updateStatCard('finished-downloads-card', data.finished_downloads, 'Completed this session'); + updateStatCard('finished-downloads-card', data.finished_downloads, 'Completed downloads'); updateStatCard('download-speed-card', data.download_speed, 'Combined speed'); updateStatCard('active-syncs-card', data.active_syncs, 'Playlists syncing'); updateStatCard('uptime-card', data.uptime, 'Application runtime'); diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 47a65710..a10a7df5 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -2278,7 +2278,9 @@ function _adlRender() { else if (_adlFilter === 'completed') filtered = filtered.filter(d => completedStatuses.includes(d.status)); else if (_adlFilter === 'failed') filtered = filtered.filter(d => failedStatuses.includes(d.status)); - const completedN = _adlData.filter(d => [...completedStatuses, ...failedStatuses].includes(d.status)).length; + const completedN = _adlData.filter(d => + [...completedStatuses, ...failedStatuses].includes(d.status) && !d.is_persistent_history + ).length; if (countEl) { const activeN = _adlData.filter(d => activeStatuses.includes(d.status)).length;