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 {