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:
parent
4ca3f70bf3
commit
9a0e3b4011
6 changed files with 191 additions and 6 deletions
|
|
@ -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']))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue