Download Queue & Progress UI (#123)

* Add download queue tracking and UI integration

Introduces backend support for a download queue with item tracking, status updates, and session statistics. Adds frontend components and hooks for displaying and managing the download queue, including a dialog and toast indicator. Updates download logic to pre-add items to the queue, track progress, and handle completion, skipping, and failure states. Integrates @radix-ui/react-scroll-area for improved UI scrolling.

* Add session stats to DownloadQueue dialog

Introduces session statistics (downloaded amount, speed, and duration) to the DownloadQueue dialog for improved user feedback. Also adjusts dialog sizing for better display and removes the sm:max-w-lg restriction in dialog.tsx for more flexible width.
This commit is contained in:
Lukas
2025-11-29 11:36:58 +01:00
committed by GitHub
parent 0c92385c56
commit 2653586eea
14 changed files with 1175 additions and 30 deletions
+288 -6
View File
@@ -60,6 +60,10 @@ export function useDownload() {
}
}
// Always add item to queue before downloading
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
const itemID = await AddToDownloadQueue(isrc, trackName || "", artistName || "", albumName || "");
if (service === "auto") {
// Get all streaming URLs once from song.link API
let streamingURLs: any = null;
@@ -95,6 +99,7 @@ export function useDownload() {
spotify_id: spotifyId,
service_url: streamingURLs.tidal_url,
duration: durationSeconds,
item_id: itemID, // Pass the same itemID through all attempts
});
if (tidalResponse.success) {
@@ -125,6 +130,7 @@ export function useDownload() {
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
service_url: streamingURLs.deezer_url,
item_id: itemID,
});
if (deezerResponse.success) {
@@ -155,6 +161,7 @@ export function useDownload() {
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
service_url: streamingURLs.amazon_url,
item_id: itemID,
});
if (amazonResponse.success) {
@@ -169,13 +176,37 @@ export function useDownload() {
// Try Qobuz as last fallback
logger.debug(`trying qobuz (fallback) for: ${trackName} - ${artistName}`);
service = "qobuz";
const qobuzResponse = await downloadTrack({
isrc,
service: "qobuz",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
duration: durationMs ? Math.round(durationMs / 1000) : undefined,
item_id: itemID,
});
// If Qobuz also failed, mark the item as failed
if (!qobuzResponse.success) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, qobuzResponse.error || "All services failed");
}
return qobuzResponse;
}
// Convert duration from ms to seconds for backend (if not already done above)
// Single service download (not auto-fallback)
// Convert duration from ms to seconds for backend
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
return await downloadTrack({
const singleServiceResponse = await downloadTrack({
isrc,
service: service as "deezer" | "tidal" | "qobuz" | "amazon",
query,
@@ -189,7 +220,213 @@ export function useDownload() {
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
duration: durationSecondsForFallback,
item_id: itemID, // Pass itemID for tracking
});
// Mark as failed if download failed for single-service attempt
if (!singleServiceResponse.success) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, singleServiceResponse.error || "Download failed");
}
return singleServiceResponse;
};
const downloadWithItemID = async (
isrc: string,
settings: any,
itemID: string,
trackName?: string,
artistName?: string,
albumName?: string,
playlistName?: string,
isArtistDiscography?: boolean,
position?: number,
spotifyId?: string,
durationMs?: number
) => {
let service = settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false;
if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
if (isArtistDiscography) {
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
useAlbumTrackNumber = true;
}
} else {
if (settings.artistSubfolder && artistName) {
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
}
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
useAlbumTrackNumber = true;
}
}
}
if (service === "auto") {
// Get all streaming URLs once from song.link API
let streamingURLs: any = null;
if (spotifyId) {
try {
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
const urlsJson = await GetStreamingURLs(spotifyId);
streamingURLs = JSON.parse(urlsJson);
} catch (err) {
console.error("Failed to get streaming URLs:", err);
}
}
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
// Try Tidal first
if (streamingURLs?.tidal_url) {
try {
const tidalResponse = await downloadTrack({
isrc,
service: "tidal",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
service_url: streamingURLs.tidal_url,
duration: durationSeconds,
item_id: itemID,
});
if (tidalResponse.success) {
return tidalResponse;
}
} catch (tidalErr) {
console.error("Tidal error:", tidalErr);
}
}
// Try Deezer second
if (streamingURLs?.deezer_url) {
try {
const deezerResponse = await downloadTrack({
isrc,
service: "deezer",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
service_url: streamingURLs.deezer_url,
item_id: itemID,
});
if (deezerResponse.success) {
return deezerResponse;
}
} catch (deezerErr) {
console.error("Deezer error:", deezerErr);
}
}
// Try Amazon third
if (streamingURLs?.amazon_url) {
try {
const amazonResponse = await downloadTrack({
isrc,
service: "amazon",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
service_url: streamingURLs.amazon_url,
item_id: itemID,
});
if (amazonResponse.success) {
return amazonResponse;
}
} catch (amazonErr) {
console.error("Amazon error:", amazonErr);
}
}
// Try Qobuz as last fallback
const qobuzResponse = await downloadTrack({
isrc,
service: "qobuz",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
duration: durationMs ? Math.round(durationMs / 1000) : undefined,
item_id: itemID,
});
// If Qobuz also failed, mark the item as failed
if (!qobuzResponse.success) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, qobuzResponse.error || "All services failed");
}
return qobuzResponse;
}
// Single service download
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
const singleServiceResponse = await downloadTrack({
isrc,
service: service as "deezer" | "tidal" | "qobuz" | "amazon",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
duration: durationSecondsForFallback,
item_id: itemID,
});
// Mark as failed if download failed for single-service attempt
if (!singleServiceResponse.success) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, singleServiceResponse.error || "Download failed");
}
return singleServiceResponse;
};
const handleDownloadTrack = async (
@@ -268,6 +505,20 @@ export function useDownload() {
setBulkDownloadType("selected");
setDownloadProgress(0);
// Pre-add ALL tracks to the queue before starting downloads
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
const itemIDs: string[] = [];
for (const isrc of selectedTracks) {
const track = allTracks.find((t) => t.isrc === isrc);
const itemID = await AddToDownloadQueue(
isrc,
track?.name || "",
track?.artists || "",
track?.album_name || ""
);
itemIDs.push(itemID);
}
let successCount = 0;
let errorCount = 0;
let skippedCount = 0;
@@ -283,6 +534,7 @@ export function useDownload() {
const isrc = selectedTracks[i];
const track = allTracks.find((t) => t.isrc === isrc);
const itemID = itemIDs[i];
setDownloadingTrack(isrc);
@@ -291,10 +543,11 @@ export function useDownload() {
}
try {
// Use sequential numbering (1, 2, 3...) for selected tracks
const response = await downloadWithAutoFallback(
// Download with pre-created itemID
const response = await downloadWithItemID(
isrc,
settings,
itemID,
track?.name,
track?.artists,
track?.album_name,
@@ -329,6 +582,9 @@ export function useDownload() {
errorCount++;
logger.error(`error: ${track?.name} - ${err}`);
setFailedTracks((prev) => new Set(prev).add(isrc));
// Mark item as failed in queue
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
}
setDownloadProgress(Math.round(((i + 1) / total) * 100));
@@ -340,6 +596,10 @@ export function useDownload() {
setBulkDownloadType(null);
shouldStopDownloadRef.current = false;
// Cancel any remaining queued items
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
await CancelAllQueuedItems();
// Build summary message
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
if (errorCount === 0 && skippedCount === 0) {
@@ -378,6 +638,19 @@ export function useDownload() {
setBulkDownloadType("all");
setDownloadProgress(0);
// Pre-add ALL tracks to the queue before starting downloads
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
const itemIDs: string[] = [];
for (const track of tracksWithIsrc) {
const itemID = await AddToDownloadQueue(
track.isrc,
track.name,
track.artists,
track.album_name || ""
);
itemIDs.push(itemID);
}
let successCount = 0;
let errorCount = 0;
let skippedCount = 0;
@@ -392,14 +665,16 @@ export function useDownload() {
}
const track = tracksWithIsrc[i];
const itemID = itemIDs[i];
setDownloadingTrack(track.isrc);
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
try {
const response = await downloadWithAutoFallback(
const response = await downloadWithItemID(
track.isrc,
settings,
itemID,
track.name,
track.artists,
track.album_name,
@@ -434,6 +709,9 @@ export function useDownload() {
errorCount++;
logger.error(`error: ${track.name} - ${err}`);
setFailedTracks((prev) => new Set(prev).add(track.isrc));
// Mark item as failed in queue
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
}
setDownloadProgress(Math.round(((i + 1) / total) * 100));
@@ -445,6 +723,10 @@ export function useDownload() {
setBulkDownloadType(null);
shouldStopDownloadRef.current = false;
// Cancel any remaining queued items
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
await CancelQueued();
// Build summary message
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
if (errorCount === 0 && skippedCount === 0) {