Persist completed downloads in downloads view

Include a capped recent tail of database-backed download history in the unified Downloads page so completed Deezer and other streaming downloads remain visible after runtime tasks are cleaned up or the container restarts. Use persistent download history for the dashboard finished count, keep live tasks authoritative for active rows, avoid showing the local clear-completed action for persisted history rows, and cover history hydration/deduping/capping in status tests.
This commit is contained in:
Broque Thomas 2026-05-24 10:02:00 -07:00
parent 4ca3f70bf3
commit 9a0e3b4011
6 changed files with 191 additions and 6 deletions

View file

@ -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']))

View file

@ -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

View file

@ -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],
)

View file

@ -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');

View file

@ -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');

View file

@ -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;