From 2653586eea606bcc49f8fd523d533986de02459c Mon Sep 17 00:00:00 2001 From: Lukas <69511007+Lukas200301@users.noreply.github.com> Date: Sat, 29 Nov 2025 11:36:58 +0100 Subject: [PATCH] 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. --- app.go | 105 +++++- backend/metadata.go | 7 +- backend/progress.go | 319 +++++++++++++++++- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 33 ++ frontend/src/App.tsx | 20 +- .../src/components/DownloadProgressToast.tsx | 30 +- frontend/src/components/DownloadQueue.tsx | 290 ++++++++++++++++ frontend/src/components/ui/dialog.tsx | 2 +- frontend/src/components/ui/scroll-area.tsx | 46 +++ frontend/src/hooks/useDownload.ts | 294 +++++++++++++++- frontend/src/hooks/useDownloadQueueData.ts | 40 +++ frontend/src/hooks/useDownloadQueueDialog.ts | 16 + frontend/src/types/api.ts | 2 + 14 files changed, 1175 insertions(+), 30 deletions(-) create mode 100644 frontend/src/components/DownloadQueue.tsx create mode 100644 frontend/src/components/ui/scroll-area.tsx create mode 100644 frontend/src/hooks/useDownloadQueueData.ts create mode 100644 frontend/src/hooks/useDownloadQueueDialog.ts 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() {
-
- - {/* Download Progress Toast */} - +
+ + {/* Download Progress Toast - Bottom Left */} + + + {/* Download Queue Dialog */} + {/* Timeout Dialog */} 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 (
-
+
+
); } diff --git a/frontend/src/components/DownloadQueue.tsx b/frontend/src/components/DownloadQueue.tsx new file mode 100644 index 0000000..445fe5b --- /dev/null +++ b/frontend/src/components/DownloadQueue.tsx @@ -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( + 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 ; + case "completed": + return ; + case "failed": + return ; + case "skipped": + return ; + case "queued": + return ; + default: + return null; + } + }; + + const getStatusBadge = (status: string) => { + const variants: Record = { + downloading: "default", + completed: "outline", + failed: "destructive", + skipped: "secondary", + queued: "outline", + }; + + return ( + + {status} + + ); + }; + + // 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 ( + + + +
+ Download Queue +
+ {(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && ( + + )} + +
+
+ + {/* Queue Status */} +
+
+ + Queued: + {queueInfo.queued_count} +
+
+ + Completed: + {queueInfo.completed_count} +
+
+ + Skipped: + {queueInfo.skipped_count} +
+
+ + Failed: + {queueInfo.failed_count} +
+
+ + {/* Session Stats */} +
+
+ + Downloaded: + + {queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"} + +
+
+ + Speed: + + {queueInfo.current_speed > 0 && queueInfo.is_downloading + ? `${queueInfo.current_speed.toFixed(2)} MB/s` + : "—"} + +
+
+ + Duration: + + {queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"} + +
+
+ +
+ + {/* Download Queue List */} +
+
+ {queueInfo.queue.length === 0 ? ( +
+ +

No downloads in queue

+
+ ) : ( + queueInfo.queue.map((item) => ( +
+
+
{getStatusIcon(item.status)}
+ +
+
+
+

{item.track_name}

+

+ {item.artist_name} + {item.album_name && ` • ${item.album_name}`} +

+
+ {getStatusBadge(item.status)} +
+ + {/* Progress bar for downloading items */} + {item.status === "downloading" && ( +
+
+ + {item.progress > 0 + ? `${item.progress.toFixed(2)} MB` + : queueInfo.is_downloading && queueInfo.current_speed > 0 + ? "Downloading..." + : "Starting..."} + + + {item.speed > 0 + ? `${item.speed.toFixed(2)} MB/s` + : queueInfo.current_speed > 0 + ? `${queueInfo.current_speed.toFixed(2)} MB/s` + : "—"} + +
+ +
+ )} + + {/* Completed info */} + {item.status === "completed" && ( +
+ {item.progress.toFixed(2)} MB +
+ )} + + {/* Skipped info */} + {item.status === "skipped" && ( +
+ File already exists +
+ )} + + {/* Error message */} + {item.status === "failed" && item.error_message && ( +
+ {item.error_message} +
+ )} + + {/* File path for completed/skipped */} + {(item.status === "completed" || item.status === "skipped") && item.file_path && ( +
+ {item.file_path} +
+ )} +
+
+
+ )) + )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index d9ccec9..16fa9b9 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -60,7 +60,7 @@ function DialogContent({ , + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/frontend/src/hooks/useDownload.ts b/frontend/src/hooks/useDownload.ts index 28bdc8c..323e2dd 100644 --- a/frontend/src/hooks/useDownload.ts +++ b/frontend/src/hooks/useDownload.ts @@ -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) { diff --git a/frontend/src/hooks/useDownloadQueueData.ts b/frontend/src/hooks/useDownloadQueueData.ts new file mode 100644 index 0000000..15c9b32 --- /dev/null +++ b/frontend/src/hooks/useDownloadQueueData.ts @@ -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( + 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; +} diff --git a/frontend/src/hooks/useDownloadQueueDialog.ts b/frontend/src/hooks/useDownloadQueueDialog.ts new file mode 100644 index 0000000..649a61b --- /dev/null +++ b/frontend/src/hooks/useDownloadQueueDialog.ts @@ -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, + }; +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 742e22c..94e5adc 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -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 {