diff --git a/app.go b/app.go
index 58c0fc9..7b139ce 100644
--- a/app.go
+++ b/app.go
@@ -53,6 +53,7 @@ type DownloadRequest struct {
SpotifyID string `json:"spotify_id,omitempty"` // Spotify track ID
ServiceURL string `json:"service_url,omitempty"` // Direct service URL (Tidal/Deezer/Amazon) to skip song.link API call
Duration int `json:"duration,omitempty"` // Track duration in seconds for better matching
+ ItemID string `json:"item_id,omitempty"` // Optional queue item ID for multi-service fallback tracking
}
// DownloadResponse represents the response structure for download operations
@@ -62,6 +63,7 @@ type DownloadResponse struct {
File string `json:"file,omitempty"`
Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"`
+ ItemID string `json:"item_id,omitempty"` // Queue item ID for tracking
}
// GetStreamingURLs fetches all streaming URLs from song.link API
@@ -143,14 +145,30 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
req.FilenameFormat = "title-artist"
}
+ // ItemID should always be provided by frontend (created via AddToDownloadQueue)
+ // If not provided, generate one for backwards compatibility
+ itemID := req.ItemID
+ if itemID == "" {
+ itemID = fmt.Sprintf("%s-%d", req.ISRC, time.Now().UnixNano())
+ // Add to queue if no ItemID was provided (legacy support)
+ backend.AddToQueue(itemID, req.TrackName, req.ArtistName, req.AlbumName, req.ISRC)
+ }
+
+ // Mark item as downloading immediately
+ backend.SetDownloading(true)
+ backend.StartDownloadItem(itemID)
+ defer backend.SetDownloading(false)
+
// Early check: Check if file with same ISRC already exists
if existingFile, exists := backend.CheckISRCExists(req.OutputDir, req.ISRC); exists {
fmt.Printf("File with ISRC %s already exists: %s\n", req.ISRC, existingFile)
+ backend.SkipDownloadItem(itemID, existingFile)
return DownloadResponse{
Success: true,
Message: "File with same ISRC already exists",
File: existingFile,
AlreadyExists: true,
+ ItemID: itemID,
}, nil
}
@@ -159,20 +177,28 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.FilenameFormat, req.TrackNumber, req.Position, req.UseAlbumTrackNumber)
expectedPath := filepath.Join(req.OutputDir, expectedFilename)
- if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
- return DownloadResponse{
- Success: true,
- Message: "File already exists",
- File: expectedPath,
- AlreadyExists: true,
- }, nil
+ if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
+ // Validate the file by checking if it has valid ISRC metadata
+ if fileISRC, readErr := backend.ReadISRCFromFile(expectedPath); readErr == nil && fileISRC != "" {
+ // File exists and has valid metadata - skip download
+ backend.SkipDownloadItem(itemID, expectedPath)
+ return DownloadResponse{
+ Success: true,
+ Message: "File already exists",
+ File: expectedPath,
+ AlreadyExists: true,
+ ItemID: itemID,
+ }, nil
+ } else {
+ // File exists but has no valid ISRC metadata - it's corrupted, delete it
+ fmt.Printf("Removing corrupted file (no valid ISRC metadata): %s\n", expectedPath)
+ if removeErr := os.Remove(expectedPath); removeErr != nil {
+ fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", expectedPath, removeErr)
+ }
+ }
}
}
- // Set downloading state
- backend.SetDownloading(true)
- defer backend.SetDownloading(false)
-
switch req.Service {
case "amazon":
downloader := backend.NewAmazonDownloader()
@@ -243,9 +269,23 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
}
if err != nil {
+ // Clean up any partial/corrupted file that was created during failed download
+ if filename != "" && !strings.HasPrefix(filename, "EXISTS:") {
+ // Check if file exists and delete it
+ if _, statErr := os.Stat(filename); statErr == nil {
+ fmt.Printf("Removing corrupted/partial file after failed download: %s\n", filename)
+ if removeErr := os.Remove(filename); removeErr != nil {
+ fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", filename, removeErr)
+ }
+ }
+ }
+
+ // Don't mark as failed in backend - let the frontend handle it
+ // Frontend will call MarkDownloadItemFailed after all services are tried
return DownloadResponse{
Success: false,
Error: fmt.Sprintf("Download failed: %v", err),
+ ItemID: itemID,
}, err
}
@@ -259,6 +299,16 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
message := "Download completed successfully"
if alreadyExists {
message = "File already exists"
+ backend.SkipDownloadItem(itemID, filename)
+ } else {
+ // Get file size for completed download
+ if fileInfo, statErr := os.Stat(filename); statErr == nil {
+ finalSize := float64(fileInfo.Size()) / (1024 * 1024) // Convert to MB
+ backend.CompleteDownloadItem(itemID, filename, finalSize)
+ } else {
+ // Fallback: mark as completed without size
+ backend.CompleteDownloadItem(itemID, filename, 0)
+ }
}
return DownloadResponse{
@@ -266,6 +316,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
Message: message,
File: filename,
AlreadyExists: alreadyExists,
+ ItemID: itemID,
}, nil
}
@@ -305,6 +356,38 @@ func (a *App) GetDownloadProgress() backend.ProgressInfo {
return backend.GetDownloadProgress()
}
+// GetDownloadQueue returns the complete download queue state
+func (a *App) GetDownloadQueue() backend.DownloadQueueInfo {
+ return backend.GetDownloadQueue()
+}
+
+// ClearCompletedDownloads clears completed, failed, and skipped items from the queue
+func (a *App) ClearCompletedDownloads() {
+ backend.ClearDownloadQueue()
+}
+
+// ClearAllDownloads clears the entire queue and resets session stats
+func (a *App) ClearAllDownloads() {
+ backend.ClearAllDownloads()
+}
+
+// AddToDownloadQueue adds a new item to the download queue and returns its ID
+func (a *App) AddToDownloadQueue(isrc, trackName, artistName, albumName string) string {
+ itemID := fmt.Sprintf("%s-%d", isrc, time.Now().UnixNano())
+ backend.AddToQueue(itemID, trackName, artistName, albumName, isrc)
+ return itemID
+}
+
+// MarkDownloadItemFailed marks a download item as failed
+func (a *App) MarkDownloadItemFailed(itemID, errorMsg string) {
+ backend.FailDownloadItem(itemID, errorMsg)
+}
+
+// CancelAllQueuedItems marks all queued items as cancelled/skipped
+func (a *App) CancelAllQueuedItems() {
+ backend.CancelAllQueuedItems()
+}
+
// Quit closes the application
func (a *App) Quit() {
// You can add cleanup logic here if needed
diff --git a/backend/metadata.go b/backend/metadata.go
index f143338..00ce5a4 100644
--- a/backend/metadata.go
+++ b/backend/metadata.go
@@ -168,9 +168,14 @@ func CheckISRCExists(outputDir string, targetISRC string) (string, bool) {
filepath := fmt.Sprintf("%s/%s", outputDir, filename)
- // Read ISRC from file
+ // Read ISRC from file (this will fail for corrupted files)
isrc, err := ReadISRCFromFile(filepath)
if err != nil {
+ // File is corrupted or unreadable, delete it
+ fmt.Printf("Removing corrupted/unreadable file: %s (error: %v)\n", filepath, err)
+ if removeErr := os.Remove(filepath); removeErr != nil {
+ fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", filepath, removeErr)
+ }
continue
}
diff --git a/backend/progress.go b/backend/progress.go
index e9f8598..3915197 100644
--- a/backend/progress.go
+++ b/backend/progress.go
@@ -7,6 +7,34 @@ import (
"time"
)
+// DownloadStatus represents the status of a download item
+type DownloadStatus string
+
+const (
+ StatusQueued DownloadStatus = "queued"
+ StatusDownloading DownloadStatus = "downloading"
+ StatusCompleted DownloadStatus = "completed"
+ StatusFailed DownloadStatus = "failed"
+ StatusSkipped DownloadStatus = "skipped"
+)
+
+// DownloadItem represents a single item in the download queue
+type DownloadItem struct {
+ ID string `json:"id"`
+ TrackName string `json:"track_name"`
+ ArtistName string `json:"artist_name"`
+ AlbumName string `json:"album_name"`
+ ISRC string `json:"isrc"`
+ Status DownloadStatus `json:"status"`
+ Progress float64 `json:"progress"` // MB downloaded
+ TotalSize float64 `json:"total_size"` // MB total (if known)
+ Speed float64 `json:"speed"` // MB/s
+ StartTime int64 `json:"start_time"` // Unix timestamp
+ EndTime int64 `json:"end_time"` // Unix timestamp
+ ErrorMessage string `json:"error_message"` // If failed
+ FilePath string `json:"file_path"` // Final file path
+}
+
// Global progress tracker
var (
currentProgress float64
@@ -15,6 +43,16 @@ var (
downloadingLock sync.RWMutex
currentSpeed float64
speedLock sync.RWMutex
+
+ // Download queue tracking
+ downloadQueue []DownloadItem
+ downloadQueueLock sync.RWMutex
+ currentItemID string
+ currentItemLock sync.RWMutex
+ totalDownloaded float64
+ totalDownloadedLock sync.RWMutex
+ sessionStartTime int64
+ sessionStartLock sync.RWMutex
)
// ProgressInfo represents download progress information
@@ -24,6 +62,19 @@ type ProgressInfo struct {
SpeedMBps float64 `json:"speed_mbps"`
}
+// DownloadQueueInfo represents the complete download queue state
+type DownloadQueueInfo struct {
+ IsDownloading bool `json:"is_downloading"`
+ Queue []DownloadItem `json:"queue"`
+ CurrentSpeed float64 `json:"current_speed"` // MB/s
+ TotalDownloaded float64 `json:"total_downloaded"` // MB this session
+ SessionStartTime int64 `json:"session_start_time"` // Unix timestamp
+ QueuedCount int `json:"queued_count"`
+ CompletedCount int `json:"completed_count"`
+ FailedCount int `json:"failed_count"`
+ SkippedCount int `json:"skipped_count"`
+}
+
// GetDownloadProgress returns current download progress
func GetDownloadProgress() ProgressInfo {
downloadingLock.RLock()
@@ -80,6 +131,7 @@ type ProgressWriter struct {
startTime int64
lastTime int64
lastBytes int64
+ itemID string // Track which download item this belongs to
}
func NewProgressWriter(writer io.Writer) *ProgressWriter {
@@ -91,9 +143,17 @@ func NewProgressWriter(writer io.Writer) *ProgressWriter {
startTime: now,
lastTime: now,
lastBytes: 0,
+ itemID: "",
}
}
+// NewProgressWriterWithID creates a progress writer with an item ID for queue tracking
+func NewProgressWriterWithID(writer io.Writer, itemID string) *ProgressWriter {
+ pw := NewProgressWriter(writer)
+ pw.itemID = itemID
+ return pw
+}
+
func getCurrentTimeMillis() int64 {
return time.Now().UnixMilli()
}
@@ -111,8 +171,9 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
timeDiff := float64(now-pw.lastTime) / 1000.0 // seconds
bytesDiff := float64(pw.total - pw.lastBytes)
+ var speedMBps float64
if timeDiff > 0 {
- speedMBps := (bytesDiff / (1024 * 1024)) / timeDiff
+ speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
SetDownloadSpeed(speedMBps)
fmt.Printf("\rDownloaded: %.2f MB (%.2f MB/s)", mbDownloaded, speedMBps)
} else {
@@ -122,6 +183,11 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
// Update global progress
SetDownloadProgress(mbDownloaded)
+ // Update individual item progress if we have an item ID
+ if pw.itemID != "" {
+ UpdateItemProgress(pw.itemID, mbDownloaded, speedMBps)
+ }
+
pw.lastPrinted = pw.total
pw.lastTime = now
pw.lastBytes = pw.total
@@ -133,3 +199,254 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
func (pw *ProgressWriter) GetTotal() int64 {
return pw.total
}
+
+// Queue management functions
+
+// AddToQueue adds a new item to the download queue
+func AddToQueue(id, trackName, artistName, albumName, isrc string) {
+ downloadQueueLock.Lock()
+ defer downloadQueueLock.Unlock()
+
+ item := DownloadItem{
+ ID: id,
+ TrackName: trackName,
+ ArtistName: artistName,
+ AlbumName: albumName,
+ ISRC: isrc,
+ Status: StatusQueued,
+ Progress: 0,
+ TotalSize: 0,
+ Speed: 0,
+ StartTime: 0,
+ EndTime: 0,
+ }
+
+ downloadQueue = append(downloadQueue, item)
+
+ // Initialize session start time if this is the first item
+ sessionStartLock.Lock()
+ if sessionStartTime == 0 {
+ sessionStartTime = time.Now().Unix()
+ }
+ sessionStartLock.Unlock()
+}
+
+// StartDownloadItem marks an item as currently downloading
+func StartDownloadItem(id string) {
+ downloadQueueLock.Lock()
+ defer downloadQueueLock.Unlock()
+
+ for i := range downloadQueue {
+ if downloadQueue[i].ID == id {
+ downloadQueue[i].Status = StatusDownloading
+ downloadQueue[i].StartTime = time.Now().Unix()
+ downloadQueue[i].Progress = 0
+ break
+ }
+ }
+
+ currentItemLock.Lock()
+ currentItemID = id
+ currentItemLock.Unlock()
+}
+
+// UpdateItemProgress updates the progress of the current download item
+func UpdateItemProgress(id string, progress, speed float64) {
+ downloadQueueLock.Lock()
+ defer downloadQueueLock.Unlock()
+
+ for i := range downloadQueue {
+ if downloadQueue[i].ID == id {
+ downloadQueue[i].Progress = progress
+ downloadQueue[i].Speed = speed
+ break
+ }
+ }
+}
+
+// CompleteDownloadItem marks an item as completed
+func CompleteDownloadItem(id, filePath string, finalSize float64) {
+ downloadQueueLock.Lock()
+ defer downloadQueueLock.Unlock()
+
+ for i := range downloadQueue {
+ if downloadQueue[i].ID == id {
+ downloadQueue[i].Status = StatusCompleted
+ downloadQueue[i].EndTime = time.Now().Unix()
+ downloadQueue[i].FilePath = filePath
+ downloadQueue[i].Progress = finalSize
+ downloadQueue[i].TotalSize = finalSize
+
+ // Add to total downloaded
+ totalDownloadedLock.Lock()
+ totalDownloaded += finalSize
+ totalDownloadedLock.Unlock()
+ break
+ }
+ }
+}
+
+// FailDownloadItem marks an item as failed
+func FailDownloadItem(id, errorMsg string) {
+ downloadQueueLock.Lock()
+ defer downloadQueueLock.Unlock()
+
+ for i := range downloadQueue {
+ if downloadQueue[i].ID == id {
+ downloadQueue[i].Status = StatusFailed
+ downloadQueue[i].EndTime = time.Now().Unix()
+ downloadQueue[i].ErrorMessage = errorMsg
+ break
+ }
+ }
+}
+
+// SkipDownloadItem marks an item as skipped (already exists)
+func SkipDownloadItem(id, filePath string) {
+ downloadQueueLock.Lock()
+ defer downloadQueueLock.Unlock()
+
+ for i := range downloadQueue {
+ if downloadQueue[i].ID == id {
+ downloadQueue[i].Status = StatusSkipped
+ downloadQueue[i].EndTime = time.Now().Unix()
+ downloadQueue[i].FilePath = filePath
+ break
+ }
+ }
+}
+
+// GetDownloadQueue returns the complete download queue state
+func GetDownloadQueue() DownloadQueueInfo {
+ // Auto-reset session if all downloads are complete
+ ResetSessionIfComplete()
+
+ downloadQueueLock.RLock()
+ defer downloadQueueLock.RUnlock()
+
+ downloadingLock.RLock()
+ downloading := isDownloading
+ downloadingLock.RUnlock()
+
+ speedLock.RLock()
+ speed := currentSpeed
+ speedLock.RUnlock()
+
+ totalDownloadedLock.RLock()
+ total := totalDownloaded
+ totalDownloadedLock.RUnlock()
+
+ sessionStartLock.RLock()
+ sessionStart := sessionStartTime
+ sessionStartLock.RUnlock()
+
+ // Count statuses
+ var queued, completed, failed, skipped int
+ for _, item := range downloadQueue {
+ switch item.Status {
+ case StatusQueued:
+ queued++
+ case StatusCompleted:
+ completed++
+ case StatusFailed:
+ failed++
+ case StatusSkipped:
+ skipped++
+ }
+ }
+
+ // Create a copy of the queue
+ queueCopy := make([]DownloadItem, len(downloadQueue))
+ copy(queueCopy, downloadQueue)
+
+ return DownloadQueueInfo{
+ IsDownloading: downloading,
+ Queue: queueCopy,
+ CurrentSpeed: speed,
+ TotalDownloaded: total,
+ SessionStartTime: sessionStart,
+ QueuedCount: queued,
+ CompletedCount: completed,
+ FailedCount: failed,
+ SkippedCount: skipped,
+ }
+}
+
+// ClearDownloadQueue clears all completed, failed, and skipped items from the queue
+func ClearDownloadQueue() {
+ downloadQueueLock.Lock()
+ defer downloadQueueLock.Unlock()
+
+ // Keep only queued and downloading items
+ newQueue := make([]DownloadItem, 0)
+ for _, item := range downloadQueue {
+ if item.Status == StatusQueued || item.Status == StatusDownloading {
+ newQueue = append(newQueue, item)
+ }
+ }
+ downloadQueue = newQueue
+}
+
+// ClearAllDownloads clears the entire queue and resets session stats
+func ClearAllDownloads() {
+ downloadQueueLock.Lock()
+ downloadQueue = []DownloadItem{}
+ downloadQueueLock.Unlock()
+
+ totalDownloadedLock.Lock()
+ totalDownloaded = 0
+ totalDownloadedLock.Unlock()
+
+ sessionStartLock.Lock()
+ sessionStartTime = 0
+ sessionStartLock.Unlock()
+
+ currentItemLock.Lock()
+ currentItemID = ""
+ currentItemLock.Unlock()
+
+ // Reset current progress and speed
+ SetDownloadProgress(0)
+ SetDownloadSpeed(0)
+}
+
+// CancelAllQueuedItems marks all queued items as skipped (cancelled)
+// This is called when user stops a download or when batch download completes
+func CancelAllQueuedItems() {
+ downloadQueueLock.Lock()
+ defer downloadQueueLock.Unlock()
+
+ for i := range downloadQueue {
+ if downloadQueue[i].Status == StatusQueued {
+ downloadQueue[i].Status = StatusSkipped
+ downloadQueue[i].EndTime = time.Now().Unix()
+ downloadQueue[i].ErrorMessage = "Cancelled"
+ }
+ }
+}
+
+// ResetSessionIfComplete resets session stats if no active or queued downloads
+// Note: Does NOT clear the queue - items remain visible for history
+func ResetSessionIfComplete() {
+ downloadQueueLock.RLock()
+ hasActiveOrQueued := false
+ for _, item := range downloadQueue {
+ if item.Status == StatusQueued || item.Status == StatusDownloading {
+ hasActiveOrQueued = true
+ break
+ }
+ }
+ downloadQueueLock.RUnlock()
+
+ // If no active or queued items, reset session stats
+ // But keep the queue items for history visibility
+ if !hasActiveOrQueued {
+ sessionStartLock.Lock()
+ sessionStartTime = 0
+ sessionStartLock.Unlock()
+
+ totalDownloadedLock.Lock()
+ totalDownloaded = 0
+ totalDownloadedLock.Unlock()
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
index 11aa3c2..bd29d97 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -18,6 +18,7 @@
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
+ "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 7ef570e..8b18aa0 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -26,6 +26,9 @@ importers:
'@radix-ui/react-radio-group':
specifier: ^1.3.8
version: 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-scroll-area':
+ specifier: ^1.2.10
+ version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-select':
specifier: ^2.2.6
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -871,6 +874,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-scroll-area@1.2.10':
+ resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-select@2.2.6':
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies:
@@ -2735,6 +2751,23 @@ snapshots:
'@types/react': 19.2.6
'@types/react-dom': 19.2.3(@types/react@19.2.6)
+ '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/number': 1.1.1
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+ '@types/react-dom': 19.2.3(@types/react@19.2.6)
+
'@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/number': 1.1.1
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 2ce6cc6..2c8cf0c 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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() {