progress
This commit is contained in:
parent
b9879c4375
commit
b84a3ae108
3 changed files with 263 additions and 46 deletions
111
web_server.py
111
web_server.py
|
|
@ -874,6 +874,15 @@ def start_download():
|
|||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _find_completed_file_robust(download_dir, api_filename):
|
||||
"""
|
||||
Robustly finds a completed file on disk, accounting for name variations and
|
||||
|
|
@ -929,6 +938,8 @@ def _find_completed_file_robust(download_dir, api_filename):
|
|||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
@app.route('/api/downloads/status')
|
||||
def get_download_status():
|
||||
"""
|
||||
|
|
@ -1021,6 +1032,7 @@ def get_download_status():
|
|||
|
||||
|
||||
|
||||
|
||||
@app.route('/api/downloads/cancel', methods=['POST'])
|
||||
def cancel_download():
|
||||
"""
|
||||
|
|
@ -1780,6 +1792,8 @@ def _search_track_in_album_context(original_search: dict, artist: dict) -> dict:
|
|||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
def _detect_album_info_web(context: dict, artist: dict) -> dict:
|
||||
"""
|
||||
This is the final, corrected version that ensures the official Spotify track
|
||||
|
|
@ -2022,6 +2036,8 @@ def _download_cover_art(album_info: dict, target_dir: str):
|
|||
print(f"❌ Error downloading cover.jpg: {e}")
|
||||
|
||||
|
||||
|
||||
|
||||
def _get_spotify_album_tracks(spotify_album: dict) -> list:
|
||||
"""Fetches all tracks for a given Spotify album ID."""
|
||||
if not spotify_album or not spotify_album.get('id'):
|
||||
|
|
@ -2086,6 +2102,7 @@ def _match_track_to_spotify_title(slsk_track_meta: dict, spotify_tracks: list) -
|
|||
return slsk_track_meta # Fallback to original
|
||||
|
||||
|
||||
|
||||
# --- Post-Processing Logic ---
|
||||
def _post_process_matched_download(context_key, context, file_path):
|
||||
"""
|
||||
|
|
@ -2201,9 +2218,6 @@ def _post_process_matched_download(context_key, context, file_path):
|
|||
matched_downloads_context[context_key] = context
|
||||
print(f"♻️ Re-added {context_key} to context for retry")
|
||||
|
||||
|
||||
|
||||
|
||||
# Keep track of processed downloads to avoid re-processing
|
||||
_processed_download_ids = set()
|
||||
|
||||
|
|
@ -2771,31 +2785,90 @@ def start_playlist_missing_downloads(playlist_id):
|
|||
@app.route('/api/playlists/<batch_id>/download_status', methods=['GET'])
|
||||
def get_batch_download_status(batch_id):
|
||||
"""
|
||||
This endpoint returns real-time status for all tasks in a batch,
|
||||
enabling live progress tracking in the modal.
|
||||
Returns real-time status for all tasks in a batch. This version correctly
|
||||
parses the live 'state' from slskd to report 'completed' status, fixing
|
||||
the UI issue where downloads would get stuck at 100%.
|
||||
"""
|
||||
try:
|
||||
# --- Fetch live transfer data from slskd ---
|
||||
live_transfers_lookup = {}
|
||||
try:
|
||||
transfers_data = asyncio.run(soulseek_client._make_request('GET', 'transfers/downloads'))
|
||||
if transfers_data:
|
||||
all_transfers = []
|
||||
for user_data in transfers_data:
|
||||
username = user_data.get('username', 'Unknown')
|
||||
if 'directories' in user_data:
|
||||
for directory in user_data['directories']:
|
||||
if 'files' in directory:
|
||||
for file_info in directory['files']:
|
||||
file_info['username'] = username
|
||||
all_transfers.append(file_info)
|
||||
|
||||
# Create a lookup dictionary using a reliable composite key
|
||||
for transfer in all_transfers:
|
||||
key = f"{transfer.get('username')}::{os.path.basename(transfer.get('filename', ''))}"
|
||||
live_transfers_lookup[key] = transfer
|
||||
except Exception as e:
|
||||
print(f"⚠️ Could not fetch live transfers for modal status: {e}")
|
||||
|
||||
# --- Process tasks and enrich with live data ---
|
||||
with tasks_lock:
|
||||
batch_tasks = []
|
||||
for task_id, task in download_tasks.items():
|
||||
if task.get('batch_id') == batch_id:
|
||||
task_status = {
|
||||
'task_id': task_id,
|
||||
'track_index': task['track_index'],
|
||||
'status': task['status'],
|
||||
'track_info': task['track_info'],
|
||||
'download_id': task.get('download_id'),
|
||||
'username': task.get('username')
|
||||
}
|
||||
batch_tasks.append(task_status)
|
||||
print(f"🔧 [Status API] Task {task_id} track_index {task['track_index']} status: {task['status']}")
|
||||
|
||||
# Sort by track_index to maintain order
|
||||
if batch_id not in download_batches:
|
||||
return jsonify({"tasks": []})
|
||||
|
||||
for task_id in download_batches[batch_id].get('queue', []):
|
||||
task = download_tasks.get(task_id)
|
||||
if not task:
|
||||
continue
|
||||
|
||||
task_status = {
|
||||
'task_id': task_id,
|
||||
'track_index': task['track_index'],
|
||||
'status': task['status'],
|
||||
'track_info': task['track_info'],
|
||||
'progress': 0
|
||||
}
|
||||
|
||||
# --- USE THE RELIABLE KEY FOR MATCHING ---
|
||||
task_filename = task.get('filename') or task['track_info'].get('filename')
|
||||
task_username = task.get('username') or task['track_info'].get('username')
|
||||
|
||||
if task_filename and task_username:
|
||||
lookup_key = f"{task_username}::{os.path.basename(task_filename)}"
|
||||
|
||||
if lookup_key in live_transfers_lookup:
|
||||
live_info = live_transfers_lookup[lookup_key]
|
||||
|
||||
# --- THIS IS THE KEY FIX ---
|
||||
# Correctly parse the live state string from the API
|
||||
state_str = live_info.get('state', 'Unknown')
|
||||
|
||||
if 'Completed' in state_str or 'Succeeded' in state_str:
|
||||
task_status['status'] = 'completed'
|
||||
elif 'Cancelled' in state_str or 'Canceled' in state_str:
|
||||
task_status['status'] = 'cancelled'
|
||||
elif 'Failed' in state_str or 'Errored' in state_str:
|
||||
task_status['status'] = 'failed'
|
||||
elif 'InProgress' in state_str:
|
||||
task_status['status'] = 'downloading'
|
||||
else:
|
||||
task_status['status'] = 'queued'
|
||||
# --- END OF FIX ---
|
||||
|
||||
task_status['progress'] = live_info.get('percentComplete', 0)
|
||||
print(f"🔧 [Status API] Live Update for Task {task_id}: Status '{task_status['status']}', Progress {task_status['progress']}%")
|
||||
|
||||
batch_tasks.append(task_status)
|
||||
|
||||
batch_tasks.sort(key=lambda x: x['track_index'])
|
||||
|
||||
return jsonify({"tasks": batch_tasks})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print(f"❌ Error getting batch status: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
|
|
|||
|
|
@ -2018,24 +2018,21 @@ function startModalDownloadPolling() {
|
|||
let failedCount = 0;
|
||||
let totalTasks = tasks.length;
|
||||
|
||||
// Update each track's status - but only for missing tracks
|
||||
// Update each track's status in the modal
|
||||
for (const task of tasks) {
|
||||
const trackIndex = task.track_index;
|
||||
const row = document.querySelector(`tr[data-track-index="${trackIndex}"]`);
|
||||
|
||||
// Only update if this is a missing track (has a matching entry in missingTracks array)
|
||||
const isMissingTrack = missingTracks.some(mt => mt.track_index === trackIndex);
|
||||
|
||||
|
||||
if (row && isMissingTrack) {
|
||||
const statusElement = row.querySelector('.track-download-status');
|
||||
const actionsElement = row.querySelector('.track-actions');
|
||||
|
||||
// Store task ID for cancellation
|
||||
row.dataset.taskId = task.task_id;
|
||||
|
||||
const status = task.status;
|
||||
|
||||
const progress = task.progress || 0; // Get live progress from backend
|
||||
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
statusElement.textContent = '⏸️ Pending';
|
||||
|
|
@ -2048,15 +2045,9 @@ function startModalDownloadPolling() {
|
|||
actionsElement.innerHTML = `<button class="cancel-track-btn" onclick="cancelTrackDownload(${trackIndex})">Cancel</button>`;
|
||||
break;
|
||||
case 'downloading':
|
||||
statusElement.textContent = '⏬ Downloading...';
|
||||
statusElement.textContent = `⏬ Downloading... ${Math.round(progress)}%`;
|
||||
statusElement.className = 'track-download-status download-downloading';
|
||||
actionsElement.innerHTML = `<button class="cancel-track-btn" onclick="cancelTrackDownload(${trackIndex})">Cancel</button>`;
|
||||
|
||||
// Start live download polling when we detect actual downloads have begun
|
||||
if (!isDownloadPollingActive) {
|
||||
console.log('🔄 Download detected - starting live download polling integration');
|
||||
startDownloadPolling();
|
||||
}
|
||||
break;
|
||||
case 'completed':
|
||||
statusElement.textContent = '✅ Completed';
|
||||
|
|
@ -2084,13 +2075,11 @@ function startModalDownloadPolling() {
|
|||
}
|
||||
}
|
||||
|
||||
// Update progress
|
||||
// Update progress bar and stats
|
||||
const progressPercent = totalTasks > 0 ? ((completedCount + failedCount) / totalTasks) * 100 : 0;
|
||||
document.getElementById('download-progress-fill').style.width = `${progressPercent}%`;
|
||||
document.getElementById('download-progress-text').textContent =
|
||||
`${completedCount}/${totalTasks} completed (${progressPercent.toFixed(0)}%)`;
|
||||
|
||||
// Update downloaded count
|
||||
document.getElementById('stat-downloaded').textContent = completedCount;
|
||||
|
||||
// Stop polling when all tasks are complete
|
||||
|
|
@ -2099,26 +2088,16 @@ function startModalDownloadPolling() {
|
|||
modalDownloadPoller = null;
|
||||
document.getElementById('cancel-all-btn').style.display = 'none';
|
||||
console.log('✅ All download tasks completed, stopping polling');
|
||||
|
||||
// Also stop live download polling if we started it
|
||||
if (isDownloadPollingActive) {
|
||||
stopDownloadPolling();
|
||||
}
|
||||
|
||||
if (completedCount > 0) {
|
||||
showToast(`Download completed: ${completedCount} tracks downloaded successfully!`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
// Update modal tracks with live download progress from the actual download queue
|
||||
updateModalWithLiveDownloadProgress();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error polling download status:', error);
|
||||
}
|
||||
}, 2000); // Poll every 2 seconds
|
||||
}
|
||||
|
||||
async function updateModalWithLiveDownloadProgress() {
|
||||
try {
|
||||
if (!currentDownloadBatchId) return;
|
||||
|
|
@ -3767,6 +3746,165 @@ function applyFiltersAndSort() {
|
|||
displayDownloadsResults(processedResults);
|
||||
}
|
||||
|
||||
function calculateRelevanceScore(result, query) {
|
||||
let score = 0.0;
|
||||
const queryTerms = query.split(' ').filter(t => t.length > 1);
|
||||
|
||||
// 1. Search Term Matching (40%)
|
||||
let searchableText = `${result.title || ''} ${result.artist || ''} ${result.album || ''} ${result.album_title || ''}`.toLowerCase();
|
||||
let termMatches = 0;
|
||||
for (const term of queryTerms) {
|
||||
if (searchableText.includes(term)) {
|
||||
termMatches++;
|
||||
}
|
||||
}
|
||||
score += (termMatches / queryTerms.length) * 0.40;
|
||||
|
||||
// 2. Quality Score (25%)
|
||||
score += (result.quality_score || 0) * 0.25;
|
||||
|
||||
// 3. User Reliability (Availability & Speed) (20%)
|
||||
const reliability = ((result.free_upload_slots || 0) > 0 ? 0.5 : 0) + Math.min(1, (result.upload_speed || 0) / 500) * 0.5;
|
||||
score += reliability * 0.20;
|
||||
|
||||
// 4. File Completeness (Bitrate & Duration) (15%)
|
||||
const completeness = (Math.min(1, (result.bitrate || 0) / 320) * 0.5) + (result.duration > 0 ? 0.5 : 0);
|
||||
score += completeness * 0.15;
|
||||
|
||||
return score;
|
||||
}
|
||||
// APPEND THIS JAVASCRIPT SNIPPET (B)
|
||||
|
||||
function initializeFilters() {
|
||||
const toggleBtn = document.getElementById('filter-toggle-btn');
|
||||
const container = document.getElementById('filters-container');
|
||||
const content = document.getElementById('filter-content');
|
||||
|
||||
if (toggleBtn && container && content) {
|
||||
// Using .onclick ensures we only ever have one click handler
|
||||
toggleBtn.onclick = () => {
|
||||
const isExpanded = container.classList.contains('expanded');
|
||||
|
||||
if (isExpanded) {
|
||||
// Collapse the container
|
||||
container.classList.remove('expanded');
|
||||
toggleBtn.textContent = '⏷ Filters';
|
||||
} else {
|
||||
// Expand the container
|
||||
content.classList.remove('hidden'); // Make sure content is visible for animation
|
||||
container.classList.add('expanded');
|
||||
toggleBtn.textContent = '⏶ Filters';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// This part is correct and doesn't need to change
|
||||
document.querySelectorAll('.filter-btn').forEach(button => {
|
||||
button.addEventListener('click', handleFilterClick);
|
||||
});
|
||||
}
|
||||
|
||||
function handleFilterClick(event) {
|
||||
const button = event.target;
|
||||
const filterType = button.dataset.filterType;
|
||||
const value = button.dataset.value;
|
||||
|
||||
if (filterType === 'type') currentFilterType = value;
|
||||
if (filterType === 'format') currentFilterFormat = value;
|
||||
if (filterType === 'sort') currentSortBy = value;
|
||||
|
||||
if (button.id === 'sort-order-btn') {
|
||||
isSortReversed = !isSortReversed;
|
||||
button.textContent = isSortReversed ? '↑' : '↓';
|
||||
}
|
||||
|
||||
document.querySelectorAll(`.filter-btn[data-filter-type="${filterType}"]`).forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
if (filterType) { // Don't try to activate the sort order button
|
||||
button.classList.add('active');
|
||||
}
|
||||
|
||||
applyFiltersAndSort();
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
currentFilterType = 'all';
|
||||
currentFilterFormat = 'all';
|
||||
currentSortBy = 'quality_score';
|
||||
isSortReversed = false;
|
||||
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
|
||||
document.querySelector('.filter-btn[data-filter-type="type"][data-value="all"]').classList.add('active');
|
||||
document.querySelector('.filter-btn[data-filter-type="format"][data-value="all"]').classList.add('active');
|
||||
document.querySelector('.filter-btn[data-filter-type="sort"][data-value="quality_score"]').classList.add('active');
|
||||
document.getElementById('sort-order-btn').textContent = '↓';
|
||||
}
|
||||
|
||||
function applyFiltersAndSort() {
|
||||
let processedResults = [...allSearchResults];
|
||||
const query = document.getElementById('downloads-search-input').value.trim().toLowerCase();
|
||||
|
||||
// 1. Filter by Type
|
||||
if (currentFilterType !== 'all') {
|
||||
processedResults = processedResults.filter(r => r.result_type === currentFilterType);
|
||||
}
|
||||
|
||||
// 2. Filter by Format
|
||||
if (currentFilterFormat !== 'all') {
|
||||
processedResults = processedResults.filter(r => {
|
||||
const quality = (r.dominant_quality || r.quality || '').toLowerCase();
|
||||
return quality === currentFilterFormat;
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Sort Results
|
||||
processedResults.sort((a, b) => {
|
||||
let valA, valB;
|
||||
|
||||
// Special handling for relevance sort
|
||||
if (currentSortBy === 'relevance') {
|
||||
valA = calculateRelevanceScore(a, query);
|
||||
valB = calculateRelevanceScore(b, query);
|
||||
return valB - valA; // Higher score is better
|
||||
}
|
||||
|
||||
// Special handling for availability
|
||||
if (currentSortBy === 'availability') {
|
||||
valA = (a.free_upload_slots || 0) - (a.queue_length || 0) * 0.1;
|
||||
valB = (b.free_upload_slots || 0) - (b.queue_length || 0) * 0.1;
|
||||
return valB - valA;
|
||||
}
|
||||
|
||||
valA = a[currentSortBy] || 0;
|
||||
valB = b[currentSortBy] || 0;
|
||||
|
||||
if (typeof valA === 'string') {
|
||||
// For name/title sort, use the correct property
|
||||
const titleA = (a.album_title || a.title || '').toLowerCase();
|
||||
const titleB = (b.album_title || b.title || '').toLowerCase();
|
||||
return titleA.localeCompare(titleB);
|
||||
}
|
||||
|
||||
// Default numeric sort (descending)
|
||||
return valB - valA;
|
||||
});
|
||||
|
||||
// Handle sort direction toggle
|
||||
const sortDefaults = {
|
||||
relevance: 'desc', quality_score: 'desc', size: 'desc', bitrate: 'desc',
|
||||
upload_speed: 'desc', duration: 'desc', availability: 'desc',
|
||||
title: 'asc', username: 'asc'
|
||||
};
|
||||
|
||||
const defaultOrder = sortDefaults[currentSortBy] || 'desc';
|
||||
if ((defaultOrder === 'asc' && isSortReversed) || (defaultOrder === 'desc' && !isSortReversed)) {
|
||||
processedResults.reverse();
|
||||
}
|
||||
|
||||
displayDownloadsResults(processedResults);
|
||||
}
|
||||
|
||||
function calculateRelevanceScore(result, query) {
|
||||
let score = 0.0;
|
||||
const queryTerms = query.split(' ').filter(t => t.length > 1);
|
||||
|
|
@ -4331,6 +4469,8 @@ async function confirmMatch() {
|
|||
}
|
||||
|
||||
|
||||
|
||||
|
||||
function matchedDownloadTrack(trackIndex) {
|
||||
const results = window.currentSearchResults;
|
||||
if (!results || !results[trackIndex]) {
|
||||
|
|
@ -4612,4 +4752,4 @@ async function handleDbUpdateButtonClick() {
|
|||
showToast('Error sending stop request.', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4827,6 +4827,10 @@ body {
|
|||
width: 80px;
|
||||
}
|
||||
|
||||
.track-result-card .track-actions{
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.cancel-track-btn {
|
||||
background: #f44336;
|
||||
color: #ffffff;
|
||||
|
|
|
|||
Loading…
Reference in a new issue