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:
+16
-4
@@ -24,6 +24,7 @@ import { TrackInfo } from "@/components/TrackInfo";
|
||||
import { AlbumInfo } from "@/components/AlbumInfo";
|
||||
import { PlaylistInfo } from "@/components/PlaylistInfo";
|
||||
import { ArtistInfo } from "@/components/ArtistInfo";
|
||||
import { DownloadQueue } from "@/components/DownloadQueue";
|
||||
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
|
||||
import type { HistoryItem } from "@/components/FetchHistory";
|
||||
|
||||
@@ -32,6 +33,7 @@ import { useDownload } from "@/hooks/useDownload";
|
||||
import { useMetadata } from "@/hooks/useMetadata";
|
||||
import { useLyrics } from "@/hooks/useLyrics";
|
||||
import { useAvailability } from "@/hooks/useAvailability";
|
||||
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
||||
|
||||
const HISTORY_KEY = "spotiflac_fetch_history";
|
||||
const MAX_HISTORY = 5;
|
||||
@@ -52,6 +54,7 @@ function App() {
|
||||
const metadata = useMetadata();
|
||||
const lyrics = useLyrics();
|
||||
const availability = useAvailability();
|
||||
const downloadQueue = useDownloadQueueDialog();
|
||||
|
||||
useEffect(() => {
|
||||
const settings = getSettings();
|
||||
@@ -456,10 +459,19 @@ function App() {
|
||||
<TitleBar />
|
||||
<div className="flex-1 p-4 md:p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} />
|
||||
|
||||
{/* Download Progress Toast */}
|
||||
<DownloadProgressToast />
|
||||
<Header
|
||||
version={CURRENT_VERSION}
|
||||
hasUpdate={hasUpdate}
|
||||
/>
|
||||
|
||||
{/* Download Progress Toast - Bottom Left */}
|
||||
<DownloadProgressToast onClick={downloadQueue.openQueue} />
|
||||
|
||||
{/* Download Queue Dialog */}
|
||||
<DownloadQueue
|
||||
isOpen={downloadQueue.isOpen}
|
||||
onClose={downloadQueue.closeQueue}
|
||||
/>
|
||||
|
||||
{/* Timeout Dialog */}
|
||||
<Dialog
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||
import { Download } from "lucide-react";
|
||||
import { useDownloadQueueData } from "@/hooks/useDownloadQueueData";
|
||||
import { Download, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function DownloadProgressToast() {
|
||||
interface DownloadProgressToastProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) {
|
||||
const progress = useDownloadProgress();
|
||||
const queueInfo = useDownloadQueueData();
|
||||
|
||||
if (!progress.is_downloading) {
|
||||
// Show indicator if there are any queued or downloading items
|
||||
// Don't show for completed/failed/skipped only
|
||||
const hasActiveDownloads = queueInfo.queue.some(
|
||||
item => item.status === "queued" || item.status === "downloading"
|
||||
);
|
||||
|
||||
if (!hasActiveDownloads) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
|
||||
<div className="bg-background border rounded-lg shadow-lg p-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background border rounded-lg shadow-lg p-3 h-auto hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Download className="h-4 w-4 text-primary animate-bounce" />
|
||||
<Download className={`h-4 w-4 text-primary ${progress.is_downloading ? 'animate-bounce' : ''}`} />
|
||||
<div className="flex flex-col min-w-[80px]">
|
||||
<p className="text-sm font-medium font-mono tabular-nums">
|
||||
{progress.mb_downloaded.toFixed(2)} MB
|
||||
@@ -23,8 +40,9 @@ export function DownloadProgressToast() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground ml-1" />
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { GetDownloadQueue, ClearCompletedDownloads } from "../../wailsjs/go/main/App";
|
||||
import { backend } from "../../wailsjs/go/models";
|
||||
|
||||
interface DownloadQueueProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
||||
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(
|
||||
new backend.DownloadQueueInfo({
|
||||
is_downloading: false,
|
||||
queue: [],
|
||||
current_speed: 0,
|
||||
total_downloaded: 0,
|
||||
session_start_time: 0,
|
||||
queued_count: 0,
|
||||
completed_count: 0,
|
||||
failed_count: 0,
|
||||
skipped_count: 0,
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const fetchQueue = async () => {
|
||||
try {
|
||||
const info = await GetDownloadQueue();
|
||||
setQueueInfo(info);
|
||||
} catch (error) {
|
||||
console.error("Failed to get download queue:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetch
|
||||
fetchQueue();
|
||||
|
||||
// Poll every 500ms when dialog is open
|
||||
const interval = setInterval(fetchQueue, 500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleClearHistory = async () => {
|
||||
try {
|
||||
await ClearCompletedDownloads();
|
||||
// Refetch immediately to update UI
|
||||
const info = await GetDownloadQueue();
|
||||
setQueueInfo(info);
|
||||
} catch (error) {
|
||||
console.error("Failed to clear history:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "downloading":
|
||||
return <Download className="h-4 w-4 text-blue-500 animate-bounce" />;
|
||||
case "completed":
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||
case "failed":
|
||||
return <XCircle className="h-4 w-4 text-red-500" />;
|
||||
case "skipped":
|
||||
return <FileCheck className="h-4 w-4 text-yellow-500" />;
|
||||
case "queued":
|
||||
return <Clock className="h-4 w-4 text-muted-foreground" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
downloading: "default",
|
||||
completed: "outline",
|
||||
failed: "destructive",
|
||||
skipped: "secondary",
|
||||
queued: "outline",
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] || "outline"} className="text-xs">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
// Format session duration
|
||||
const formatDuration = (startTimestamp: number) => {
|
||||
if (startTimestamp === 0) return "—";
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const durationSeconds = now - startTimestamp;
|
||||
|
||||
const hours = Math.floor(durationSeconds / 3600);
|
||||
const minutes = Math.floor((durationSeconds % 3600) / 60);
|
||||
const seconds = durationSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${seconds}s`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
|
||||
<div className="flex items-center justify-between mb-4 pr-8">
|
||||
<DialogTitle className="text-lg font-semibold">Download Queue</DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1.5"
|
||||
onClick={handleClearHistory}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Clear History
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-full hover:bg-muted"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Queue Status */}
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Queued:</span>
|
||||
<span className="font-semibold">{queueInfo.queued_count}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||||
<span className="text-muted-foreground">Completed:</span>
|
||||
<span className="font-semibold">{queueInfo.completed_count}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileCheck className="h-3.5 w-3.5 text-yellow-500" />
|
||||
<span className="text-muted-foreground">Skipped:</span>
|
||||
<span className="font-semibold">{queueInfo.skipped_count}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<XCircle className="h-3.5 w-3.5 text-red-500" />
|
||||
<span className="text-muted-foreground">Failed:</span>
|
||||
<span className="font-semibold">{queueInfo.failed_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Stats */}
|
||||
<div className="flex items-center gap-4 text-sm pt-3 mt-3 border-t">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Downloaded:</span>
|
||||
<span className="font-semibold font-mono">
|
||||
{queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Zap className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Speed:</span>
|
||||
<span className="font-semibold font-mono">
|
||||
{queueInfo.current_speed > 0 && queueInfo.is_downloading
|
||||
? `${queueInfo.current_speed.toFixed(2)} MB/s`
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Timer className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Duration:</span>
|
||||
<span className="font-semibold font-mono">
|
||||
{queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</DialogHeader>
|
||||
|
||||
{/* Download Queue List */}
|
||||
<div className="flex-1 overflow-y-auto px-6 custom-scrollbar">
|
||||
<div className="space-y-2 py-4">
|
||||
{queueInfo.queue.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Download className="h-12 w-12 mx-auto mb-3 opacity-20" />
|
||||
<p>No downloads in queue</p>
|
||||
</div>
|
||||
) : (
|
||||
queueInfo.queue.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="border rounded-lg p-3 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1">{getStatusIcon(item.status)}</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{item.track_name}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{item.artist_name}
|
||||
{item.album_name && ` • ${item.album_name}`}
|
||||
</p>
|
||||
</div>
|
||||
{getStatusBadge(item.status)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar for downloading items */}
|
||||
{item.status === "downloading" && (
|
||||
<div className="space-y-1.5 mt-2">
|
||||
<div className="flex items-center justify-between text-xs font-mono">
|
||||
<span>
|
||||
{item.progress > 0
|
||||
? `${item.progress.toFixed(2)} MB`
|
||||
: queueInfo.is_downloading && queueInfo.current_speed > 0
|
||||
? "Downloading..."
|
||||
: "Starting..."}
|
||||
</span>
|
||||
<span>
|
||||
{item.speed > 0
|
||||
? `${item.speed.toFixed(2)} MB/s`
|
||||
: queueInfo.current_speed > 0
|
||||
? `${queueInfo.current_speed.toFixed(2)} MB/s`
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={100} className="h-1.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completed info */}
|
||||
{item.status === "completed" && (
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{item.progress.toFixed(2)} MB</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skipped info */}
|
||||
{item.status === "skipped" && (
|
||||
<div className="mt-1.5 text-xs text-muted-foreground">
|
||||
File already exists
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{item.status === "failed" && item.error_message && (
|
||||
<div className="mt-1.5 text-xs text-red-500 bg-red-50 dark:bg-red-950/20 rounded px-2 py-1">
|
||||
{item.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File path for completed/skipped */}
|
||||
{(item.status === "completed" || item.status === "skipped") && item.file_path && (
|
||||
<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono">
|
||||
{item.file_path}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -60,7 +60,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { GetDownloadQueue } from "../../wailsjs/go/main/App";
|
||||
import { backend } from "../../wailsjs/go/models";
|
||||
|
||||
export function useDownloadQueueData() {
|
||||
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(
|
||||
new backend.DownloadQueueInfo({
|
||||
is_downloading: false,
|
||||
queue: [],
|
||||
current_speed: 0,
|
||||
total_downloaded: 0,
|
||||
session_start_time: 0,
|
||||
queued_count: 0,
|
||||
completed_count: 0,
|
||||
failed_count: 0,
|
||||
skipped_count: 0,
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchQueue = async () => {
|
||||
try {
|
||||
const info = await GetDownloadQueue();
|
||||
setQueueInfo(info);
|
||||
} catch (error) {
|
||||
console.error("Failed to get download queue:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetch
|
||||
fetchQueue();
|
||||
|
||||
// Poll every 200ms
|
||||
const interval = setInterval(fetchQueue, 200);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return queueInfo;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export function useDownloadQueueDialog() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const openQueue = () => setIsOpen(true);
|
||||
const closeQueue = () => setIsOpen(false);
|
||||
const toggleQueue = () => setIsOpen((prev) => !prev);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
openQueue,
|
||||
closeQueue,
|
||||
toggleQueue,
|
||||
};
|
||||
}
|
||||
@@ -125,6 +125,7 @@ export interface DownloadRequest {
|
||||
spotify_id?: string;
|
||||
service_url?: string;
|
||||
duration?: number; // Track duration in seconds for better matching
|
||||
item_id?: string; // Optional queue item ID for multi-service fallback tracking
|
||||
}
|
||||
|
||||
export interface DownloadResponse {
|
||||
@@ -133,6 +134,7 @@ export interface DownloadResponse {
|
||||
file?: string;
|
||||
error?: string;
|
||||
already_exists?: boolean;
|
||||
item_id?: string; // Queue item ID for tracking
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
|
||||
Reference in New Issue
Block a user