Compare commits

...

6 Commits

Author SHA1 Message Date
afkarxyz cc3f7640c6 v6.5 2025-11-30 05:38:44 +07:00
Lukas 2653586eea 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.
2025-11-29 17:36:58 +07:00
afkarxyz 0c92385c56 v6.4 2025-11-27 14:14:19 +07:00
afkarxyz 957fb83dbc v6.4 2025-11-27 06:49:33 +07:00
afkarxyz 90f1871488 UPX 2025-11-27 04:58:53 +07:00
afkarxyz 2d0e5055f8 UPX 2025-11-27 04:52:16 +07:00
35 changed files with 2010 additions and 279 deletions
+14 -7
View File
@@ -2,18 +2,13 @@ name: Build Multi-Platform
on: on:
push: push:
branches:
- main
tags: tags:
- 'v*' - 'v*'
pull_request:
branches:
- main
workflow_dispatch: workflow_dispatch:
env: env:
GO_VERSION: '1.25.4' GO_VERSION: '1.25.4'
NODE_VERSION: '20' NODE_VERSION: '24'
jobs: jobs:
build-windows: build-windows:
@@ -72,9 +67,17 @@ jobs:
pnpm install pnpm install
pnpm run generate-icon pnpm run generate-icon
- name: Install UPX
run: |
choco install upx -y
- name: Build application - name: Build application
run: wails build -platform windows/amd64 run: wails build -platform windows/amd64
- name: Compress with UPX
run: |
upx --best --lzma "build\bin\SpotiFLAC.exe"
- name: Prepare artifacts - name: Prepare artifacts
run: | run: |
mkdir -p dist mkdir -p dist
@@ -219,7 +222,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libfuse2 imagemagick sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libfuse2 imagemagick upx-ucl
# Create symlink for webkit2gtk-4.0 -> webkit2gtk-4.1 (Ubuntu 24.04 compatibility) # Create symlink for webkit2gtk-4.0 -> webkit2gtk-4.1 (Ubuntu 24.04 compatibility)
sudo ln -sf /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.1.pc /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc sudo ln -sf /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.1.pc /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc
@@ -236,6 +239,10 @@ jobs:
- name: Build application - name: Build application
run: wails build -platform linux/amd64 run: wails build -platform linux/amd64
- name: Compress with UPX
run: |
upx --best --lzma build/bin/SpotiFLAC
- name: Download appimagetool - name: Download appimagetool
run: | run: |
wget -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage wget -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
+130 -19
View File
@@ -53,6 +53,7 @@ type DownloadRequest struct {
SpotifyID string `json:"spotify_id,omitempty"` // Spotify track ID 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 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 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 // DownloadResponse represents the response structure for download operations
@@ -62,6 +63,7 @@ type DownloadResponse struct {
File string `json:"file,omitempty"` File string `json:"file,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,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 // 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" 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 // Early check: Check if file with same ISRC already exists
if existingFile, exists := backend.CheckISRCExists(req.OutputDir, req.ISRC); exists { if existingFile, exists := backend.CheckISRCExists(req.OutputDir, req.ISRC); exists {
fmt.Printf("File with ISRC %s already exists: %s\n", req.ISRC, existingFile) fmt.Printf("File with ISRC %s already exists: %s\n", req.ISRC, existingFile)
backend.SkipDownloadItem(itemID, existingFile)
return DownloadResponse{ return DownloadResponse{
Success: true, Success: true,
Message: "File with same ISRC already exists", Message: "File with same ISRC already exists",
File: existingFile, File: existingFile,
AlreadyExists: true, AlreadyExists: true,
ItemID: itemID,
}, nil }, 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) expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.FilenameFormat, req.TrackNumber, req.Position, req.UseAlbumTrackNumber)
expectedPath := filepath.Join(req.OutputDir, expectedFilename) expectedPath := filepath.Join(req.OutputDir, expectedFilename)
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 { if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
return DownloadResponse{ // Validate the file by checking if it has valid ISRC metadata
Success: true, if fileISRC, readErr := backend.ReadISRCFromFile(expectedPath); readErr == nil && fileISRC != "" {
Message: "File already exists", // File exists and has valid metadata - skip download
File: expectedPath, backend.SkipDownloadItem(itemID, expectedPath)
AlreadyExists: true, return DownloadResponse{
}, nil 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 { switch req.Service {
case "amazon": case "amazon":
downloader := backend.NewAmazonDownloader() downloader := backend.NewAmazonDownloader()
@@ -243,9 +269,23 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
} }
if err != nil { 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{ return DownloadResponse{
Success: false, Success: false,
Error: fmt.Sprintf("Download failed: %v", err), Error: fmt.Sprintf("Download failed: %v", err),
ItemID: itemID,
}, err }, err
} }
@@ -259,6 +299,16 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
message := "Download completed successfully" message := "Download completed successfully"
if alreadyExists { if alreadyExists {
message = "File already exists" 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{ return DownloadResponse{
@@ -266,6 +316,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
Message: message, Message: message,
File: filename, File: filename,
AlreadyExists: alreadyExists, AlreadyExists: alreadyExists,
ItemID: itemID,
}, nil }, nil
} }
@@ -305,6 +356,38 @@ func (a *App) GetDownloadProgress() backend.ProgressInfo {
return backend.GetDownloadProgress() 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 // Quit closes the application
func (a *App) Quit() { func (a *App) Quit() {
// You can add cleanup logic here if needed // You can add cleanup logic here if needed
@@ -357,10 +440,14 @@ func (a *App) AnalyzeMultipleTracks(filePaths []string) (string, error) {
// LyricsDownloadRequest represents the request structure for downloading lyrics // LyricsDownloadRequest represents the request structure for downloading lyrics
type LyricsDownloadRequest struct { type LyricsDownloadRequest struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"` TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"` ArtistName string `json:"artist_name"`
OutputDir string `json:"output_dir"` OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"`
Position int `json:"position"`
UseAlbumTrackNumber bool `json:"use_album_track_number"`
} }
// DownloadLyrics downloads lyrics for a single track // DownloadLyrics downloads lyrics for a single track
@@ -374,10 +461,14 @@ func (a *App) DownloadLyrics(req LyricsDownloadRequest) (backend.LyricsDownloadR
client := backend.NewLyricsClient() client := backend.NewLyricsClient()
backendReq := backend.LyricsDownloadRequest{ backendReq := backend.LyricsDownloadRequest{
SpotifyID: req.SpotifyID, SpotifyID: req.SpotifyID,
TrackName: req.TrackName, TrackName: req.TrackName,
ArtistName: req.ArtistName, ArtistName: req.ArtistName,
OutputDir: req.OutputDir, OutputDir: req.OutputDir,
FilenameFormat: req.FilenameFormat,
TrackNumber: req.TrackNumber,
Position: req.Position,
UseAlbumTrackNumber: req.UseAlbumTrackNumber,
} }
resp, err := client.DownloadLyrics(backendReq) resp, err := client.DownloadLyrics(backendReq)
@@ -390,3 +481,23 @@ func (a *App) DownloadLyrics(req LyricsDownloadRequest) (backend.LyricsDownloadR
return *resp, nil return *resp, nil
} }
// CheckTrackAvailability checks the availability of a track on different streaming platforms
func (a *App) CheckTrackAvailability(spotifyTrackID string, isrc string) (string, error) {
if spotifyTrackID == "" {
return "", fmt.Errorf("spotify track ID is required")
}
client := backend.NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyTrackID, isrc)
if err != nil {
return "", err
}
jsonData, err := json.Marshal(availability)
if err != nil {
return "", fmt.Errorf("failed to encode response: %v", err)
}
return string(jsonData), nil
}
+39 -6
View File
@@ -28,10 +28,14 @@ type LyricsResponse struct {
// LyricsDownloadRequest represents a request to download lyrics // LyricsDownloadRequest represents a request to download lyrics
type LyricsDownloadRequest struct { type LyricsDownloadRequest struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"` TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"` ArtistName string `json:"artist_name"`
OutputDir string `json:"output_dir"` OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"`
Position int `json:"position"`
UseAlbumTrackNumber bool `json:"use_album_track_number"`
} }
// LyricsDownloadResponse represents the response from lyrics download // LyricsDownloadResponse represents the response from lyrics download
@@ -121,6 +125,31 @@ func msToLRCTimestamp(msStr string) string {
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds) return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
} }
// buildLyricsFilename builds the lyrics filename based on settings (same as track filename)
func buildLyricsFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int) string {
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
var filename string
// Build base filename based on format
switch filenameFormat {
case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
filename = safeTitle
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
// Add track number prefix if enabled
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
}
return filename + ".lrc"
}
// DownloadLyrics downloads lyrics for a single track // DownloadLyrics downloads lyrics for a single track
func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloadResponse, error) { func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloadResponse, error) {
if req.SpotifyID == "" { if req.SpotifyID == "" {
@@ -143,8 +172,12 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
}, err }, err
} }
// Generate filename // Generate filename using same format as track
filename := sanitizeFilename(fmt.Sprintf("%s - %s.lrc", req.TrackName, req.ArtistName)) filenameFormat := req.FilenameFormat
if filenameFormat == "" {
filenameFormat = "title-artist" // default
}
filename := buildLyricsFilename(req.TrackName, req.ArtistName, filenameFormat, req.TrackNumber, req.Position)
filePath := filepath.Join(outputDir, filename) filePath := filepath.Join(outputDir, filename)
// Check if file already exists // Check if file already exists
+6 -1
View File
@@ -168,9 +168,14 @@ func CheckISRCExists(outputDir string, targetISRC string) (string, bool) {
filepath := fmt.Sprintf("%s/%s", outputDir, filename) 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) isrc, err := ReadISRCFromFile(filepath)
if err != nil { 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 continue
} }
+318 -1
View File
@@ -7,6 +7,34 @@ import (
"time" "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 // Global progress tracker
var ( var (
currentProgress float64 currentProgress float64
@@ -15,6 +43,16 @@ var (
downloadingLock sync.RWMutex downloadingLock sync.RWMutex
currentSpeed float64 currentSpeed float64
speedLock sync.RWMutex 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 // ProgressInfo represents download progress information
@@ -24,6 +62,19 @@ type ProgressInfo struct {
SpeedMBps float64 `json:"speed_mbps"` 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 // GetDownloadProgress returns current download progress
func GetDownloadProgress() ProgressInfo { func GetDownloadProgress() ProgressInfo {
downloadingLock.RLock() downloadingLock.RLock()
@@ -80,6 +131,7 @@ type ProgressWriter struct {
startTime int64 startTime int64
lastTime int64 lastTime int64
lastBytes int64 lastBytes int64
itemID string // Track which download item this belongs to
} }
func NewProgressWriter(writer io.Writer) *ProgressWriter { func NewProgressWriter(writer io.Writer) *ProgressWriter {
@@ -91,9 +143,17 @@ func NewProgressWriter(writer io.Writer) *ProgressWriter {
startTime: now, startTime: now,
lastTime: now, lastTime: now,
lastBytes: 0, 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 { func getCurrentTimeMillis() int64 {
return time.Now().UnixMilli() return time.Now().UnixMilli()
} }
@@ -111,8 +171,9 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
timeDiff := float64(now-pw.lastTime) / 1000.0 // seconds timeDiff := float64(now-pw.lastTime) / 1000.0 // seconds
bytesDiff := float64(pw.total - pw.lastBytes) bytesDiff := float64(pw.total - pw.lastBytes)
var speedMBps float64
if timeDiff > 0 { if timeDiff > 0 {
speedMBps := (bytesDiff / (1024 * 1024)) / timeDiff speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
SetDownloadSpeed(speedMBps) SetDownloadSpeed(speedMBps)
fmt.Printf("\rDownloaded: %.2f MB (%.2f MB/s)", mbDownloaded, speedMBps) fmt.Printf("\rDownloaded: %.2f MB (%.2f MB/s)", mbDownloaded, speedMBps)
} else { } else {
@@ -122,6 +183,11 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
// Update global progress // Update global progress
SetDownloadProgress(mbDownloaded) 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.lastPrinted = pw.total
pw.lastTime = now pw.lastTime = now
pw.lastBytes = pw.total pw.lastBytes = pw.total
@@ -133,3 +199,254 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
func (pw *ProgressWriter) GetTotal() int64 { func (pw *ProgressWriter) GetTotal() int64 {
return pw.total 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()
}
}
+162
View File
@@ -22,6 +22,19 @@ type SongLinkURLs struct {
AmazonURL string `json:"amazon_url"` AmazonURL string `json:"amazon_url"`
} }
// TrackAvailability represents the availability of a track on different platforms
type TrackAvailability struct {
SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"`
Deezer bool `json:"deezer"`
Amazon bool `json:"amazon"`
Qobuz bool `json:"qobuz"`
TidalURL string `json:"tidal_url,omitempty"`
DeezerURL string `json:"deezer_url,omitempty"`
AmazonURL string `json:"amazon_url,omitempty"`
QobuzURL string `json:"qobuz_url,omitempty"`
}
func NewSongLinkClient() *SongLinkClient { func NewSongLinkClient() *SongLinkClient {
return &SongLinkClient{ return &SongLinkClient{
client: &http.Client{ client: &http.Client{
@@ -148,3 +161,152 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink
return urls, nil return urls, nil
} }
// CheckTrackAvailability checks the availability of a track on different platforms
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
// Rate limiting: max 10 requests per minute (song.link API limit)
now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute {
s.apiCallCount = 0
s.apiCallResetTime = now
}
// If we've hit the limit, wait until the next minute
if s.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
if waitTime > 0 {
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
s.apiCallCount = 0
s.apiCallResetTime = time.Now()
}
}
// Add delay between requests (7 seconds to be safe)
if !s.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(s.lastAPICallTime)
minDelay := 7 * time.Second
if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
}
}
// Decode base64 API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
fmt.Printf("Checking availability for track: %s\n", spotifyTrackID)
// Retry logic for rate limit errors
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
resp, err = s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to check availability: %w", err)
}
// Update rate limit tracking
s.lastAPICallTime = time.Now()
s.apiCallCount++
if resp.StatusCode == 429 {
resp.Body.Close()
if i < maxRetries-1 {
waitTime := 15 * time.Second
fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime)
time.Sleep(waitTime)
continue
}
return nil, fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
}
if resp.StatusCode != 200 {
resp.Body.Close()
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
break
}
defer resp.Body.Close()
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
availability := &TrackAvailability{
SpotifyID: spotifyTrackID,
}
// Check Tidal
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
}
// Check Deezer
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
}
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
// Check Qobuz using ISRC (song.link doesn't support Qobuz)
if isrc != "" {
qobuzAvailable := checkQobuzAvailability(isrc)
availability.Qobuz = qobuzAvailable
}
return availability, nil
}
// checkQobuzAvailability checks if a track is available on Qobuz using ISRC
func checkQobuzAvailability(isrc string) bool {
client := &http.Client{Timeout: 10 * time.Second}
appID := "798273057"
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID)
resp, err := client.Get(searchURL)
if err != nil {
return false
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return false
}
var searchResp struct {
Tracks struct {
Total int `json:"total"`
} `json:"tracks"`
}
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return false
}
return searchResp.Tracks.Total > 0
}
+1
View File
@@ -18,6 +18,7 @@
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.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-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
+1 -1
View File
@@ -1 +1 @@
2d92c35b92c8ea713ea561773c5b7b7b 23a60910537eca7800052fa01bf45b7a
+33
View File
@@ -26,6 +26,9 @@ importers:
'@radix-ui/react-radio-group': '@radix-ui/react-radio-group':
specifier: ^1.3.8 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) 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': '@radix-ui/react-select':
specifier: ^2.2.6 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) 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': '@types/react-dom':
optional: true 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': '@radix-ui/react-select@2.2.6':
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies: peerDependencies:
@@ -2735,6 +2751,23 @@ snapshots:
'@types/react': 19.2.6 '@types/react': 19.2.6
'@types/react-dom': 19.2.3(@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)': '@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: dependencies:
'@radix-ui/number': 1.1.1 '@radix-ui/number': 1.1.1
+51 -12
View File
@@ -24,18 +24,25 @@ import { TrackInfo } from "@/components/TrackInfo";
import { AlbumInfo } from "@/components/AlbumInfo"; import { AlbumInfo } from "@/components/AlbumInfo";
import { PlaylistInfo } from "@/components/PlaylistInfo"; import { PlaylistInfo } from "@/components/PlaylistInfo";
import { ArtistInfo } from "@/components/ArtistInfo"; import { ArtistInfo } from "@/components/ArtistInfo";
import { DownloadQueue } from "@/components/DownloadQueue";
import { DownloadProgressToast } from "@/components/DownloadProgressToast"; import { DownloadProgressToast } from "@/components/DownloadProgressToast";
import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
import type { HistoryItem } from "@/components/FetchHistory"; import type { HistoryItem } from "@/components/FetchHistory";
// Hooks // Hooks
import { useDownload } from "@/hooks/useDownload"; import { useDownload } from "@/hooks/useDownload";
import { useMetadata } from "@/hooks/useMetadata"; import { useMetadata } from "@/hooks/useMetadata";
import { useLyrics } from "@/hooks/useLyrics"; import { useLyrics } from "@/hooks/useLyrics";
import { useAvailability } from "@/hooks/useAvailability";
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
const HISTORY_KEY = "spotiflac_fetch_history"; const HISTORY_KEY = "spotiflac_fetch_history";
const MAX_HISTORY = 5; const MAX_HISTORY = 5;
type PageType = "main" | "audio-analysis";
function App() { function App() {
const [currentPageView, setCurrentPageView] = useState<PageType>("main");
const [spotifyUrl, setSpotifyUrl] = useState(""); const [spotifyUrl, setSpotifyUrl] = useState("");
const [selectedTracks, setSelectedTracks] = useState<string[]>([]); const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -45,11 +52,13 @@ function App() {
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]); const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
const ITEMS_PER_PAGE = 50; const ITEMS_PER_PAGE = 50;
const CURRENT_VERSION = "6.3"; const CURRENT_VERSION = "6.5";
const download = useDownload(); const download = useDownload();
const metadata = useMetadata(); const metadata = useMetadata();
const lyrics = useLyrics(); const lyrics = useLyrics();
const availability = useAvailability();
const downloadQueue = useDownloadQueueDialog();
useEffect(() => { useEffect(() => {
const settings = getSettings(); const settings = getSettings();
@@ -79,6 +88,7 @@ function App() {
setSearchQuery(""); setSearchQuery("");
download.resetDownloadedTracks(); download.resetDownloadedTracks();
lyrics.resetLyricsState(); lyrics.resetLyricsState();
availability.clearAvailability();
setSortBy("default"); setSortBy("default");
setCurrentPage(1); setCurrentPage(1);
}, [metadata.metadata]); }, [metadata.metadata]);
@@ -252,12 +262,16 @@ function App() {
downloadingTrack={download.downloadingTrack} downloadingTrack={download.downloadingTrack}
isDownloaded={download.downloadedTracks.has(track.isrc)} isDownloaded={download.downloadedTracks.has(track.isrc)}
isFailed={download.failedTracks.has(track.isrc)} isFailed={download.failedTracks.has(track.isrc)}
isSkipped={download.skippedTracks.has(track.isrc)}
downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")}
failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")}
skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")}
checkingAvailability={availability.checkingTrackId === track.spotify_id}
availability={availability.getAvailability(track.spotify_id || "")}
onDownload={download.handleDownloadTrack} onDownload={download.handleDownloadTrack}
onDownloadLyrics={lyrics.handleDownloadLyrics} onDownloadLyrics={lyrics.handleDownloadLyrics}
onCheckAvailability={availability.checkAvailability}
onOpenFolder={handleOpenFolder} onOpenFolder={handleOpenFolder}
/> />
); );
@@ -286,14 +300,17 @@ function App() {
failedLyrics={lyrics.failedLyrics} failedLyrics={lyrics.failedLyrics}
skippedLyrics={lyrics.skippedLyrics} skippedLyrics={lyrics.skippedLyrics}
downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
checkingAvailabilityTrack={availability.checkingTrackId}
availabilityMap={availability.availabilityMap}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
onSortChange={setSortBy} onSortChange={setSortBy}
onToggleTrack={toggleTrackSelection} onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll} onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack} onDownloadTrack={download.handleDownloadTrack}
onDownloadLyrics={(spotifyId, name, artists, albumName) => onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name) lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, false, position)
} }
onCheckAvailability={availability.checkAvailability}
onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name)}
onDownloadSelected={() => onDownloadSelected={() =>
download.handleDownloadSelected(selectedTracks, track_list, album_info.name) download.handleDownloadSelected(selectedTracks, track_list, album_info.name)
@@ -340,14 +357,17 @@ function App() {
failedLyrics={lyrics.failedLyrics} failedLyrics={lyrics.failedLyrics}
skippedLyrics={lyrics.skippedLyrics} skippedLyrics={lyrics.skippedLyrics}
downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
checkingAvailabilityTrack={availability.checkingTrackId}
availabilityMap={availability.availabilityMap}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
onSortChange={setSortBy} onSortChange={setSortBy}
onToggleTrack={toggleTrackSelection} onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll} onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack} onDownloadTrack={download.handleDownloadTrack}
onDownloadLyrics={(spotifyId, name, artists, albumName) => onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name) lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, false, position)
} }
onCheckAvailability={availability.checkAvailability}
onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)}
onDownloadSelected={() => onDownloadSelected={() =>
download.handleDownloadSelected( download.handleDownloadSelected(
@@ -400,14 +420,17 @@ function App() {
failedLyrics={lyrics.failedLyrics} failedLyrics={lyrics.failedLyrics}
skippedLyrics={lyrics.skippedLyrics} skippedLyrics={lyrics.skippedLyrics}
downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
checkingAvailabilityTrack={availability.checkingTrackId}
availabilityMap={availability.availabilityMap}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
onSortChange={setSortBy} onSortChange={setSortBy}
onToggleTrack={toggleTrackSelection} onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll} onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack} onDownloadTrack={download.handleDownloadTrack}
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, isArtistDiscography) => onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, isArtistDiscography, position) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, isArtistDiscography) lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, isArtistDiscography, position)
} }
onCheckAvailability={availability.checkAvailability}
onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name, true)}
onDownloadSelected={() => onDownloadSelected={() =>
download.handleDownloadSelected(selectedTracks, track_list, artist_info.name, true) download.handleDownloadSelected(selectedTracks, track_list, artist_info.name, true)
@@ -441,10 +464,24 @@ function App() {
<TitleBar /> <TitleBar />
<div className="flex-1 p-4 md:p-8"> <div className="flex-1 p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} /> {currentPageView === "audio-analysis" ? (
<AudioAnalysisPage onBack={() => setCurrentPageView("main")} />
{/* Download Progress Toast */} ) : (
<DownloadProgressToast /> <>
<Header
version={CURRENT_VERSION}
hasUpdate={hasUpdate}
onOpenAudioAnalysis={() => setCurrentPageView("audio-analysis")}
/>
{/* Download Progress Toast - Bottom Left */}
<DownloadProgressToast onClick={downloadQueue.openQueue} />
{/* Download Queue Dialog */}
<DownloadQueue
isOpen={downloadQueue.isOpen}
onClose={downloadQueue.closeQueue}
/>
{/* Timeout Dialog */} {/* Timeout Dialog */}
<Dialog <Dialog
@@ -554,7 +591,9 @@ function App() {
hasResult={!!metadata.metadata} hasResult={!!metadata.metadata}
/> />
{metadata.metadata && renderMetadata()} {metadata.metadata && renderMetadata()}
</>
)}
</div> </div>
</div> </div>
</div> </div>
+12 -2
View File
@@ -5,7 +5,7 @@ import { Spinner } from "@/components/ui/spinner";
import { SearchAndSort } from "./SearchAndSort"; import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList"; import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress"; import { DownloadProgress } from "./DownloadProgress";
import type { TrackMetadata } from "@/types/api"; import type { TrackMetadata, TrackAvailability } from "@/types/api";
interface AlbumInfoProps { interface AlbumInfoProps {
albumInfo: { albumInfo: {
@@ -36,12 +36,16 @@ interface AlbumInfoProps {
failedLyrics?: Set<string>; failedLyrics?: Set<string>;
skippedLyrics?: Set<string>; skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null; downloadingLyricsTrack?: string | null;
// Availability props
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onSortChange: (value: string) => void; onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void; onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean) => void; onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAll: () => void; onDownloadAll: () => void;
onDownloadSelected: () => void; onDownloadSelected: () => void;
onStopDownload: () => void; onStopDownload: () => void;
@@ -71,12 +75,15 @@ export function AlbumInfo({
failedLyrics, failedLyrics,
skippedLyrics, skippedLyrics,
downloadingLyricsTrack, downloadingLyricsTrack,
checkingAvailabilityTrack,
availabilityMap,
onSearchChange, onSearchChange,
onSortChange, onSortChange,
onToggleTrack, onToggleTrack,
onToggleSelectAll, onToggleSelectAll,
onDownloadTrack, onDownloadTrack,
onDownloadLyrics, onDownloadLyrics,
onCheckAvailability,
onDownloadAll, onDownloadAll,
onDownloadSelected, onDownloadSelected,
onStopDownload, onStopDownload,
@@ -191,10 +198,13 @@ export function AlbumInfo({
failedLyrics={failedLyrics} failedLyrics={failedLyrics}
skippedLyrics={skippedLyrics} skippedLyrics={skippedLyrics}
downloadingLyricsTrack={downloadingLyricsTrack} downloadingLyricsTrack={downloadingLyricsTrack}
checkingAvailabilityTrack={checkingAvailabilityTrack}
availabilityMap={availabilityMap}
onToggleTrack={onToggleTrack} onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll} onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack} onDownloadTrack={onDownloadTrack}
onDownloadLyrics={onDownloadLyrics} onDownloadLyrics={onDownloadLyrics}
onCheckAvailability={onCheckAvailability}
onPageChange={onPageChange} onPageChange={onPageChange}
onTrackClick={onTrackClick} onTrackClick={onTrackClick}
/> />
+12 -2
View File
@@ -5,7 +5,7 @@ import { Spinner } from "@/components/ui/spinner";
import { SearchAndSort } from "./SearchAndSort"; import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList"; import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress"; import { DownloadProgress } from "./DownloadProgress";
import type { TrackMetadata } from "@/types/api"; import type { TrackMetadata, TrackAvailability } from "@/types/api";
interface ArtistInfoProps { interface ArtistInfoProps {
artistInfo: { artistInfo: {
@@ -41,12 +41,16 @@ interface ArtistInfoProps {
failedLyrics?: Set<string>; failedLyrics?: Set<string>;
skippedLyrics?: Set<string>; skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null; downloadingLyricsTrack?: string | null;
// Availability props
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onSortChange: (value: string) => void; onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void; onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean) => void; onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAll: () => void; onDownloadAll: () => void;
onDownloadSelected: () => void; onDownloadSelected: () => void;
onStopDownload: () => void; onStopDownload: () => void;
@@ -78,12 +82,15 @@ export function ArtistInfo({
failedLyrics, failedLyrics,
skippedLyrics, skippedLyrics,
downloadingLyricsTrack, downloadingLyricsTrack,
checkingAvailabilityTrack,
availabilityMap,
onSearchChange, onSearchChange,
onSortChange, onSortChange,
onToggleTrack, onToggleTrack,
onToggleSelectAll, onToggleSelectAll,
onDownloadTrack, onDownloadTrack,
onDownloadLyrics, onDownloadLyrics,
onCheckAvailability,
onDownloadAll, onDownloadAll,
onDownloadSelected, onDownloadSelected,
onStopDownload, onStopDownload,
@@ -230,10 +237,13 @@ export function ArtistInfo({
failedLyrics={failedLyrics} failedLyrics={failedLyrics}
skippedLyrics={skippedLyrics} skippedLyrics={skippedLyrics}
downloadingLyricsTrack={downloadingLyricsTrack} downloadingLyricsTrack={downloadingLyricsTrack}
checkingAvailabilityTrack={checkingAvailabilityTrack}
availabilityMap={availabilityMap}
onToggleTrack={onToggleTrack} onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll} onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack} onDownloadTrack={onDownloadTrack}
onDownloadLyrics={onDownloadLyrics} onDownloadLyrics={onDownloadLyrics}
onCheckAvailability={onCheckAvailability}
onPageChange={onPageChange} onPageChange={onPageChange}
onAlbumClick={onAlbumClick} onAlbumClick={onAlbumClick}
onArtistClick={onArtistClick} onArtistClick={onArtistClick}
@@ -1,143 +0,0 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Activity, Upload, X } from "lucide-react";
import { AudioAnalysis } from "@/components/AudioAnalysis";
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
import { SelectFile } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
export function AudioAnalysisDialog() {
const [open, setOpen] = useState(false);
const { analyzing, result, analyzeFile, clearResult } = useAudioAnalysis();
const [selectedFilePath, setSelectedFilePath] = useState<string>("");
const handleSelectFile = async () => {
try {
const filePath = await SelectFile();
if (filePath) {
setSelectedFilePath(filePath);
await analyzeFile(filePath);
}
} catch (err) {
toast.error("File Selection Failed", {
description: err instanceof Error ? err.message : "Failed to select file",
});
}
};
const handleClose = () => {
setOpen(false);
setTimeout(() => {
clearResult();
setSelectedFilePath("");
}, 200);
};
return (
<Dialog open={open} onOpenChange={(isOpen) => {
if (!isOpen) {
handleClose();
} else {
setOpen(true);
}
}}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<Activity className="h-5 w-5" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent side="left">
<p>Audio Quality Analyzer</p>
</TooltipContent>
</Tooltip>
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-y-auto flex flex-col p-6 [&>button]:hidden custom-scrollbar" aria-describedby={undefined}>
<div className="absolute right-4 top-4">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-70 hover:opacity-100"
onClick={handleClose}
>
<X className="h-4 w-4" />
</Button>
</div>
<DialogTitle className="text-sm font-medium">Audio Quality Analyzer</DialogTitle>
<div className="space-y-4">
{/* File Selection */}
{!result && !analyzing && (
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed rounded-lg">
<Activity className="h-16 w-16 text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium mb-2">Analyze FLAC Audio Quality</h3>
<p className="text-sm text-muted-foreground mb-6 text-center max-w-md">
Upload a FLAC file to verify true lossless quality, view detailed technical specifications, and see the frequency spectrum
</p>
<Button onClick={handleSelectFile} size="lg">
<Upload className="h-5 w-5" />
Select FLAC File
</Button>
</div>
)}
{/* Analysis Results */}
{result && (
<div className="space-y-4">
{/* File Info */}
<div className="p-3 bg-muted/30 rounded-lg">
<p className="text-xs text-muted-foreground">Analyzing file:</p>
<p className="text-sm font-mono truncate">{selectedFilePath}</p>
</div>
{/* Spectrum Visualization */}
<SpectrumVisualization
sampleRate={result.sample_rate}
bitsPerSample={result.bits_per_sample}
duration={result.duration}
spectrumData={result.spectrum}
/>
{/* Detailed Analysis */}
<AudioAnalysis
result={result}
analyzing={analyzing}
showAnalyzeButton={false}
/>
{/* Actions */}
<div className="flex gap-2 justify-end pt-2">
<Button onClick={handleSelectFile} variant="outline">
<Upload className="h-4 w-4" />
Analyze Another File
</Button>
</div>
</div>
)}
{/* Loading State */}
{analyzing && !result && (
<div className="flex flex-col items-center justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
<p className="text-sm text-muted-foreground">Analyzing audio file...</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,162 @@
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Activity, Upload, ArrowLeft } from "lucide-react";
import { AudioAnalysis } from "@/components/AudioAnalysis";
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
import { SelectFile } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
interface AudioAnalysisPageProps {
onBack: () => void;
}
export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) {
const { analyzing, result, analyzeFile, clearResult } = useAudioAnalysis();
const [selectedFilePath, setSelectedFilePath] = useState<string>("");
const [isDragging, setIsDragging] = useState(false);
const handleSelectFile = async () => {
try {
const filePath = await SelectFile();
if (filePath) {
setSelectedFilePath(filePath);
await analyzeFile(filePath);
}
} catch (err) {
toast.error("File Selection Failed", {
description: err instanceof Error ? err.message : "Failed to select file",
});
}
};
const handleFileDrop = useCallback(
async (_x: number, _y: number, paths: string[]) => {
setIsDragging(false);
if (paths.length === 0) return;
const filePath = paths[0];
if (!filePath.toLowerCase().endsWith(".flac")) {
toast.error("Invalid File Type", {
description: "Please drop a FLAC file for analysis",
});
return;
}
setSelectedFilePath(filePath);
await analyzeFile(filePath);
},
[analyzeFile]
);
useEffect(() => {
OnFileDrop((x, y, paths) => {
handleFileDrop(x, y, paths);
}, true);
return () => {
OnFileDropOff();
};
}, [handleFileDrop]);
const handleAnalyzeAnother = () => {
clearResult();
setSelectedFilePath("");
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
<p className="text-sm text-muted-foreground">
Analyze FLAC files to verify true lossless quality
</p>
</div>
</div>
{/* File Selection */}
{!result && !analyzing && (
<div
className={`flex flex-col items-center justify-center py-16 border-2 border-dashed rounded-lg transition-colors ${
isDragging
? "border-primary bg-primary/10"
: "border-muted-foreground/30 hover:border-muted-foreground/50"
}`}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
}}
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
>
<Activity
className={`h-20 w-20 mb-4 transition-colors ${isDragging ? "text-primary" : "text-muted-foreground/50"}`}
/>
<h3 className="text-xl font-medium mb-2">Analyze FLAC Audio Quality</h3>
<p className="text-sm text-muted-foreground mb-6 text-center max-w-md">
{isDragging
? "Drop your FLAC file here"
: "Drag and drop a FLAC file here, or click the button below to select"}
</p>
<Button onClick={handleSelectFile} size="lg">
<Upload className="h-5 w-5" />
Select FLAC File
</Button>
</div>
)}
{/* Loading State */}
{analyzing && !result && (
<div className="flex flex-col items-center justify-center py-16">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
<p className="text-sm text-muted-foreground">Analyzing audio file...</p>
</div>
)}
{/* Analysis Results */}
{result && (
<div className="space-y-4">
{/* File Info */}
<div className="p-3 bg-muted/30 rounded-lg">
<p className="text-xs text-muted-foreground">Analyzing file:</p>
<p className="text-sm font-mono truncate">{selectedFilePath}</p>
</div>
{/* Spectrum Visualization */}
<SpectrumVisualization
sampleRate={result.sample_rate}
bitsPerSample={result.bits_per_sample}
duration={result.duration}
spectrumData={result.spectrum}
/>
{/* Detailed Analysis */}
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} />
{/* Actions */}
<div className="flex gap-2 justify-end pt-2">
<Button onClick={handleAnalyzeAnother} variant="outline">
<Upload className="h-4 w-4" />
Analyze Another File
</Button>
</div>
</div>
)}
</div>
);
}
@@ -1,18 +1,35 @@
import { useDownloadProgress } from "@/hooks/useDownloadProgress"; import { useDownloadProgress } from "@/hooks/useDownloadProgress";
import { Download } from "lucide-react"; import { useDownloadQueueData } from "@/hooks/useDownloadQueueData";
import { Download, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
export function DownloadProgressToast() { interface DownloadProgressToastProps {
onClick: () => void;
}
export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) {
const progress = useDownloadProgress(); const 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 null;
} }
return ( return (
<div className="fixed bottom-4 left-4 z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5"> <div className="fixed bottom-4 left-4 z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
<div className="bg-background border rounded-lg shadow-lg p-3"> <Button
variant="outline"
className="bg-background border rounded-lg shadow-lg p-3 h-auto hover:bg-muted/50 transition-colors cursor-pointer"
onClick={onClick}
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Download className="h-4 w-4 text-primary animate-bounce" /> <Download className={`h-4 w-4 text-primary ${progress.is_downloading ? 'animate-bounce' : ''}`} />
<div className="flex flex-col min-w-[80px]"> <div className="flex flex-col min-w-[80px]">
<p className="text-sm font-medium font-mono tabular-nums"> <p className="text-sm font-medium font-mono tabular-nums">
{progress.mb_downloaded.toFixed(2)} MB {progress.mb_downloaded.toFixed(2)} MB
@@ -23,8 +40,9 @@ export function DownloadProgressToast() {
</p> </p>
)} )}
</div> </div>
<ChevronRight className="h-4 w-4 text-muted-foreground ml-1" />
</div> </div>
</div> </Button>
</div> </div>
); );
} }
+287
View File
@@ -0,0 +1,287 @@
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 { Badge } from "@/components/ui/badge";
import { GetDownloadQueue, ClearCompletedDownloads } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models";
interface DownloadQueueProps {
isOpen: boolean;
onClose: () => void;
}
export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(
new backend.DownloadQueueInfo({
is_downloading: false,
queue: [],
current_speed: 0,
total_downloaded: 0,
session_start_time: 0,
queued_count: 0,
completed_count: 0,
failed_count: 0,
skipped_count: 0,
})
);
useEffect(() => {
if (!isOpen) return;
const fetchQueue = async () => {
try {
const info = await GetDownloadQueue();
setQueueInfo(info);
} catch (error) {
console.error("Failed to get download queue:", error);
}
};
// Initial fetch
fetchQueue();
// Poll every 500ms when dialog is open
const interval = setInterval(fetchQueue, 500);
return () => clearInterval(interval);
}, [isOpen]);
const handleClearHistory = async () => {
try {
await ClearCompletedDownloads();
// Refetch immediately to update UI
const info = await GetDownloadQueue();
setQueueInfo(info);
} catch (error) {
console.error("Failed to clear history:", error);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "downloading":
return <Download className="h-4 w-4 text-blue-500 animate-bounce" />;
case "completed":
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case "failed":
return <XCircle className="h-4 w-4 text-red-500" />;
case "skipped":
return <FileCheck className="h-4 w-4 text-yellow-500" />;
case "queued":
return <Clock className="h-4 w-4 text-muted-foreground" />;
default:
return null;
}
};
const getStatusBadge = (status: string) => {
const variants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
downloading: "default",
completed: "outline",
failed: "destructive",
skipped: "secondary",
queued: "outline",
};
return (
<Badge variant={variants[status] || "outline"} className="text-xs">
{status}
</Badge>
);
};
// Format session duration
const formatDuration = (startTimestamp: number) => {
if (startTimestamp === 0) return "—";
const now = Math.floor(Date.now() / 1000);
const durationSeconds = now - startTimestamp;
const hours = Math.floor(durationSeconds / 3600);
const minutes = Math.floor((durationSeconds % 3600) / 60);
const seconds = durationSeconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`;
} else if (minutes > 0) {
return `${minutes}m ${seconds}s`;
} else {
return `${seconds}s`;
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
<div className="flex items-center justify-between mb-4 pr-8">
<DialogTitle className="text-lg font-semibold">Download Queue</DialogTitle>
<div className="flex items-center gap-2">
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1.5"
onClick={handleClearHistory}
>
<Trash2 className="h-3 w-3" />
Clear History
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full hover:bg-muted"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* Queue Status */}
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Queued:</span>
<span className="font-semibold">{queueInfo.queued_count}</span>
</div>
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
<span className="text-muted-foreground">Completed:</span>
<span className="font-semibold">{queueInfo.completed_count}</span>
</div>
<div className="flex items-center gap-1.5">
<FileCheck className="h-3.5 w-3.5 text-yellow-500" />
<span className="text-muted-foreground">Skipped:</span>
<span className="font-semibold">{queueInfo.skipped_count}</span>
</div>
<div className="flex items-center gap-1.5">
<XCircle className="h-3.5 w-3.5 text-red-500" />
<span className="text-muted-foreground">Failed:</span>
<span className="font-semibold">{queueInfo.failed_count}</span>
</div>
</div>
{/* Session Stats */}
<div className="flex items-center gap-4 text-sm pt-3 mt-3 border-t">
<div className="flex items-center gap-1.5">
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Downloaded:</span>
<span className="font-semibold font-mono">
{queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"}
</span>
</div>
<div className="flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Speed:</span>
<span className="font-semibold font-mono">
{queueInfo.current_speed > 0 && queueInfo.is_downloading
? `${queueInfo.current_speed.toFixed(2)} MB/s`
: "—"}
</span>
</div>
<div className="flex items-center gap-1.5">
<Timer className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Duration:</span>
<span className="font-semibold font-mono">
{queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"}
</span>
</div>
</div>
</DialogHeader>
{/* Download Queue List */}
<div className="flex-1 overflow-y-auto px-6 custom-scrollbar">
<div className="space-y-2 py-4">
{queueInfo.queue.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Download className="h-12 w-12 mx-auto mb-3 opacity-20" />
<p>No downloads in queue</p>
</div>
) : (
queueInfo.queue.map((item) => (
<div
key={item.id}
className="border rounded-lg p-3 hover:bg-muted/30 transition-colors"
>
<div className="flex items-start gap-3">
<div className="mt-1">{getStatusIcon(item.status)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{item.track_name}</p>
<p className="text-sm text-muted-foreground truncate">
{item.artist_name}
{item.album_name && `${item.album_name}`}
</p>
</div>
{getStatusBadge(item.status)}
</div>
{/* Info for downloading items */}
{item.status === "downloading" && (
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono">
<span>
{item.progress > 0
? `${item.progress.toFixed(2)} MB`
: queueInfo.is_downloading && queueInfo.current_speed > 0
? "Downloading..."
: "Starting..."}
</span>
<span>
{item.speed > 0
? `${item.speed.toFixed(2)} MB/s`
: queueInfo.current_speed > 0
? `${queueInfo.current_speed.toFixed(2)} MB/s`
: "—"}
</span>
</div>
)}
{/* Completed info */}
{item.status === "completed" && (
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
<span className="font-mono">{item.progress.toFixed(2)} MB</span>
</div>
)}
{/* Skipped info */}
{item.status === "skipped" && (
<div className="mt-1.5 text-xs text-muted-foreground">
File already exists
</div>
)}
{/* Error message */}
{item.status === "failed" && item.error_message && (
<div className="mt-1.5 text-xs text-red-500 bg-red-50 dark:bg-red-950/20 rounded px-2 py-1">
{item.error_message}
</div>
)}
{/* File path for completed/skipped */}
{(item.status === "completed" || item.status === "skipped") && item.file_path && (
<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono">
{item.file_path}
</div>
)}
</div>
</div>
</div>
))
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
+27 -19
View File
@@ -1,19 +1,21 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Settings } from "@/components/Settings"; import { Settings } from "@/components/Settings";
import { AudioAnalysisDialog } from "@/components/AudioAnalysisDialog"; import { Activity } from "lucide-react";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { openExternal } from "@/lib/utils";
interface HeaderProps { interface HeaderProps {
version: string; version: string;
hasUpdate: boolean; hasUpdate: boolean;
onOpenAudioAnalysis: () => void;
} }
export function Header({ version, hasUpdate }: HeaderProps) { export function Header({ version, hasUpdate, onOpenAudioAnalysis }: HeaderProps) {
return ( return (
<div className="relative"> <div className="relative">
<div className="text-center space-y-2"> <div className="text-center space-y-2">
@@ -32,14 +34,13 @@ export function Header({ version, hasUpdate }: HeaderProps) {
</h1> </h1>
<div className="relative"> <div className="relative">
<Badge variant="default" asChild> <Badge variant="default" asChild>
<a <button
href="https://github.com/afkarxyz/SpotiFLAC/releases" type="button"
target="_blank" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/releases")}
rel="noopener noreferrer"
className="cursor-pointer hover:opacity-80 transition-opacity" className="cursor-pointer hover:opacity-80 transition-opacity"
> >
v{version} v{version}
</a> </button>
</Badge> </Badge>
{hasUpdate && ( {hasUpdate && (
<span className="absolute -top-1 -right-1 flex h-3 w-3"> <span className="absolute -top-1 -right-1 flex h-3 w-3">
@@ -56,24 +57,31 @@ export function Header({ version, hasUpdate }: HeaderProps) {
<div className="absolute right-0 top-0 flex gap-2"> <div className="absolute right-0 top-0 flex gap-2">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="outline" size="icon" asChild> <Button
<a variant="outline"
href="https://github.com/afkarxyz/SpotiFLAC/issues" size="icon"
target="_blank" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")}
rel="noopener noreferrer" aria-label="GitHub Issues"
aria-label="GitHub Issues" >
> <svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor"> <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" /> </svg>
</svg>
</a>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="left"> <TooltipContent side="left">
<p>Report bug or request feature</p> <p>Report bug or request feature</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<AudioAnalysisDialog /> <Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" onClick={onOpenAudioAnalysis}>
<Activity className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Audio Quality Analyzer</p>
</TooltipContent>
</Tooltip>
<Settings /> <Settings />
</div> </div>
</div> </div>
+28
View File
@@ -0,0 +1,28 @@
// Platform Icons for streaming services
export const TidalIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
</svg>
);
export const DeezerIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<path d="M18.77 5.55c.19-1.07.46-1.75.76-1.75.56 0 1.02 2.34 1.02 5.23 0 2.89-.46 5.23-1.02 5.23-.23 0-.44-.4-.61-1.06-.27 2.43-.83 4.11-1.48 4.11-.5 0-.96-1-1.26-2.6-.2 3.03-.73 5.17-1.33 5.17-.39 0-.73-.85-.99-2.23-.31 2.85-1.03 4.85-1.86 4.85-.83 0-1.55-2-1.86-4.85-.25 1.38-.6 2.23-.99 2.23-.6 0-1.12-2.14-1.33-5.16-.3 1.58-.75 2.6-1.26 2.6-.65 0-1.2-1.68-1.48-4.12-.17.66-.38 1.06-.61 1.06-.56 0-1.02-2.34-1.02-5.23 0-2.89.46-5.23 1.02-5.23.3 0 .57.68.76 1.75C5.53 3.7 6 2.5 6.56 2.5c.66 0 1.22 1.7 1.49 4.17.26-1.8.66-2.94 1.1-2.94.63 0 1.16 2.25 1.36 5.4.36-1.62.9-2.63 1.5-2.63.58 0 1.12 1.01 1.49 2.62.2-3.14.72-5.4 1.35-5.4.44 0 .84 1.15 1.1 2.95.27-2.47.84-4.17 1.49-4.17.55 0 1.03 1.2 1.33 3.05ZM2 8.52c0-1.3.26-2.34.58-2.34.32 0 .57 1.05.57 2.34 0 1.29-.25 2.34-.57 2.34-.32 0-.58-1.05-.58-2.34Zm18.85 0c0-1.3.25-2.34.57-2.34.32 0 .58 1.05.58 2.34 0 1.29-.26 2.34-.58 2.34-.32 0-.57-1.05-.57-2.34Z"></path>
</svg>
);
export const QobuzIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
</svg>
);
export const AmazonIcon = ({ className = "w-4 h-4" }: { className?: string }) => (
<svg viewBox="0 0 24 24" className={`${className} fill-current`}>
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
</svg>
);
+12 -2
View File
@@ -5,7 +5,7 @@ import { Spinner } from "@/components/ui/spinner";
import { SearchAndSort } from "./SearchAndSort"; import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList"; import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress"; import { DownloadProgress } from "./DownloadProgress";
import type { TrackMetadata } from "@/types/api"; import type { TrackMetadata, TrackAvailability } from "@/types/api";
interface PlaylistInfoProps { interface PlaylistInfoProps {
playlistInfo: { playlistInfo: {
@@ -40,12 +40,16 @@ interface PlaylistInfoProps {
failedLyrics?: Set<string>; failedLyrics?: Set<string>;
skippedLyrics?: Set<string>; skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null; downloadingLyricsTrack?: string | null;
// Availability props
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onSortChange: (value: string) => void; onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void; onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean) => void; onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAll: () => void; onDownloadAll: () => void;
onDownloadSelected: () => void; onDownloadSelected: () => void;
onStopDownload: () => void; onStopDownload: () => void;
@@ -76,12 +80,15 @@ export function PlaylistInfo({
failedLyrics, failedLyrics,
skippedLyrics, skippedLyrics,
downloadingLyricsTrack, downloadingLyricsTrack,
checkingAvailabilityTrack,
availabilityMap,
onSearchChange, onSearchChange,
onSortChange, onSortChange,
onToggleTrack, onToggleTrack,
onToggleSelectAll, onToggleSelectAll,
onDownloadTrack, onDownloadTrack,
onDownloadLyrics, onDownloadLyrics,
onCheckAvailability,
onDownloadAll, onDownloadAll,
onDownloadSelected, onDownloadSelected,
onStopDownload, onStopDownload,
@@ -182,10 +189,13 @@ export function PlaylistInfo({
failedLyrics={failedLyrics} failedLyrics={failedLyrics}
skippedLyrics={skippedLyrics} skippedLyrics={skippedLyrics}
downloadingLyricsTrack={downloadingLyricsTrack} downloadingLyricsTrack={downloadingLyricsTrack}
checkingAvailabilityTrack={checkingAvailabilityTrack}
availabilityMap={availabilityMap}
onToggleTrack={onToggleTrack} onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll} onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack} onDownloadTrack={onDownloadTrack}
onDownloadLyrics={onDownloadLyrics} onDownloadLyrics={onDownloadLyrics}
onCheckAvailability={onCheckAvailability}
onPageChange={onPageChange} onPageChange={onPageChange}
onAlbumClick={onAlbumClick} onAlbumClick={onAlbumClick}
onArtistClick={onArtistClick} onArtistClick={onArtistClick}
+85 -36
View File
@@ -1,8 +1,14 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, CheckCircle, XCircle, FileText, SkipForward } from "lucide-react"; import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import type { TrackMetadata } from "@/types/api"; import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { TidalIcon, DeezerIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
interface TrackInfoProps { interface TrackInfoProps {
track: TrackMetadata & { album_name: string; release_date: string }; track: TrackMetadata & { album_name: string; release_date: string };
@@ -10,12 +16,16 @@ interface TrackInfoProps {
downloadingTrack: string | null; downloadingTrack: string | null;
isDownloaded: boolean; isDownloaded: boolean;
isFailed: boolean; isFailed: boolean;
isSkipped: boolean;
downloadingLyricsTrack?: string | null; downloadingLyricsTrack?: string | null;
downloadedLyrics?: boolean; downloadedLyrics?: boolean;
failedLyrics?: boolean; failedLyrics?: boolean;
skippedLyrics?: boolean; skippedLyrics?: boolean;
checkingAvailability?: boolean;
availability?: TrackAvailability;
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string) => void; onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string) => void; onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onOpenFolder: () => void; onOpenFolder: () => void;
} }
@@ -25,35 +35,42 @@ export function TrackInfo({
downloadingTrack, downloadingTrack,
isDownloaded, isDownloaded,
isFailed, isFailed,
isSkipped,
downloadingLyricsTrack, downloadingLyricsTrack,
downloadedLyrics, downloadedLyrics,
failedLyrics, failedLyrics,
skippedLyrics, skippedLyrics,
checkingAvailability,
availability,
onDownload, onDownload,
onDownloadLyrics, onDownloadLyrics,
onCheckAvailability,
onOpenFolder, onOpenFolder,
}: TrackInfoProps) { }: TrackInfoProps) {
return ( return (
<Card> <Card>
<CardContent className="px-6"> <CardContent className="px-6">
<div className="flex gap-6 items-start"> <div className="flex gap-6 items-start">
{track.images && ( <div className="shrink-0">
<img {track.images && (
src={track.images} <img
alt={track.name} src={track.images}
className="w-48 h-48 rounded-md shadow-lg object-cover shrink-0" alt={track.name}
/> className="w-48 h-48 rounded-md shadow-lg object-cover"
)} />
)}
</div>
<div className="flex-1 space-y-4 min-w-0"> <div className="flex-1 space-y-4 min-w-0">
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1> <h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
{isDownloaded && ( {isSkipped ? (
<FileCheck className="h-6 w-6 text-yellow-500 shrink-0" />
) : isDownloaded ? (
<CheckCircle className="h-6 w-6 text-green-500 shrink-0" /> <CheckCircle className="h-6 w-6 text-green-500 shrink-0" />
)} ) : isFailed ? (
{isFailed && (
<XCircle className="h-6 w-6 text-red-500 shrink-0" /> <XCircle className="h-6 w-6 text-red-500 shrink-0" />
)} ) : null}
</div> </div>
<p className="text-lg text-muted-foreground">{track.artists}</p> <p className="text-lg text-muted-foreground">{track.artists}</p>
</div> </div>
@@ -68,7 +85,7 @@ export function TrackInfo({
</div> </div>
</div> </div>
{track.isrc && ( {track.isrc && (
<div className="flex gap-2"> <div className="flex gap-2 flex-wrap">
<Button <Button
onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id)} onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id)}
disabled={isDownloading || downloadingTrack === track.isrc} disabled={isDownloading || downloadingTrack === track.isrc}
@@ -82,30 +99,62 @@ export function TrackInfo({
</> </>
)} )}
</Button> </Button>
{track.spotify_id && onCheckAvailability && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)}
variant="outline"
disabled={checkingAvailability}
>
{checkingAvailability ? (
<Spinner />
) : availability ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : (
<Globe className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{availability ? (
<div className="flex items-center gap-2">
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`} />
<DeezerIcon className={`w-4 h-4 ${availability.deezer ? "text-green-500" : "text-red-500"}`} />
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`} />
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`} />
</div>
) : (
<p>Check Availability</p>
)}
</TooltipContent>
</Tooltip>
)}
{track.spotify_id && onDownloadLyrics && ( {track.spotify_id && onDownloadLyrics && (
<Button <Tooltip>
onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name)} <TooltipTrigger asChild>
variant="secondary" <Button
disabled={downloadingLyricsTrack === track.spotify_id} onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name)}
> variant="outline"
{downloadingLyricsTrack === track.spotify_id ? ( disabled={downloadingLyricsTrack === track.spotify_id}
<Spinner /> >
) : ( {downloadingLyricsTrack === track.spotify_id ? (
<> <Spinner />
<FileText className="h-4 w-4" /> ) : skippedLyrics ? (
Download Lyric <FileCheck className="h-4 w-4 text-yellow-500" />
{skippedLyrics && ( ) : downloadedLyrics ? (
<SkipForward className="h-4 w-4 text-yellow-500 ml-1" /> <CheckCircle className="h-4 w-4 text-green-500" />
) : failedLyrics ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<FileText className="h-4 w-4" />
)} )}
{downloadedLyrics && !skippedLyrics && ( </Button>
<CheckCircle className="h-4 w-4 text-green-500 ml-1" /> </TooltipTrigger>
)} <TooltipContent>
{failedLyrics && ( <p>Download Lyric</p>
<XCircle className="h-4 w-4 text-red-500 ml-1" /> </TooltipContent>
)} </Tooltip>
</>
)}
</Button>
)} )}
{isDownloaded && ( {isDownloaded && (
<Button onClick={onOpenFolder} variant="outline"> <Button onClick={onOpenFolder} variant="outline">
+46 -6
View File
@@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Download, CheckCircle, XCircle, SkipForward, FileText } from "lucide-react"; import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { import {
Tooltip, Tooltip,
@@ -15,7 +15,8 @@ import {
PaginationNext, PaginationNext,
PaginationPrevious, PaginationPrevious,
} from "@/components/ui/pagination"; } from "@/components/ui/pagination";
import type { TrackMetadata } from "@/types/api"; import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { TidalIcon, DeezerIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
interface TrackListProps { interface TrackListProps {
tracks: TrackMetadata[]; tracks: TrackMetadata[];
@@ -38,10 +39,14 @@ interface TrackListProps {
failedLyrics?: Set<string>; failedLyrics?: Set<string>;
skippedLyrics?: Set<string>; skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null; downloadingLyricsTrack?: string | null;
// Availability props
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, isArtistDiscography?: boolean) => void; onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, isArtistDiscography?: boolean) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean) => void; onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void; onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void;
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void; onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
@@ -68,10 +73,13 @@ export function TrackList({
failedLyrics, failedLyrics,
skippedLyrics, skippedLyrics,
downloadingLyricsTrack, downloadingLyricsTrack,
checkingAvailabilityTrack,
availabilityMap,
onToggleTrack, onToggleTrack,
onToggleSelectAll, onToggleSelectAll,
onDownloadTrack, onDownloadTrack,
onDownloadLyrics, onDownloadLyrics,
onCheckAvailability,
onPageChange, onPageChange,
onAlbumClick, onAlbumClick,
onArtistClick, onArtistClick,
@@ -202,7 +210,7 @@ export function TrackList({
<span className="font-medium">{track.name}</span> <span className="font-medium">{track.name}</span>
)} )}
{skippedTracks.has(track.isrc) ? ( {skippedTracks.has(track.isrc) ? (
<SkipForward className="h-4 w-4 text-yellow-500 shrink-0" /> <FileCheck className="h-4 w-4 text-yellow-500 shrink-0" />
) : downloadedTracks.has(track.isrc) ? ( ) : downloadedTracks.has(track.isrc) ? (
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" /> <CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
) : failedTracks.has(track.isrc) ? ( ) : failedTracks.has(track.isrc) ? (
@@ -296,12 +304,44 @@ export function TrackList({
)} )}
</Button> </Button>
)} )}
{track.spotify_id && onCheckAvailability && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)}
size="sm"
variant="outline"
disabled={checkingAvailabilityTrack === track.spotify_id}
>
{checkingAvailabilityTrack === track.spotify_id ? (
<Spinner />
) : availabilityMap?.has(track.spotify_id) ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : (
<Globe className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{availabilityMap?.has(track.spotify_id) ? (
<div className="flex items-center gap-2">
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`} />
<DeezerIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.deezer ? "text-green-500" : "text-red-500"}`} />
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`} />
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`} />
</div>
) : (
<p>Check Availability</p>
)}
</TooltipContent>
</Tooltip>
)}
{track.spotify_id && onDownloadLyrics && ( {track.spotify_id && onDownloadLyrics && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
onClick={() => onClick={() =>
onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography) onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1)
} }
size="sm" size="sm"
variant="outline" variant="outline"
@@ -310,7 +350,7 @@ export function TrackList({
{downloadingLyricsTrack === track.spotify_id ? ( {downloadingLyricsTrack === track.spotify_id ? (
<Spinner /> <Spinner />
) : skippedLyrics?.has(track.spotify_id) ? ( ) : skippedLyrics?.has(track.spotify_id) ? (
<SkipForward className="h-4 w-4 text-yellow-500" /> <FileCheck className="h-4 w-4 text-yellow-500" />
) : downloadedLyrics?.has(track.spotify_id) ? ( ) : downloadedLyrics?.has(track.spotify_id) ? (
<CheckCircle className="h-4 w-4 text-green-500" /> <CheckCircle className="h-4 w-4 text-green-500" />
) : failedLyrics?.has(track.spotify_id) ? ( ) : failedLyrics?.has(track.spotify_id) ? (
+1 -1
View File
@@ -60,7 +60,7 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200",
className className
)} )}
{...props} {...props}
@@ -32,7 +32,7 @@ const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProp
setHasSelection(start !== end); setHasSelection(start !== end);
}; };
// Check clipboard permission // Check clipboard permission when user explicitly opens the context menu.
const checkClipboard = async () => { const checkClipboard = async () => {
try { try {
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText();
@@ -42,10 +42,6 @@ const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProp
} }
}; };
React.useEffect(() => {
checkClipboard();
}, []);
const handleCut = async () => { const handleCut = async () => {
const input = inputRef.current; const input = inputRef.current;
if (!input) return; if (!input) return;
@@ -156,7 +152,13 @@ const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProp
}; };
return ( return (
<ContextMenu onOpenChange={checkClipboard}> <ContextMenu
onOpenChange={(open) => {
if (open) {
checkClipboard();
}
}}
>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<Input <Input
ref={inputRef} ref={inputRef}
@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
+69
View File
@@ -0,0 +1,69 @@
import { useState, useCallback } from "react";
import { CheckTrackAvailability } from "../../wailsjs/go/main/App";
import type { TrackAvailability } from "@/types/api";
import { logger } from "@/lib/logger";
export function useAvailability() {
const [checking, setChecking] = useState(false);
const [checkingTrackId, setCheckingTrackId] = useState<string | null>(null);
const [availabilityMap, setAvailabilityMap] = useState<Map<string, TrackAvailability>>(new Map());
const [error, setError] = useState<string | null>(null);
const checkAvailability = useCallback(async (spotifyId: string, isrc?: string) => {
if (!spotifyId) {
setError("No Spotify ID provided");
return null;
}
// Check if already cached
if (availabilityMap.has(spotifyId)) {
return availabilityMap.get(spotifyId)!;
}
setChecking(true);
setCheckingTrackId(spotifyId);
setError(null);
try {
logger.info(`Checking availability for track: ${spotifyId}`);
const response = await CheckTrackAvailability(spotifyId, isrc || "");
const availability: TrackAvailability = JSON.parse(response);
setAvailabilityMap((prev) => {
const newMap = new Map(prev);
newMap.set(spotifyId, availability);
return newMap;
});
logger.success(`Availability check completed for ${spotifyId}`);
return availability;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to check availability";
logger.error(`Availability check error: ${errorMessage}`);
setError(errorMessage);
return null;
} finally {
setChecking(false);
setCheckingTrackId(null);
}
}, [availabilityMap]);
const getAvailability = useCallback((spotifyId: string) => {
return availabilityMap.get(spotifyId);
}, [availabilityMap]);
const clearAvailability = useCallback(() => {
setAvailabilityMap(new Map());
setError(null);
}, []);
return {
checking,
checkingTrackId,
availabilityMap,
error,
checkAvailability,
getAvailability,
clearAvailability,
};
}
+288 -6
View File
@@ -60,6 +60,10 @@ export function useDownload() {
} }
} }
// Always add item to queue before downloading
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
const itemID = await AddToDownloadQueue(isrc, trackName || "", artistName || "", albumName || "");
if (service === "auto") { if (service === "auto") {
// Get all streaming URLs once from song.link API // Get all streaming URLs once from song.link API
let streamingURLs: any = null; let streamingURLs: any = null;
@@ -95,6 +99,7 @@ export function useDownload() {
spotify_id: spotifyId, spotify_id: spotifyId,
service_url: streamingURLs.tidal_url, service_url: streamingURLs.tidal_url,
duration: durationSeconds, duration: durationSeconds,
item_id: itemID, // Pass the same itemID through all attempts
}); });
if (tidalResponse.success) { if (tidalResponse.success) {
@@ -125,6 +130,7 @@ export function useDownload() {
use_album_track_number: useAlbumTrackNumber, use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId, spotify_id: spotifyId,
service_url: streamingURLs.deezer_url, service_url: streamingURLs.deezer_url,
item_id: itemID,
}); });
if (deezerResponse.success) { if (deezerResponse.success) {
@@ -155,6 +161,7 @@ export function useDownload() {
use_album_track_number: useAlbumTrackNumber, use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId, spotify_id: spotifyId,
service_url: streamingURLs.amazon_url, service_url: streamingURLs.amazon_url,
item_id: itemID,
}); });
if (amazonResponse.success) { if (amazonResponse.success) {
@@ -169,13 +176,37 @@ export function useDownload() {
// Try Qobuz as last fallback // Try Qobuz as last fallback
logger.debug(`trying qobuz (fallback) for: ${trackName} - ${artistName}`); 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; const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
return await downloadTrack({ const singleServiceResponse = await downloadTrack({
isrc, isrc,
service: service as "deezer" | "tidal" | "qobuz" | "amazon", service: service as "deezer" | "tidal" | "qobuz" | "amazon",
query, query,
@@ -189,7 +220,213 @@ export function useDownload() {
use_album_track_number: useAlbumTrackNumber, use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId, spotify_id: spotifyId,
duration: durationSecondsForFallback, 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 ( const handleDownloadTrack = async (
@@ -268,6 +505,20 @@ export function useDownload() {
setBulkDownloadType("selected"); setBulkDownloadType("selected");
setDownloadProgress(0); 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 successCount = 0;
let errorCount = 0; let errorCount = 0;
let skippedCount = 0; let skippedCount = 0;
@@ -283,6 +534,7 @@ export function useDownload() {
const isrc = selectedTracks[i]; const isrc = selectedTracks[i];
const track = allTracks.find((t) => t.isrc === isrc); const track = allTracks.find((t) => t.isrc === isrc);
const itemID = itemIDs[i];
setDownloadingTrack(isrc); setDownloadingTrack(isrc);
@@ -291,10 +543,11 @@ export function useDownload() {
} }
try { try {
// Use sequential numbering (1, 2, 3...) for selected tracks // Download with pre-created itemID
const response = await downloadWithAutoFallback( const response = await downloadWithItemID(
isrc, isrc,
settings, settings,
itemID,
track?.name, track?.name,
track?.artists, track?.artists,
track?.album_name, track?.album_name,
@@ -329,6 +582,9 @@ export function useDownload() {
errorCount++; errorCount++;
logger.error(`error: ${track?.name} - ${err}`); logger.error(`error: ${track?.name} - ${err}`);
setFailedTracks((prev) => new Set(prev).add(isrc)); 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)); setDownloadProgress(Math.round(((i + 1) / total) * 100));
@@ -340,6 +596,10 @@ export function useDownload() {
setBulkDownloadType(null); setBulkDownloadType(null);
shouldStopDownloadRef.current = false; shouldStopDownloadRef.current = false;
// Cancel any remaining queued items
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
await CancelAllQueuedItems();
// Build summary message // Build summary message
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`); logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
if (errorCount === 0 && skippedCount === 0) { if (errorCount === 0 && skippedCount === 0) {
@@ -378,6 +638,19 @@ export function useDownload() {
setBulkDownloadType("all"); setBulkDownloadType("all");
setDownloadProgress(0); 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 successCount = 0;
let errorCount = 0; let errorCount = 0;
let skippedCount = 0; let skippedCount = 0;
@@ -392,14 +665,16 @@ export function useDownload() {
} }
const track = tracksWithIsrc[i]; const track = tracksWithIsrc[i];
const itemID = itemIDs[i];
setDownloadingTrack(track.isrc); setDownloadingTrack(track.isrc);
setCurrentDownloadInfo({ name: track.name, artists: track.artists }); setCurrentDownloadInfo({ name: track.name, artists: track.artists });
try { try {
const response = await downloadWithAutoFallback( const response = await downloadWithItemID(
track.isrc, track.isrc,
settings, settings,
itemID,
track.name, track.name,
track.artists, track.artists,
track.album_name, track.album_name,
@@ -434,6 +709,9 @@ export function useDownload() {
errorCount++; errorCount++;
logger.error(`error: ${track.name} - ${err}`); logger.error(`error: ${track.name} - ${err}`);
setFailedTracks((prev) => new Set(prev).add(track.isrc)); 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)); setDownloadProgress(Math.round(((i + 1) / total) * 100));
@@ -445,6 +723,10 @@ export function useDownload() {
setBulkDownloadType(null); setBulkDownloadType(null);
shouldStopDownloadRef.current = false; shouldStopDownloadRef.current = false;
// Cancel any remaining queued items
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
await CancelQueued();
// Build summary message // Build summary message
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`); logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
if (errorCount === 0 && skippedCount === 0) { if (errorCount === 0 && skippedCount === 0) {
@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import { GetDownloadQueue } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models";
export function useDownloadQueueData() {
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(
new backend.DownloadQueueInfo({
is_downloading: false,
queue: [],
current_speed: 0,
total_downloaded: 0,
session_start_time: 0,
queued_count: 0,
completed_count: 0,
failed_count: 0,
skipped_count: 0,
})
);
useEffect(() => {
const fetchQueue = async () => {
try {
const info = await GetDownloadQueue();
setQueueInfo(info);
} catch (error) {
console.error("Failed to get download queue:", error);
}
};
// Initial fetch
fetchQueue();
// Poll every 200ms
const interval = setInterval(fetchQueue, 200);
return () => clearInterval(interval);
}, []);
return queueInfo;
}
@@ -0,0 +1,16 @@
import { useState } from "react";
export function useDownloadQueueDialog() {
const [isOpen, setIsOpen] = useState(false);
const openQueue = () => setIsOpen(true);
const closeQueue = () => setIsOpen(false);
const toggleQueue = () => setIsOpen((prev) => !prev);
return {
isOpen,
openQueue,
closeQueue,
toggleQueue,
};
}
+6 -1
View File
@@ -17,7 +17,8 @@ export function useLyrics() {
artistName: string, artistName: string,
albumName?: string, albumName?: string,
playlistName?: string, playlistName?: string,
isArtistDiscography?: boolean isArtistDiscography?: boolean,
position?: number
) => { ) => {
if (!spotifyId) { if (!spotifyId) {
toast.error("No Spotify ID found for this track"); toast.error("No Spotify ID found for this track");
@@ -55,6 +56,10 @@ export function useLyrics() {
track_name: trackName, track_name: trackName,
artist_name: artistName, artist_name: artistName,
output_dir: outputDir, output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position: position || 0,
use_album_track_number: settings.albumSubfolder,
}); });
if (response.success) { if (response.success) {
+48
View File
@@ -196,3 +196,51 @@
.dark [data-sonner-toast][data-type="info"] [data-icon] { .dark [data-sonner-toast][data-type="info"] [data-icon] {
@apply text-blue-400; @apply text-blue-400;
} }
/* Custom Scrollbar - mengikuti primary color theme */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 4px;
opacity: 0.7;
}
::-webkit-scrollbar-thumb:hover {
background: var(--primary);
filter: brightness(1.2);
}
/* Firefox scrollbar support */
* {
scrollbar-width: thin;
scrollbar-color: var(--primary) var(--secondary);
}
/* Custom scrollbar class untuk komponen tertentu */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: var(--muted);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
filter: brightness(1.2);
}
+12
View File
@@ -1,5 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import { BrowserOpenURL } from "../../wailsjs/runtime/runtime"
import type { Settings } from "./settings"; import type { Settings } from "./settings";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
@@ -44,4 +45,15 @@ export function buildOutputPath(settings: Settings, folder?: string) {
const sanitized = folder ? sanitizePath(folder, os) : undefined; const sanitized = folder ? sanitizePath(folder, os) : undefined;
return sanitized ? joinPath(os, base, sanitized) : base; return sanitized ? joinPath(os, base, sanitized) : base;
}
export function openExternal(url: string) {
if (!url) return;
try {
BrowserOpenURL(url);
} catch (error) {
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
}
}
} }
+18
View File
@@ -125,6 +125,7 @@ export interface DownloadRequest {
spotify_id?: string; spotify_id?: string;
service_url?: string; service_url?: string;
duration?: number; // Track duration in seconds for better matching duration?: number; // Track duration in seconds for better matching
item_id?: string; // Optional queue item ID for multi-service fallback tracking
} }
export interface DownloadResponse { export interface DownloadResponse {
@@ -133,6 +134,7 @@ export interface DownloadResponse {
file?: string; file?: string;
error?: string; error?: string;
already_exists?: boolean; already_exists?: boolean;
item_id?: string; // Queue item ID for tracking
} }
export interface HealthResponse { export interface HealthResponse {
@@ -172,6 +174,10 @@ export interface LyricsDownloadRequest {
track_name: string; track_name: string;
artist_name: string; artist_name: string;
output_dir?: string; output_dir?: string;
filename_format?: string;
track_number?: boolean;
position?: number;
use_album_track_number?: boolean;
} }
export interface LyricsDownloadResponse { export interface LyricsDownloadResponse {
@@ -182,4 +188,16 @@ export interface LyricsDownloadResponse {
already_exists?: boolean; already_exists?: boolean;
} }
export interface TrackAvailability {
spotify_id: string;
tidal: boolean;
deezer: boolean;
amazon: boolean;
qobuz: boolean;
tidal_url?: string;
deezer_url?: string;
amazon_url?: string;
qobuz_url?: string;
}
+6
View File
@@ -30,6 +30,12 @@ func main() {
}, },
BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 255}, BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 255},
OnStartup: app.startup, OnStartup: app.startup,
DragAndDrop: &options.DragAndDrop{
EnableFileDrop: true,
DisableWebViewDrop: false,
CSSDropProperty: "--wails-drop-target",
CSSDropValue: "drop",
},
Bind: []interface{}{ Bind: []interface{}{
app, app,
}, },
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"version": "6.2" "version": "6.5"
} }
+1 -1
View File
@@ -12,7 +12,7 @@
}, },
"info": { "info": {
"productName": "SpotiFLAC", "productName": "SpotiFLAC",
"productVersion": "6.3" "productVersion": "6.5"
}, },
"wailsjsdir": "./frontend", "wailsjsdir": "./frontend",
"assetdir": "./frontend/dist", "assetdir": "./frontend/dist",