Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f10094e40 | |||
| 2fb544d1f8 | |||
| d16eaa324a | |||
| cc3f7640c6 | |||
| 2653586eea |
@@ -56,6 +56,9 @@ temp/
|
||||
*.bak
|
||||
*.old
|
||||
|
||||
# Test files
|
||||
test
|
||||
|
||||
# Build notes (optional - uncomment if you don't want to commit)
|
||||
# BUILD_NOTES.md
|
||||
build.txt
|
||||
@@ -16,7 +16,7 @@ Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music — no
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||

|
||||
|
||||
## Lossless Audio Checker
|
||||
|
||||
@@ -30,7 +30,7 @@ A simple utility for verifying the authenticity of FLAC files.
|
||||
|
||||

|
||||
|
||||
## Other projects
|
||||
## Other project
|
||||
|
||||
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ type DownloadRequest struct {
|
||||
SpotifyID string `json:"spotify_id,omitempty"` // Spotify track ID
|
||||
ServiceURL string `json:"service_url,omitempty"` // Direct service URL (Tidal/Deezer/Amazon) to skip song.link API call
|
||||
Duration int `json:"duration,omitempty"` // Track duration in seconds for better matching
|
||||
ItemID string `json:"item_id,omitempty"` // Optional queue item ID for multi-service fallback tracking
|
||||
}
|
||||
|
||||
// DownloadResponse represents the response structure for download operations
|
||||
@@ -62,6 +63,7 @@ type DownloadResponse struct {
|
||||
File string `json:"file,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
ItemID string `json:"item_id,omitempty"` // Queue item ID for tracking
|
||||
}
|
||||
|
||||
// GetStreamingURLs fetches all streaming URLs from song.link API
|
||||
@@ -129,6 +131,9 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
|
||||
if req.OutputDir == "" {
|
||||
req.OutputDir = "."
|
||||
} else {
|
||||
// Sanitize output directory path to remove invalid characters
|
||||
req.OutputDir = backend.SanitizeFolderPath(req.OutputDir)
|
||||
}
|
||||
|
||||
if req.AudioFormat == "" {
|
||||
@@ -143,14 +148,30 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
req.FilenameFormat = "title-artist"
|
||||
}
|
||||
|
||||
// ItemID should always be provided by frontend (created via AddToDownloadQueue)
|
||||
// If not provided, generate one for backwards compatibility
|
||||
itemID := req.ItemID
|
||||
if itemID == "" {
|
||||
itemID = fmt.Sprintf("%s-%d", req.ISRC, time.Now().UnixNano())
|
||||
// Add to queue if no ItemID was provided (legacy support)
|
||||
backend.AddToQueue(itemID, req.TrackName, req.ArtistName, req.AlbumName, req.ISRC)
|
||||
}
|
||||
|
||||
// Mark item as downloading immediately
|
||||
backend.SetDownloading(true)
|
||||
backend.StartDownloadItem(itemID)
|
||||
defer backend.SetDownloading(false)
|
||||
|
||||
// Early check: Check if file with same ISRC already exists
|
||||
if existingFile, exists := backend.CheckISRCExists(req.OutputDir, req.ISRC); exists {
|
||||
fmt.Printf("File with ISRC %s already exists: %s\n", req.ISRC, existingFile)
|
||||
backend.SkipDownloadItem(itemID, existingFile)
|
||||
return DownloadResponse{
|
||||
Success: true,
|
||||
Message: "File with same ISRC already exists",
|
||||
File: existingFile,
|
||||
AlreadyExists: true,
|
||||
ItemID: itemID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -159,20 +180,28 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.FilenameFormat, req.TrackNumber, req.Position, req.UseAlbumTrackNumber)
|
||||
expectedPath := filepath.Join(req.OutputDir, expectedFilename)
|
||||
|
||||
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
|
||||
return DownloadResponse{
|
||||
Success: true,
|
||||
Message: "File already exists",
|
||||
File: expectedPath,
|
||||
AlreadyExists: true,
|
||||
}, nil
|
||||
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
|
||||
// Validate the file by checking if it has valid ISRC metadata
|
||||
if fileISRC, readErr := backend.ReadISRCFromFile(expectedPath); readErr == nil && fileISRC != "" {
|
||||
// File exists and has valid metadata - skip download
|
||||
backend.SkipDownloadItem(itemID, expectedPath)
|
||||
return DownloadResponse{
|
||||
Success: true,
|
||||
Message: "File already exists",
|
||||
File: expectedPath,
|
||||
AlreadyExists: true,
|
||||
ItemID: itemID,
|
||||
}, nil
|
||||
} else {
|
||||
// File exists but has no valid ISRC metadata - it's corrupted, delete it
|
||||
fmt.Printf("Removing corrupted file (no valid ISRC metadata): %s\n", expectedPath)
|
||||
if removeErr := os.Remove(expectedPath); removeErr != nil {
|
||||
fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", expectedPath, removeErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set downloading state
|
||||
backend.SetDownloading(true)
|
||||
defer backend.SetDownloading(false)
|
||||
|
||||
switch req.Service {
|
||||
case "amazon":
|
||||
downloader := backend.NewAmazonDownloader()
|
||||
@@ -243,9 +272,23 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// Clean up any partial/corrupted file that was created during failed download
|
||||
if filename != "" && !strings.HasPrefix(filename, "EXISTS:") {
|
||||
// Check if file exists and delete it
|
||||
if _, statErr := os.Stat(filename); statErr == nil {
|
||||
fmt.Printf("Removing corrupted/partial file after failed download: %s\n", filename)
|
||||
if removeErr := os.Remove(filename); removeErr != nil {
|
||||
fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", filename, removeErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't mark as failed in backend - let the frontend handle it
|
||||
// Frontend will call MarkDownloadItemFailed after all services are tried
|
||||
return DownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Download failed: %v", err),
|
||||
ItemID: itemID,
|
||||
}, err
|
||||
}
|
||||
|
||||
@@ -259,6 +302,16 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
message := "Download completed successfully"
|
||||
if alreadyExists {
|
||||
message = "File already exists"
|
||||
backend.SkipDownloadItem(itemID, filename)
|
||||
} else {
|
||||
// Get file size for completed download
|
||||
if fileInfo, statErr := os.Stat(filename); statErr == nil {
|
||||
finalSize := float64(fileInfo.Size()) / (1024 * 1024) // Convert to MB
|
||||
backend.CompleteDownloadItem(itemID, filename, finalSize)
|
||||
} else {
|
||||
// Fallback: mark as completed without size
|
||||
backend.CompleteDownloadItem(itemID, filename, 0)
|
||||
}
|
||||
}
|
||||
|
||||
return DownloadResponse{
|
||||
@@ -266,6 +319,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
Message: message,
|
||||
File: filename,
|
||||
AlreadyExists: alreadyExists,
|
||||
ItemID: itemID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -305,6 +359,38 @@ func (a *App) GetDownloadProgress() backend.ProgressInfo {
|
||||
return backend.GetDownloadProgress()
|
||||
}
|
||||
|
||||
// GetDownloadQueue returns the complete download queue state
|
||||
func (a *App) GetDownloadQueue() backend.DownloadQueueInfo {
|
||||
return backend.GetDownloadQueue()
|
||||
}
|
||||
|
||||
// ClearCompletedDownloads clears completed, failed, and skipped items from the queue
|
||||
func (a *App) ClearCompletedDownloads() {
|
||||
backend.ClearDownloadQueue()
|
||||
}
|
||||
|
||||
// ClearAllDownloads clears the entire queue and resets session stats
|
||||
func (a *App) ClearAllDownloads() {
|
||||
backend.ClearAllDownloads()
|
||||
}
|
||||
|
||||
// AddToDownloadQueue adds a new item to the download queue and returns its ID
|
||||
func (a *App) AddToDownloadQueue(isrc, trackName, artistName, albumName string) string {
|
||||
itemID := fmt.Sprintf("%s-%d", isrc, time.Now().UnixNano())
|
||||
backend.AddToQueue(itemID, trackName, artistName, albumName, isrc)
|
||||
return itemID
|
||||
}
|
||||
|
||||
// MarkDownloadItemFailed marks a download item as failed
|
||||
func (a *App) MarkDownloadItemFailed(itemID, errorMsg string) {
|
||||
backend.FailDownloadItem(itemID, errorMsg)
|
||||
}
|
||||
|
||||
// CancelAllQueuedItems marks all queued items as cancelled/skipped
|
||||
func (a *App) CancelAllQueuedItems() {
|
||||
backend.CancelAllQueuedItems()
|
||||
}
|
||||
|
||||
// Quit closes the application
|
||||
func (a *App) Quit() {
|
||||
// You can add cleanup logic here if needed
|
||||
@@ -399,6 +485,48 @@ func (a *App) DownloadLyrics(req LyricsDownloadRequest) (backend.LyricsDownloadR
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// CoverDownloadRequest represents the request structure for downloading cover art
|
||||
type CoverDownloadRequest struct {
|
||||
CoverURL string `json:"cover_url"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
FilenameFormat string `json:"filename_format"`
|
||||
TrackNumber bool `json:"track_number"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
// DownloadCover downloads cover art for a single track
|
||||
func (a *App) DownloadCover(req CoverDownloadRequest) (backend.CoverDownloadResponse, error) {
|
||||
if req.CoverURL == "" {
|
||||
return backend.CoverDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Cover URL is required",
|
||||
}, fmt.Errorf("cover URL is required")
|
||||
}
|
||||
|
||||
client := backend.NewCoverClient()
|
||||
backendReq := backend.CoverDownloadRequest{
|
||||
CoverURL: req.CoverURL,
|
||||
TrackName: req.TrackName,
|
||||
ArtistName: req.ArtistName,
|
||||
OutputDir: req.OutputDir,
|
||||
FilenameFormat: req.FilenameFormat,
|
||||
TrackNumber: req.TrackNumber,
|
||||
Position: req.Position,
|
||||
}
|
||||
|
||||
resp, err := client.DownloadCover(backendReq)
|
||||
if err != nil {
|
||||
return backend.CoverDownloadResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}, err
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
|
||||
+31
-11
@@ -10,6 +10,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -390,18 +391,37 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st
|
||||
|
||||
// Build filename based on format settings
|
||||
var newFilename string
|
||||
switch filenameFormat {
|
||||
case "artist-title":
|
||||
newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
||||
case "title":
|
||||
newFilename = safeTitle
|
||||
default: // "title-artist"
|
||||
newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||
}
|
||||
|
||||
// Add track number prefix if enabled
|
||||
if includeTrackNumber && position > 0 {
|
||||
newFilename = fmt.Sprintf("%02d. %s", position, newFilename)
|
||||
// Check if format is a template (contains {})
|
||||
if strings.Contains(filenameFormat, "{") {
|
||||
newFilename = filenameFormat
|
||||
newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle)
|
||||
newFilename = strings.ReplaceAll(newFilename, "{artist}", safeArtist)
|
||||
|
||||
// Handle track number - if position is 0, remove {track} and surrounding separators
|
||||
if position > 0 {
|
||||
newFilename = strings.ReplaceAll(newFilename, "{track}", fmt.Sprintf("%02d", position))
|
||||
} else {
|
||||
// Remove {track} with common separators
|
||||
newFilename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(newFilename, "")
|
||||
newFilename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(newFilename, "")
|
||||
newFilename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(newFilename, "")
|
||||
}
|
||||
} else {
|
||||
// Legacy format support
|
||||
switch filenameFormat {
|
||||
case "artist-title":
|
||||
newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
|
||||
case "title":
|
||||
newFilename = safeTitle
|
||||
default: // "title-artist"
|
||||
newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
|
||||
}
|
||||
|
||||
// Add track number prefix if enabled (legacy behavior)
|
||||
if includeTrackNumber && position > 0 {
|
||||
newFilename = fmt.Sprintf("%02d. %s", position, newFilename)
|
||||
}
|
||||
}
|
||||
|
||||
newFilename = newFilename + ".flac"
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// Spotify image size codes
|
||||
spotifySize640 = "ab67616d0000b273" // 640x640
|
||||
spotifySizeMax = "ab67616d000082c1" // Max resolution
|
||||
)
|
||||
|
||||
// CoverDownloadRequest represents a request to download cover art
|
||||
type CoverDownloadRequest struct {
|
||||
CoverURL string `json:"cover_url"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
FilenameFormat string `json:"filename_format"`
|
||||
TrackNumber bool `json:"track_number"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
// CoverDownloadResponse represents the response from cover download
|
||||
type CoverDownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
File string `json:"file,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
}
|
||||
|
||||
// CoverClient handles cover art downloading
|
||||
type CoverClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewCoverClient creates a new cover client
|
||||
func NewCoverClient() *CoverClient {
|
||||
return &CoverClient{
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// buildCoverFilename builds the cover filename based on settings (same as track filename)
|
||||
func buildCoverFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int) string {
|
||||
safeTitle := sanitizeFilename(trackName)
|
||||
safeArtist := sanitizeFilename(artistName)
|
||||
|
||||
var filename string
|
||||
|
||||
// Check if format is a template (contains {})
|
||||
if strings.Contains(filenameFormat, "{") {
|
||||
filename = filenameFormat
|
||||
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
|
||||
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
|
||||
|
||||
// Handle track number - if position is 0, remove {track} and surrounding separators
|
||||
if position > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
|
||||
} else {
|
||||
// Remove {track} with common separators
|
||||
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||
}
|
||||
} else {
|
||||
// Legacy format support
|
||||
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 (legacy behavior)
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d. %s", position, filename)
|
||||
}
|
||||
}
|
||||
|
||||
return filename + ".jpg"
|
||||
}
|
||||
|
||||
// getMaxResolutionURL converts a Spotify cover URL to max resolution
|
||||
// Falls back to original URL if max resolution is not available
|
||||
func (c *CoverClient) getMaxResolutionURL(coverURL string) string {
|
||||
// Try to convert to max resolution
|
||||
if strings.Contains(coverURL, spotifySize640) {
|
||||
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||
// Check if max resolution URL is available
|
||||
resp, err := c.httpClient.Head(maxURL)
|
||||
if err == nil && resp.StatusCode == http.StatusOK {
|
||||
return maxURL
|
||||
}
|
||||
}
|
||||
// Return original URL as fallback
|
||||
return coverURL
|
||||
}
|
||||
|
||||
// DownloadCover downloads cover art for a single track
|
||||
func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) {
|
||||
if req.CoverURL == "" {
|
||||
return &CoverDownloadResponse{
|
||||
Success: false,
|
||||
Error: "Cover URL is required",
|
||||
}, fmt.Errorf("cover URL is required")
|
||||
}
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
outputDir := req.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = GetDefaultMusicPath()
|
||||
} else {
|
||||
outputDir = SanitizeFolderPath(outputDir)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return &CoverDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create output directory: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
// Generate filename using same format as track
|
||||
filenameFormat := req.FilenameFormat
|
||||
if filenameFormat == "" {
|
||||
filenameFormat = "title-artist" // default
|
||||
}
|
||||
filename := buildCoverFilename(req.TrackName, req.ArtistName, filenameFormat, req.TrackNumber, req.Position)
|
||||
filePath := filepath.Join(outputDir, filename)
|
||||
|
||||
// Check if file already exists
|
||||
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
|
||||
return &CoverDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Cover file already exists",
|
||||
File: filePath,
|
||||
AlreadyExists: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Try to get max resolution URL, fallback to original
|
||||
downloadURL := c.getMaxResolutionURL(req.CoverURL)
|
||||
|
||||
// Download cover image
|
||||
resp, err := c.httpClient.Get(downloadURL)
|
||||
if err != nil {
|
||||
return &CoverDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download cover: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &CoverDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to download cover: HTTP %d", resp.StatusCode),
|
||||
}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Create file
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return &CoverDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to create file: %v", err),
|
||||
}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Write content to file
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return &CoverDownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("failed to write cover file: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
return &CoverDownloadResponse{
|
||||
Success: true,
|
||||
Message: "Cover downloaded successfully",
|
||||
File: filePath,
|
||||
}, nil
|
||||
}
|
||||
+34
-15
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -229,24 +230,42 @@ func (d *DeezerDownloader) DownloadCoverArt(coverURL, filepath string) error {
|
||||
func buildFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
|
||||
var filename string
|
||||
|
||||
// Build base filename based on format
|
||||
switch format {
|
||||
case "artist-title":
|
||||
filename = fmt.Sprintf("%s - %s", artist, title)
|
||||
case "title":
|
||||
filename = title
|
||||
default: // "title-artist"
|
||||
filename = fmt.Sprintf("%s - %s", title, artist)
|
||||
// Determine track number to use
|
||||
numberToUse := position
|
||||
if useAlbumTrackNumber && trackNumber > 0 {
|
||||
numberToUse = trackNumber
|
||||
}
|
||||
|
||||
// Add track number prefix if enabled
|
||||
if includeTrackNumber && position > 0 {
|
||||
// Use album track number if in album folder structure, otherwise use playlist position
|
||||
numberToUse := position
|
||||
if useAlbumTrackNumber && trackNumber > 0 {
|
||||
numberToUse = trackNumber
|
||||
// Check if format is a template (contains {})
|
||||
if strings.Contains(format, "{") {
|
||||
filename = format
|
||||
filename = strings.ReplaceAll(filename, "{title}", title)
|
||||
filename = strings.ReplaceAll(filename, "{artist}", artist)
|
||||
|
||||
// Handle track number - if numberToUse is 0, remove {track} and surrounding separators
|
||||
if numberToUse > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse))
|
||||
} else {
|
||||
// Remove {track} with common separators
|
||||
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||
}
|
||||
} else {
|
||||
// Legacy format support
|
||||
switch format {
|
||||
case "artist-title":
|
||||
filename = fmt.Sprintf("%s - %s", artist, title)
|
||||
case "title":
|
||||
filename = title
|
||||
default: // "title-artist"
|
||||
filename = fmt.Sprintf("%s - %s", title, artist)
|
||||
}
|
||||
|
||||
// Add track number prefix if enabled (legacy behavior)
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
|
||||
}
|
||||
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
|
||||
}
|
||||
|
||||
return filename + ".flac"
|
||||
|
||||
+71
-14
@@ -2,6 +2,7 @@ package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
@@ -14,21 +15,36 @@ func BuildExpectedFilename(trackName, artistName, filenameFormat string, include
|
||||
|
||||
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)
|
||||
}
|
||||
// Check if format is a template (contains {})
|
||||
if strings.Contains(filenameFormat, "{") {
|
||||
filename = filenameFormat
|
||||
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
|
||||
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
|
||||
|
||||
// Add track number prefix if enabled
|
||||
// Note: We can't determine the exact track number without fetching from API
|
||||
// So we only add it if position > 0 (bulk download)
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d. %s", position, filename)
|
||||
// Handle track number - if position is 0, remove {track} and surrounding separators
|
||||
if position > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
|
||||
} else {
|
||||
// Remove {track} with common separators like ". " or " - " or ". "
|
||||
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||
}
|
||||
} else {
|
||||
// Legacy format support
|
||||
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 (legacy behavior)
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d. %s", position, filename)
|
||||
}
|
||||
}
|
||||
|
||||
return filename + ".flac"
|
||||
@@ -44,3 +60,44 @@ func sanitizeFilename(name string) string {
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// SanitizeFolderPath sanitizes each component of a folder path and normalizes separators
|
||||
func SanitizeFolderPath(folderPath string) string {
|
||||
// Normalize all forward slashes to backslashes on Windows
|
||||
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||
|
||||
// Detect separator
|
||||
sep := string(filepath.Separator)
|
||||
|
||||
// Split path into components
|
||||
parts := strings.Split(normalizedPath, sep)
|
||||
sanitizedParts := make([]string, 0, len(parts))
|
||||
|
||||
for i, part := range parts {
|
||||
// Keep drive letter intact on Windows (e.g., "C:")
|
||||
if i == 0 && len(part) == 2 && part[1] == ':' {
|
||||
sanitizedParts = append(sanitizedParts, part)
|
||||
continue
|
||||
}
|
||||
|
||||
// Sanitize each folder name (but don't replace / or \ since we already normalized)
|
||||
sanitized := sanitizeFolderName(part)
|
||||
if sanitized != "" {
|
||||
sanitizedParts = append(sanitizedParts, sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(sanitizedParts, sep)
|
||||
}
|
||||
|
||||
// sanitizeFolderName removes invalid characters from a single folder name
|
||||
func sanitizeFolderName(name string) string {
|
||||
// Remove or replace invalid characters for folder names (excluding path separators)
|
||||
re := regexp.MustCompile(`[<>:"|?*]`)
|
||||
sanitized := re.ReplaceAllString(name, "_")
|
||||
sanitized = strings.TrimSpace(sanitized)
|
||||
if sanitized == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
+32
-12
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -132,19 +133,36 @@ func buildLyricsFilename(trackName, artistName, filenameFormat string, includeTr
|
||||
|
||||
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)
|
||||
}
|
||||
// Check if format is a template (contains {})
|
||||
if strings.Contains(filenameFormat, "{") {
|
||||
filename = filenameFormat
|
||||
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
|
||||
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
|
||||
|
||||
// Add track number prefix if enabled
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d. %s", position, filename)
|
||||
// Handle track number - if position is 0, remove {track} and surrounding separators
|
||||
if position > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
|
||||
} else {
|
||||
// Remove {track} with common separators
|
||||
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||
}
|
||||
} else {
|
||||
// Legacy format support
|
||||
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 (legacy behavior)
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d. %s", position, filename)
|
||||
}
|
||||
}
|
||||
|
||||
return filename + ".lrc"
|
||||
@@ -163,6 +181,8 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
||||
outputDir := req.OutputDir
|
||||
if outputDir == "" {
|
||||
outputDir = GetDefaultMusicPath()
|
||||
} else {
|
||||
outputDir = SanitizeFolderPath(outputDir)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
|
||||
+6
-1
@@ -168,9 +168,14 @@ func CheckISRCExists(outputDir string, targetISRC string) (string, bool) {
|
||||
|
||||
filepath := fmt.Sprintf("%s/%s", outputDir, filename)
|
||||
|
||||
// Read ISRC from file
|
||||
// Read ISRC from file (this will fail for corrupted files)
|
||||
isrc, err := ReadISRCFromFile(filepath)
|
||||
if err != nil {
|
||||
// File is corrupted or unreadable, delete it
|
||||
fmt.Printf("Removing corrupted/unreadable file: %s (error: %v)\n", filepath, err)
|
||||
if removeErr := os.Remove(filepath); removeErr != nil {
|
||||
fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", filepath, removeErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
+325
-1
@@ -7,6 +7,34 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// DownloadStatus represents the status of a download item
|
||||
type DownloadStatus string
|
||||
|
||||
const (
|
||||
StatusQueued DownloadStatus = "queued"
|
||||
StatusDownloading DownloadStatus = "downloading"
|
||||
StatusCompleted DownloadStatus = "completed"
|
||||
StatusFailed DownloadStatus = "failed"
|
||||
StatusSkipped DownloadStatus = "skipped"
|
||||
)
|
||||
|
||||
// DownloadItem represents a single item in the download queue
|
||||
type DownloadItem struct {
|
||||
ID string `json:"id"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
ISRC string `json:"isrc"`
|
||||
Status DownloadStatus `json:"status"`
|
||||
Progress float64 `json:"progress"` // MB downloaded
|
||||
TotalSize float64 `json:"total_size"` // MB total (if known)
|
||||
Speed float64 `json:"speed"` // MB/s
|
||||
StartTime int64 `json:"start_time"` // Unix timestamp
|
||||
EndTime int64 `json:"end_time"` // Unix timestamp
|
||||
ErrorMessage string `json:"error_message"` // If failed
|
||||
FilePath string `json:"file_path"` // Final file path
|
||||
}
|
||||
|
||||
// Global progress tracker
|
||||
var (
|
||||
currentProgress float64
|
||||
@@ -15,6 +43,16 @@ var (
|
||||
downloadingLock sync.RWMutex
|
||||
currentSpeed float64
|
||||
speedLock sync.RWMutex
|
||||
|
||||
// Download queue tracking
|
||||
downloadQueue []DownloadItem
|
||||
downloadQueueLock sync.RWMutex
|
||||
currentItemID string
|
||||
currentItemLock sync.RWMutex
|
||||
totalDownloaded float64
|
||||
totalDownloadedLock sync.RWMutex
|
||||
sessionStartTime int64
|
||||
sessionStartLock sync.RWMutex
|
||||
)
|
||||
|
||||
// ProgressInfo represents download progress information
|
||||
@@ -24,6 +62,19 @@ type ProgressInfo struct {
|
||||
SpeedMBps float64 `json:"speed_mbps"`
|
||||
}
|
||||
|
||||
// DownloadQueueInfo represents the complete download queue state
|
||||
type DownloadQueueInfo struct {
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
Queue []DownloadItem `json:"queue"`
|
||||
CurrentSpeed float64 `json:"current_speed"` // MB/s
|
||||
TotalDownloaded float64 `json:"total_downloaded"` // MB this session
|
||||
SessionStartTime int64 `json:"session_start_time"` // Unix timestamp
|
||||
QueuedCount int `json:"queued_count"`
|
||||
CompletedCount int `json:"completed_count"`
|
||||
FailedCount int `json:"failed_count"`
|
||||
SkippedCount int `json:"skipped_count"`
|
||||
}
|
||||
|
||||
// GetDownloadProgress returns current download progress
|
||||
func GetDownloadProgress() ProgressInfo {
|
||||
downloadingLock.RLock()
|
||||
@@ -80,6 +131,7 @@ type ProgressWriter struct {
|
||||
startTime int64
|
||||
lastTime int64
|
||||
lastBytes int64
|
||||
itemID string // Track which download item this belongs to
|
||||
}
|
||||
|
||||
func NewProgressWriter(writer io.Writer) *ProgressWriter {
|
||||
@@ -91,9 +143,17 @@ func NewProgressWriter(writer io.Writer) *ProgressWriter {
|
||||
startTime: now,
|
||||
lastTime: now,
|
||||
lastBytes: 0,
|
||||
itemID: "",
|
||||
}
|
||||
}
|
||||
|
||||
// NewProgressWriterWithID creates a progress writer with an item ID for queue tracking
|
||||
func NewProgressWriterWithID(writer io.Writer, itemID string) *ProgressWriter {
|
||||
pw := NewProgressWriter(writer)
|
||||
pw.itemID = itemID
|
||||
return pw
|
||||
}
|
||||
|
||||
func getCurrentTimeMillis() int64 {
|
||||
return time.Now().UnixMilli()
|
||||
}
|
||||
@@ -111,8 +171,9 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
||||
timeDiff := float64(now-pw.lastTime) / 1000.0 // seconds
|
||||
bytesDiff := float64(pw.total - pw.lastBytes)
|
||||
|
||||
var speedMBps float64
|
||||
if timeDiff > 0 {
|
||||
speedMBps := (bytesDiff / (1024 * 1024)) / timeDiff
|
||||
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
|
||||
SetDownloadSpeed(speedMBps)
|
||||
fmt.Printf("\rDownloaded: %.2f MB (%.2f MB/s)", mbDownloaded, speedMBps)
|
||||
} else {
|
||||
@@ -122,6 +183,11 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
||||
// Update global progress
|
||||
SetDownloadProgress(mbDownloaded)
|
||||
|
||||
// Update individual item progress if we have an item ID
|
||||
if pw.itemID != "" {
|
||||
UpdateItemProgress(pw.itemID, mbDownloaded, speedMBps)
|
||||
}
|
||||
|
||||
pw.lastPrinted = pw.total
|
||||
pw.lastTime = now
|
||||
pw.lastBytes = pw.total
|
||||
@@ -133,3 +199,261 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
||||
func (pw *ProgressWriter) GetTotal() int64 {
|
||||
return pw.total
|
||||
}
|
||||
|
||||
// Queue management functions
|
||||
|
||||
// AddToQueue adds a new item to the download queue
|
||||
func AddToQueue(id, trackName, artistName, albumName, isrc string) {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
|
||||
item := DownloadItem{
|
||||
ID: id,
|
||||
TrackName: trackName,
|
||||
ArtistName: artistName,
|
||||
AlbumName: albumName,
|
||||
ISRC: isrc,
|
||||
Status: StatusQueued,
|
||||
Progress: 0,
|
||||
TotalSize: 0,
|
||||
Speed: 0,
|
||||
StartTime: 0,
|
||||
EndTime: 0,
|
||||
}
|
||||
|
||||
downloadQueue = append(downloadQueue, item)
|
||||
|
||||
// Initialize session start time if this is the first item
|
||||
sessionStartLock.Lock()
|
||||
if sessionStartTime == 0 {
|
||||
sessionStartTime = time.Now().Unix()
|
||||
}
|
||||
sessionStartLock.Unlock()
|
||||
}
|
||||
|
||||
// StartDownloadItem marks an item as currently downloading
|
||||
func StartDownloadItem(id string) {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
|
||||
for i := range downloadQueue {
|
||||
if downloadQueue[i].ID == id {
|
||||
downloadQueue[i].Status = StatusDownloading
|
||||
downloadQueue[i].StartTime = time.Now().Unix()
|
||||
downloadQueue[i].Progress = 0
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
currentItemLock.Lock()
|
||||
currentItemID = id
|
||||
currentItemLock.Unlock()
|
||||
}
|
||||
|
||||
// UpdateItemProgress updates the progress of the current download item
|
||||
func UpdateItemProgress(id string, progress, speed float64) {
|
||||
downloadQueueLock.Lock()
|
||||
defer downloadQueueLock.Unlock()
|
||||
|
||||
for i := range downloadQueue {
|
||||
if downloadQueue[i].ID == id {
|
||||
downloadQueue[i].Progress = progress
|
||||
downloadQueue[i].Speed = speed
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentItemID returns the ID of the currently downloading item
|
||||
func GetCurrentItemID() string {
|
||||
currentItemLock.RLock()
|
||||
defer currentItemLock.RUnlock()
|
||||
return currentItemID
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
+35
-15
@@ -8,6 +8,8 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -225,24 +227,42 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
|
||||
func buildQobuzFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
|
||||
var filename string
|
||||
|
||||
// Build base filename based on format
|
||||
switch format {
|
||||
case "artist-title":
|
||||
filename = fmt.Sprintf("%s - %s", artist, title)
|
||||
case "title":
|
||||
filename = title
|
||||
default: // "title-artist"
|
||||
filename = fmt.Sprintf("%s - %s", title, artist)
|
||||
// Determine track number to use
|
||||
numberToUse := position
|
||||
if useAlbumTrackNumber && trackNumber > 0 {
|
||||
numberToUse = trackNumber
|
||||
}
|
||||
|
||||
// Add track number prefix if enabled
|
||||
if includeTrackNumber && position > 0 {
|
||||
// Use album track number if in album folder structure, otherwise use playlist position
|
||||
numberToUse := position
|
||||
if useAlbumTrackNumber && trackNumber > 0 {
|
||||
numberToUse = trackNumber
|
||||
// Check if format is a template (contains {})
|
||||
if strings.Contains(format, "{") {
|
||||
filename = format
|
||||
filename = strings.ReplaceAll(filename, "{title}", title)
|
||||
filename = strings.ReplaceAll(filename, "{artist}", artist)
|
||||
|
||||
// Handle track number - if numberToUse is 0, remove {track} and surrounding separators
|
||||
if numberToUse > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse))
|
||||
} else {
|
||||
// Remove {track} with common separators
|
||||
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||
}
|
||||
} else {
|
||||
// Legacy format support
|
||||
switch format {
|
||||
case "artist-title":
|
||||
filename = fmt.Sprintf("%s - %s", artist, title)
|
||||
case "title":
|
||||
filename = title
|
||||
default: // "title-artist"
|
||||
filename = fmt.Sprintf("%s - %s", title, artist)
|
||||
}
|
||||
|
||||
// Add track number prefix if enabled (legacy behavior)
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
|
||||
}
|
||||
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
|
||||
}
|
||||
|
||||
return filename + ".flac"
|
||||
|
||||
+384
-39
@@ -3,12 +3,15 @@ package backend
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -59,11 +62,35 @@ type TidalAPIResponse struct {
|
||||
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
||||
}
|
||||
|
||||
// TidalAPIResponseV2 is the new API response format (version 2.0)
|
||||
type TidalAPIResponseV2 struct {
|
||||
Version string `json:"version"`
|
||||
Data struct {
|
||||
TrackID int64 `json:"trackId"`
|
||||
AssetPresentation string `json:"assetPresentation"`
|
||||
AudioMode string `json:"audioMode"`
|
||||
AudioQuality string `json:"audioQuality"`
|
||||
ManifestMimeType string `json:"manifestMimeType"`
|
||||
ManifestHash string `json:"manifestHash"`
|
||||
Manifest string `json:"manifest"`
|
||||
BitDepth int `json:"bitDepth"`
|
||||
SampleRate int `json:"sampleRate"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type TidalAPIInfo struct {
|
||||
URL string `json:"url"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// TidalBTSManifest is the BTS (application/vnd.tidal.bts) manifest format
|
||||
type TidalBTSManifest struct {
|
||||
MimeType string `json:"mimeType"`
|
||||
Codecs string `json:"codecs"`
|
||||
EncryptionType string `json:"encryptionType"`
|
||||
URLs []string `json:"urls"`
|
||||
}
|
||||
|
||||
func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
|
||||
clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=")
|
||||
@@ -72,7 +99,7 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||
if apiURL == "" {
|
||||
downloader := &TidalDownloader{
|
||||
client: &http.Client{
|
||||
Timeout: 5 * time.Second, // Fast timeout for quick API fallback
|
||||
Timeout: 5 * time.Second,
|
||||
},
|
||||
timeout: 5 * time.Second,
|
||||
maxRetries: 3,
|
||||
@@ -84,13 +111,13 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||
// Try to get available APIs
|
||||
apis, err := downloader.GetAvailableAPIs()
|
||||
if err == nil && len(apis) > 0 {
|
||||
apiURL = apis[0] // Use first available API
|
||||
apiURL = apis[0]
|
||||
}
|
||||
}
|
||||
|
||||
return &TidalDownloader{
|
||||
client: &http.Client{
|
||||
Timeout: 5 * time.Second, // Fast timeout for quick API fallback
|
||||
Timeout: 5 * time.Second,
|
||||
},
|
||||
timeout: 5 * time.Second,
|
||||
maxRetries: 3,
|
||||
@@ -527,8 +554,23 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
return "", fmt.Errorf("API returned status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Read body to try both formats
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Printf("✗ Failed to read response body: %v\n", err)
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Try v2 format first (object with manifest)
|
||||
var v2Response TidalAPIResponseV2
|
||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||
fmt.Println("✓ Tidal manifest found (v2 API)")
|
||||
return "MANIFEST:" + v2Response.Data.Manifest, nil
|
||||
}
|
||||
|
||||
// Fallback to v1 format (array with OriginalTrackUrl)
|
||||
var apiResponses []TidalAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResponses); err != nil {
|
||||
if err := json.Unmarshal(body, &apiResponses); err != nil {
|
||||
fmt.Printf("✗ Failed to decode Tidal API response: %v\n", err)
|
||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
@@ -569,6 +611,11 @@ func (t *TidalDownloader) DownloadAlbumArt(albumID string) ([]byte, error) {
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
||||
// Check if this is a manifest-based download
|
||||
if strings.HasPrefix(url, "MANIFEST:") {
|
||||
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath)
|
||||
}
|
||||
|
||||
resp, err := t.client.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
@@ -599,6 +646,155 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadFromManifest downloads audio from manifest (supports BTS and DASH formats)
|
||||
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) error {
|
||||
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse manifest: %w", err)
|
||||
}
|
||||
|
||||
// Create HTTP client with longer timeout
|
||||
client := &http.Client{
|
||||
Timeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// If we have a direct URL (BTS format), download directly
|
||||
if directURL != "" {
|
||||
fmt.Println("Downloading file...")
|
||||
|
||||
resp, err := client.Get(directURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Use progress writer to track download
|
||||
pw := NewProgressWriter(out)
|
||||
_, err = io.Copy(pw, resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||
fmt.Println("Download complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
// DASH format - download segments to temporary M4A file, then remux to FLAC
|
||||
fmt.Printf("Downloading %d segments...\n", len(mediaURLs)+1)
|
||||
|
||||
// Create temporary file for M4A segments
|
||||
tempPath := outputPath + ".m4a.tmp"
|
||||
out, err := os.Create(tempPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
|
||||
// Download initialization segment
|
||||
fmt.Print("Downloading init segment... ")
|
||||
resp, err := client.Get(initURL)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to download init segment: %w", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
out.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("init segment download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to write init segment: %w", err)
|
||||
}
|
||||
fmt.Println("OK")
|
||||
|
||||
// Download media segments with progress tracking
|
||||
totalSegments := len(mediaURLs)
|
||||
var totalBytes int64
|
||||
lastTime := time.Now()
|
||||
var lastBytes int64
|
||||
for i, mediaURL := range mediaURLs {
|
||||
resp, err := client.Get(mediaURL)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to download segment %d: %w", i+1, err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
out.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode)
|
||||
}
|
||||
n, err := io.Copy(out, resp.Body)
|
||||
totalBytes += n
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to write segment %d: %w", i+1, err)
|
||||
}
|
||||
|
||||
// Calculate speed and update progress for frontend
|
||||
mbDownloaded := float64(totalBytes) / (1024 * 1024)
|
||||
now := time.Now()
|
||||
timeDiff := now.Sub(lastTime).Seconds()
|
||||
var speedMBps float64
|
||||
if timeDiff > 0.1 { // Update speed every 100ms
|
||||
bytesDiff := float64(totalBytes - lastBytes)
|
||||
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
|
||||
SetDownloadSpeed(speedMBps)
|
||||
lastTime = now
|
||||
lastBytes = totalBytes
|
||||
}
|
||||
SetDownloadProgress(mbDownloaded)
|
||||
|
||||
// Show progress with size in terminal
|
||||
fmt.Printf("\rDownloading: %.2f MB (%d/%d segments)", mbDownloaded, i+1, totalSegments)
|
||||
}
|
||||
|
||||
// Close temp file before remuxing
|
||||
out.Close()
|
||||
|
||||
// Get temp file size
|
||||
tempInfo, _ := os.Stat(tempPath)
|
||||
fmt.Printf("\rDownloaded: %.2f MB (Complete) \n", float64(tempInfo.Size())/(1024*1024))
|
||||
|
||||
// Remux M4A to FLAC using ffmpeg
|
||||
// DASH segments are in fMP4 container with FLAC codec, need to extract to native FLAC
|
||||
fmt.Println("Converting to FLAC...")
|
||||
cmd := exec.Command("ffmpeg", "-y", "-i", tempPath, "-vn", "-c:a", "flac", outputPath)
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
// If ffmpeg fails, try to keep the M4A file for debugging
|
||||
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||
os.Rename(tempPath, m4aPath)
|
||||
return fmt.Errorf("ffmpeg conversion failed (M4A saved as %s): %w - %s", m4aPath, err, stderr.String())
|
||||
}
|
||||
|
||||
// Remove temp file
|
||||
os.Remove(tempPath)
|
||||
fmt.Println("Download complete")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
@@ -1050,21 +1246,132 @@ func (t *TidalDownloader) DownloadBySearchWithISRC(trackName, artistName, albumN
|
||||
return outputFilename, nil
|
||||
}
|
||||
|
||||
// apiResult holds the result from a parallel API request
|
||||
type apiResult struct {
|
||||
apiURL string
|
||||
downloadURL string
|
||||
err error
|
||||
// DASH MPD XML structures for parsing manifest
|
||||
type MPD struct {
|
||||
XMLName xml.Name `xml:"MPD"`
|
||||
Period struct {
|
||||
AdaptationSet struct {
|
||||
Representation struct {
|
||||
SegmentTemplate struct {
|
||||
Initialization string `xml:"initialization,attr"`
|
||||
Media string `xml:"media,attr"`
|
||||
Timeline struct {
|
||||
Segments []struct {
|
||||
Duration int `xml:"d,attr"`
|
||||
Repeat int `xml:"r,attr"`
|
||||
} `xml:"S"`
|
||||
} `xml:"SegmentTimeline"`
|
||||
} `xml:"SegmentTemplate"`
|
||||
} `xml:"Representation"`
|
||||
} `xml:"AdaptationSet"`
|
||||
} `xml:"Period"`
|
||||
}
|
||||
|
||||
// parseManifest extracts download URL from base64 encoded manifest
|
||||
// Supports both BTS (JSON) and DASH (XML) formats
|
||||
// Returns: directURL (for BTS), or initURL + mediaURLs (for DASH)
|
||||
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) {
|
||||
// Decode base64 manifest
|
||||
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("failed to decode manifest: %w", err)
|
||||
}
|
||||
|
||||
manifestStr := string(manifestBytes)
|
||||
|
||||
// Check if it's BTS format (JSON) or DASH format (XML)
|
||||
if strings.HasPrefix(manifestStr, "{") {
|
||||
// BTS format - JSON with direct URLs
|
||||
var btsManifest TidalBTSManifest
|
||||
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
|
||||
return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err)
|
||||
}
|
||||
|
||||
if len(btsManifest.URLs) == 0 {
|
||||
return "", "", nil, fmt.Errorf("no URLs in BTS manifest")
|
||||
}
|
||||
|
||||
fmt.Printf("Manifest: BTS format (%s, %s)\n", btsManifest.MimeType, btsManifest.Codecs)
|
||||
return btsManifest.URLs[0], "", nil, nil
|
||||
}
|
||||
|
||||
// DASH format - XML with segments
|
||||
fmt.Println("Manifest: DASH format")
|
||||
|
||||
// Parse XML
|
||||
var mpd MPD
|
||||
if err := xml.Unmarshal(manifestBytes, &mpd); err != nil {
|
||||
return "", "", nil, fmt.Errorf("failed to parse manifest XML: %w", err)
|
||||
}
|
||||
|
||||
segTemplate := mpd.Period.AdaptationSet.Representation.SegmentTemplate
|
||||
initURL = segTemplate.Initialization
|
||||
mediaTemplate := segTemplate.Media
|
||||
|
||||
if initURL == "" || mediaTemplate == "" {
|
||||
// Fallback: try regex extraction
|
||||
initRe := regexp.MustCompile(`initialization="([^"]+)"`)
|
||||
mediaRe := regexp.MustCompile(`media="([^"]+)"`)
|
||||
|
||||
if match := initRe.FindStringSubmatch(manifestStr); len(match) > 1 {
|
||||
initURL = match[1]
|
||||
}
|
||||
if match := mediaRe.FindStringSubmatch(manifestStr); len(match) > 1 {
|
||||
mediaTemplate = match[1]
|
||||
}
|
||||
}
|
||||
|
||||
if initURL == "" {
|
||||
return "", "", nil, fmt.Errorf("no initialization URL found in manifest")
|
||||
}
|
||||
|
||||
// Unescape HTML entities in URLs
|
||||
initURL = strings.ReplaceAll(initURL, "&", "&")
|
||||
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&")
|
||||
|
||||
// Calculate segment count from timeline
|
||||
segmentCount := 0
|
||||
for _, seg := range segTemplate.Timeline.Segments {
|
||||
segmentCount += seg.Repeat + 1
|
||||
}
|
||||
|
||||
// If no segments found via XML, try regex
|
||||
if segmentCount == 0 {
|
||||
segRe := regexp.MustCompile(`<S d="\d+"(?: r="(\d+)")?`)
|
||||
matches := segRe.FindAllStringSubmatch(manifestStr, -1)
|
||||
for _, match := range matches {
|
||||
repeat := 0
|
||||
if len(match) > 1 && match[1] != "" {
|
||||
fmt.Sscanf(match[1], "%d", &repeat)
|
||||
}
|
||||
segmentCount += repeat + 1
|
||||
}
|
||||
}
|
||||
|
||||
// Generate media URLs for each segment
|
||||
for i := 1; i <= segmentCount; i++ {
|
||||
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
||||
mediaURLs = append(mediaURLs, mediaURL)
|
||||
}
|
||||
|
||||
return "", initURL, mediaURLs, nil
|
||||
}
|
||||
|
||||
// manifestResult holds the result from a parallel API request for v2 API
|
||||
type manifestResult struct {
|
||||
apiURL string
|
||||
manifest string
|
||||
err error
|
||||
}
|
||||
|
||||
// getDownloadURLParallel requests download URL from all APIs in parallel
|
||||
// Returns the first successful result
|
||||
// Returns the first successful result (supports both v1 and v2 API formats)
|
||||
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", "", fmt.Errorf("no APIs available")
|
||||
}
|
||||
|
||||
resultChan := make(chan apiResult, len(apis))
|
||||
resultChan := make(chan manifestResult, len(apis))
|
||||
|
||||
// Start all requests in parallel with longer timeout client
|
||||
fmt.Printf("Requesting download URL from %d APIs in parallel...\n", len(apis))
|
||||
@@ -1078,30 +1385,43 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
||||
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
resultChan <- apiResult{apiURL: api, err: err}
|
||||
resultChan <- manifestResult{apiURL: api, err: err}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resultChan <- apiResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode)}
|
||||
resultChan <- manifestResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode)}
|
||||
return
|
||||
}
|
||||
|
||||
var apiResponses []TidalAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResponses); err != nil {
|
||||
resultChan <- apiResult{apiURL: api, err: err}
|
||||
// Read body to try both formats
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
resultChan <- manifestResult{apiURL: api, err: err}
|
||||
return
|
||||
}
|
||||
|
||||
for _, item := range apiResponses {
|
||||
if item.OriginalTrackURL != "" {
|
||||
resultChan <- apiResult{apiURL: api, downloadURL: item.OriginalTrackURL, err: nil}
|
||||
return
|
||||
// Try v2 format first (object with manifest)
|
||||
var v2Response TidalAPIResponseV2
|
||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||
resultChan <- manifestResult{apiURL: api, manifest: v2Response.Data.Manifest, err: nil}
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to v1 format (array with OriginalTrackUrl)
|
||||
var v1Responses []TidalAPIResponse
|
||||
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
||||
for _, item := range v1Responses {
|
||||
if item.OriginalTrackURL != "" {
|
||||
// For v1, we store the URL directly with a prefix to distinguish
|
||||
resultChan <- manifestResult{apiURL: api, manifest: "DIRECT:" + item.OriginalTrackURL, err: nil}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resultChan <- apiResult{apiURL: api, err: fmt.Errorf("no download URL in response")}
|
||||
resultChan <- manifestResult{apiURL: api, err: fmt.Errorf("no download URL or manifest in response")}
|
||||
}(apiURL)
|
||||
}
|
||||
|
||||
@@ -1111,10 +1431,17 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
||||
|
||||
for i := 0; i < len(apis); i++ {
|
||||
result := <-resultChan
|
||||
if result.err == nil && result.downloadURL != "" {
|
||||
if result.err == nil && result.manifest != "" {
|
||||
// First success - use this one
|
||||
fmt.Printf("✓ Got download URL from: %s\n", result.apiURL)
|
||||
return result.apiURL, result.downloadURL, nil
|
||||
fmt.Printf("✓ Got response from: %s\n", result.apiURL)
|
||||
|
||||
// Check if it's a direct URL (v1) or manifest (v2)
|
||||
if strings.HasPrefix(result.manifest, "DIRECT:") {
|
||||
return result.apiURL, strings.TrimPrefix(result.manifest, "DIRECT:"), nil
|
||||
}
|
||||
|
||||
// It's a v2 manifest - return it with MANIFEST: prefix
|
||||
return result.apiURL, "MANIFEST:" + result.manifest, nil
|
||||
} else {
|
||||
errMsg := result.err.Error()
|
||||
if len(errMsg) > 50 {
|
||||
@@ -1311,24 +1638,42 @@ func (t *TidalDownloader) DownloadWithFallbackAndISRC(spotifyTrackID, spotifyISR
|
||||
func buildTidalFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
|
||||
var filename string
|
||||
|
||||
// Build base filename based on format
|
||||
switch format {
|
||||
case "artist-title":
|
||||
filename = fmt.Sprintf("%s - %s", artist, title)
|
||||
case "title":
|
||||
filename = title
|
||||
default: // "title-artist"
|
||||
filename = fmt.Sprintf("%s - %s", title, artist)
|
||||
// Determine track number to use
|
||||
numberToUse := position
|
||||
if useAlbumTrackNumber && trackNumber > 0 {
|
||||
numberToUse = trackNumber
|
||||
}
|
||||
|
||||
// Add track number prefix if enabled
|
||||
if includeTrackNumber && position > 0 {
|
||||
// Use album track number if in album folder structure, otherwise use playlist position
|
||||
numberToUse := position
|
||||
if useAlbumTrackNumber && trackNumber > 0 {
|
||||
numberToUse = trackNumber
|
||||
// Check if format is a template (contains {})
|
||||
if strings.Contains(format, "{") {
|
||||
filename = format
|
||||
filename = strings.ReplaceAll(filename, "{title}", title)
|
||||
filename = strings.ReplaceAll(filename, "{artist}", artist)
|
||||
|
||||
// Handle track number - if numberToUse is 0, remove {track} and surrounding separators
|
||||
if numberToUse > 0 {
|
||||
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse))
|
||||
} else {
|
||||
// Remove {track} with common separators
|
||||
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
||||
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
||||
}
|
||||
} else {
|
||||
// Legacy format support
|
||||
switch format {
|
||||
case "artist-title":
|
||||
filename = fmt.Sprintf("%s - %s", artist, title)
|
||||
case "title":
|
||||
filename = title
|
||||
default: // "title-artist"
|
||||
filename = fmt.Sprintf("%s - %s", title, artist)
|
||||
}
|
||||
|
||||
// Add track number prefix if enabled (legacy behavior)
|
||||
if includeTrackNumber && position > 0 {
|
||||
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
|
||||
}
|
||||
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
|
||||
}
|
||||
|
||||
return filename + ".flac"
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans+Flex:opsz,wght@6..144,1..1000&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300..800&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans+Flex:opsz,wght@6..144,1..1000&family=Inter:wght@300..800&family=Manrope:wght@300..800&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
|
||||
<title>SpotiFLAC</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -18,17 +18,19 @@
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.554.0",
|
||||
"lucide-react": "^0.556.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.17"
|
||||
@@ -36,9 +38,9 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.6",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
@@ -46,7 +48,7 @@
|
||||
"sharp": "^0.34.5",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.47.0",
|
||||
"vite": "^7.2.4"
|
||||
"typescript-eslint": "^8.48.1",
|
||||
"vite": "^7.2.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
e92e100705a0bb90f6783cd4074df1a7
|
||||
58400d5c8f7b03bac8ab784b5e775687
|
||||
Generated
+533
-590
File diff suppressed because it is too large
Load Diff
+214
-136
@@ -11,52 +11,65 @@ import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { getSettings, applyThemeMode } from "@/lib/settings";
|
||||
import { getSettings, applyThemeMode, applyFont } from "@/lib/settings";
|
||||
import { applyTheme } from "@/lib/themes";
|
||||
import { OpenFolder } from "../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
|
||||
// Components
|
||||
import { TitleBar } from "@/components/TitleBar";
|
||||
import { Sidebar, type PageType } from "@/components/Sidebar";
|
||||
import { Header } from "@/components/Header";
|
||||
import { SearchBar } from "@/components/SearchBar";
|
||||
import { TrackInfo } from "@/components/TrackInfo";
|
||||
import { AlbumInfo } from "@/components/AlbumInfo";
|
||||
import { PlaylistInfo } from "@/components/PlaylistInfo";
|
||||
import { ArtistInfo } from "@/components/ArtistInfo";
|
||||
import { DownloadQueue } from "@/components/DownloadQueue";
|
||||
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
|
||||
import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
|
||||
import { SettingsPage } from "@/components/SettingsPage";
|
||||
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
||||
import type { HistoryItem } from "@/components/FetchHistory";
|
||||
|
||||
// Hooks
|
||||
import { useDownload } from "@/hooks/useDownload";
|
||||
import { useMetadata } from "@/hooks/useMetadata";
|
||||
import { useLyrics } from "@/hooks/useLyrics";
|
||||
import { useCover } from "@/hooks/useCover";
|
||||
import { useAvailability } from "@/hooks/useAvailability";
|
||||
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
|
||||
|
||||
const HISTORY_KEY = "spotiflac_fetch_history";
|
||||
const MAX_HISTORY = 5;
|
||||
|
||||
function App() {
|
||||
const [currentPage, setCurrentPage] = useState<PageType>("main");
|
||||
const [spotifyUrl, setSpotifyUrl] = useState("");
|
||||
const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [sortBy, setSortBy] = useState<string>("default");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [currentListPage, setCurrentListPage] = useState(1);
|
||||
const [hasUpdate, setHasUpdate] = useState(false);
|
||||
const [releaseDate, setReleaseDate] = useState<string | null>(null);
|
||||
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
|
||||
|
||||
const ITEMS_PER_PAGE = 50;
|
||||
const CURRENT_VERSION = "6.4";
|
||||
const CURRENT_VERSION = "6.7";
|
||||
|
||||
const download = useDownload();
|
||||
const metadata = useMetadata();
|
||||
const lyrics = useLyrics();
|
||||
const cover = useCover();
|
||||
const availability = useAvailability();
|
||||
const downloadQueue = useDownloadQueueDialog();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const settings = getSettings();
|
||||
applyThemeMode(settings.themeMode);
|
||||
applyTheme(settings.theme);
|
||||
applyFont(settings.fontFamily);
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = () => {
|
||||
@@ -81,9 +94,10 @@ function App() {
|
||||
setSearchQuery("");
|
||||
download.resetDownloadedTracks();
|
||||
lyrics.resetLyricsState();
|
||||
cover.resetCoverState();
|
||||
availability.clearAvailability();
|
||||
setSortBy("default");
|
||||
setCurrentPage(1);
|
||||
setCurrentListPage(1);
|
||||
}, [metadata.metadata]);
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
@@ -92,9 +106,12 @@ function App() {
|
||||
"https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest"
|
||||
);
|
||||
const data = await response.json();
|
||||
// tag_name format: "v6.1" -> extract "6.1"
|
||||
const latestVersion = data.tag_name?.replace(/^v/, "") || "";
|
||||
|
||||
if (data.published_at) {
|
||||
setReleaseDate(data.published_at);
|
||||
}
|
||||
|
||||
if (latestVersion && latestVersion > CURRENT_VERSION) {
|
||||
setHasUpdate(true);
|
||||
}
|
||||
@@ -159,7 +176,6 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
// Add to history when metadata is successfully fetched
|
||||
useEffect(() => {
|
||||
if (!metadata.metadata || !spotifyUrl) return;
|
||||
|
||||
@@ -210,7 +226,7 @@ function App() {
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchQuery(value);
|
||||
setCurrentPage(1);
|
||||
setCurrentListPage(1);
|
||||
};
|
||||
|
||||
const toggleTrackSelection = (isrc: string) => {
|
||||
@@ -243,6 +259,7 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const renderMetadata = () => {
|
||||
if (!metadata.metadata) return null;
|
||||
|
||||
@@ -255,15 +272,18 @@ function App() {
|
||||
downloadingTrack={download.downloadingTrack}
|
||||
isDownloaded={download.downloadedTracks.has(track.isrc)}
|
||||
isFailed={download.failedTracks.has(track.isrc)}
|
||||
isSkipped={download.skippedTracks.has(track.isrc)}
|
||||
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
|
||||
downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")}
|
||||
failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")}
|
||||
skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")}
|
||||
checkingAvailability={availability.checkingTrackId === track.spotify_id}
|
||||
availability={availability.getAvailability(track.spotify_id || "")}
|
||||
downloadingCover={cover.downloadingCover}
|
||||
onDownload={download.handleDownloadTrack}
|
||||
onDownloadLyrics={lyrics.handleDownloadLyrics}
|
||||
onCheckAvailability={availability.checkAvailability}
|
||||
onDownloadCover={cover.handleDownloadCover}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
/>
|
||||
);
|
||||
@@ -286,7 +306,7 @@ function App() {
|
||||
bulkDownloadType={download.bulkDownloadType}
|
||||
downloadProgress={download.downloadProgress}
|
||||
currentDownloadInfo={download.currentDownloadInfo}
|
||||
currentPage={currentPage}
|
||||
currentPage={currentListPage}
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
downloadedLyrics={lyrics.downloadedLyrics}
|
||||
failedLyrics={lyrics.failedLyrics}
|
||||
@@ -294,22 +314,31 @@ function App() {
|
||||
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
|
||||
checkingAvailabilityTrack={availability.checkingTrackId}
|
||||
availabilityMap={availability.availabilityMap}
|
||||
downloadedCovers={cover.downloadedCovers}
|
||||
failedCovers={cover.failedCovers}
|
||||
skippedCovers={cover.skippedCovers}
|
||||
downloadingCoverTrack={cover.downloadingCoverTrack}
|
||||
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSortChange={setSortBy}
|
||||
onToggleTrack={toggleTrackSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
onDownloadTrack={download.handleDownloadTrack}
|
||||
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
|
||||
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, false, position)
|
||||
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position)
|
||||
}
|
||||
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) =>
|
||||
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId)
|
||||
}
|
||||
onCheckAvailability={availability.checkAvailability}
|
||||
onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name)}
|
||||
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name)}
|
||||
onDownloadAll={() => download.handleDownloadAll(track_list, undefined, true)}
|
||||
onDownloadSelected={() =>
|
||||
download.handleDownloadSelected(selectedTracks, track_list, album_info.name)
|
||||
download.handleDownloadSelected(selectedTracks, track_list, undefined, true)
|
||||
}
|
||||
onStopDownload={download.handleStopDownload}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageChange={setCurrentListPage}
|
||||
onArtistClick={async (artist) => {
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
if (artistUrl) {
|
||||
@@ -343,7 +372,7 @@ function App() {
|
||||
bulkDownloadType={download.bulkDownloadType}
|
||||
downloadProgress={download.downloadProgress}
|
||||
currentDownloadInfo={download.currentDownloadInfo}
|
||||
currentPage={currentPage}
|
||||
currentPage={currentListPage}
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
downloadedLyrics={lyrics.downloadedLyrics}
|
||||
failedLyrics={lyrics.failedLyrics}
|
||||
@@ -351,15 +380,24 @@ function App() {
|
||||
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
|
||||
checkingAvailabilityTrack={availability.checkingTrackId}
|
||||
availabilityMap={availability.availabilityMap}
|
||||
downloadedCovers={cover.downloadedCovers}
|
||||
failedCovers={cover.failedCovers}
|
||||
skippedCovers={cover.skippedCovers}
|
||||
downloadingCoverTrack={cover.downloadingCoverTrack}
|
||||
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSortChange={setSortBy}
|
||||
onToggleTrack={toggleTrackSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
onDownloadTrack={download.handleDownloadTrack}
|
||||
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
|
||||
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, false, position)
|
||||
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position)
|
||||
}
|
||||
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) =>
|
||||
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId)
|
||||
}
|
||||
onCheckAvailability={availability.checkAvailability}
|
||||
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)}
|
||||
onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)}
|
||||
onDownloadSelected={() =>
|
||||
download.handleDownloadSelected(
|
||||
@@ -370,7 +408,7 @@ function App() {
|
||||
}
|
||||
onStopDownload={download.handleStopDownload}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageChange={setCurrentListPage}
|
||||
onAlbumClick={metadata.handleAlbumClick}
|
||||
onArtistClick={async (artist) => {
|
||||
const artistUrl = await metadata.handleArtistClick(artist);
|
||||
@@ -406,7 +444,7 @@ function App() {
|
||||
bulkDownloadType={download.bulkDownloadType}
|
||||
downloadProgress={download.downloadProgress}
|
||||
currentDownloadInfo={download.currentDownloadInfo}
|
||||
currentPage={currentPage}
|
||||
currentPage={currentListPage}
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
downloadedLyrics={lyrics.downloadedLyrics}
|
||||
failedLyrics={lyrics.failedLyrics}
|
||||
@@ -414,18 +452,27 @@ function App() {
|
||||
downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
|
||||
checkingAvailabilityTrack={availability.checkingTrackId}
|
||||
availabilityMap={availability.availabilityMap}
|
||||
downloadedCovers={cover.downloadedCovers}
|
||||
failedCovers={cover.failedCovers}
|
||||
skippedCovers={cover.skippedCovers}
|
||||
downloadingCoverTrack={cover.downloadingCoverTrack}
|
||||
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSortChange={setSortBy}
|
||||
onToggleTrack={toggleTrackSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
onDownloadTrack={download.handleDownloadTrack}
|
||||
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, isArtistDiscography, position) =>
|
||||
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, isArtistDiscography, position)
|
||||
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
|
||||
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position)
|
||||
}
|
||||
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) =>
|
||||
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId)
|
||||
}
|
||||
onCheckAvailability={availability.checkAvailability}
|
||||
onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name, true)}
|
||||
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)}
|
||||
onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)}
|
||||
onDownloadSelected={() =>
|
||||
download.handleDownloadSelected(selectedTracks, track_list, artist_info.name, true)
|
||||
download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)
|
||||
}
|
||||
onStopDownload={download.handleStopDownload}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
@@ -436,7 +483,7 @@ function App() {
|
||||
setSpotifyUrl(artistUrl);
|
||||
}
|
||||
}}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageChange={setCurrentListPage}
|
||||
onTrackClick={async (track) => {
|
||||
if (track.external_urls) {
|
||||
setSpotifyUrl(track.external_urls);
|
||||
@@ -450,128 +497,159 @@ function App() {
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case "settings":
|
||||
return <SettingsPage />;
|
||||
case "debug":
|
||||
return <DebugLoggerPage />;
|
||||
case "audio-analysis":
|
||||
return <AudioAnalysisPage />;
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
version={CURRENT_VERSION}
|
||||
hasUpdate={hasUpdate}
|
||||
releaseDate={releaseDate}
|
||||
/>
|
||||
|
||||
{/* Timeout Dialog */}
|
||||
<Dialog
|
||||
open={metadata.showTimeoutDialog}
|
||||
onOpenChange={metadata.setShowTimeoutDialog}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||
<div className="absolute right-4 top-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||
onClick={() => metadata.setShowTimeoutDialog(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogTitle className="text-sm font-medium">Fetch Artist</DialogTitle>
|
||||
<DialogDescription>
|
||||
Set timeout for fetching metadata. Longer timeout is recommended for artists
|
||||
with large discography.
|
||||
</DialogDescription>
|
||||
{metadata.pendingArtistName && (
|
||||
<div className="py-2">
|
||||
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.pendingArtistName}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timeout">Timeout (seconds)</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
min="10"
|
||||
max="600"
|
||||
value={metadata.timeoutValue}
|
||||
onChange={(e) => metadata.setTimeoutValue(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default: 60 seconds. For large discographies, try 300-600 seconds (5-10
|
||||
minutes).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => metadata.setShowTimeoutDialog(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={metadata.handleConfirmFetch}>
|
||||
<Search className="h-4 w-4" />
|
||||
Fetch
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Album Fetch Dialog */}
|
||||
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||
<div className="absolute right-4 top-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||
onClick={() => metadata.setShowAlbumDialog(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle>
|
||||
<DialogDescription>
|
||||
Do you want to fetch metadata for this album?
|
||||
</DialogDescription>
|
||||
{metadata.selectedAlbum && (
|
||||
<div className="py-2">
|
||||
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.selectedAlbum.name}</p>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={async () => {
|
||||
const albumUrl = await metadata.handleConfirmAlbumFetch();
|
||||
if (albumUrl) {
|
||||
setSpotifyUrl(albumUrl);
|
||||
}
|
||||
}}>
|
||||
<Search className="h-4 w-4" />
|
||||
Fetch Album
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<SearchBar
|
||||
url={spotifyUrl}
|
||||
loading={metadata.loading}
|
||||
onUrlChange={setSpotifyUrl}
|
||||
onFetch={handleFetchMetadata}
|
||||
history={fetchHistory}
|
||||
onHistorySelect={handleHistorySelect}
|
||||
onHistoryRemove={removeFromHistory}
|
||||
hasResult={!!metadata.metadata}
|
||||
/>
|
||||
|
||||
{metadata.metadata && renderMetadata()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<TitleBar />
|
||||
<div className="flex-1 p-4 md:p-8">
|
||||
<Sidebar currentPage={currentPage} onPageChange={setCurrentPage} />
|
||||
|
||||
{/* Main content area with sidebar offset */}
|
||||
<div className="flex-1 ml-14 mt-10 p-4 md:p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} />
|
||||
|
||||
{/* Download Progress Toast */}
|
||||
<DownloadProgressToast />
|
||||
|
||||
{/* Timeout Dialog */}
|
||||
<Dialog
|
||||
open={metadata.showTimeoutDialog}
|
||||
onOpenChange={metadata.setShowTimeoutDialog}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||
<div className="absolute right-4 top-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||
onClick={() => metadata.setShowTimeoutDialog(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogTitle className="text-sm font-medium">Fetch Artist</DialogTitle>
|
||||
<DialogDescription>
|
||||
Set timeout for fetching metadata. Longer timeout is recommended for artists
|
||||
with large discography.
|
||||
</DialogDescription>
|
||||
{metadata.pendingArtistName && (
|
||||
<div className="py-2">
|
||||
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.pendingArtistName}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timeout">Timeout (seconds)</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
min="10"
|
||||
max="600"
|
||||
value={metadata.timeoutValue}
|
||||
onChange={(e) => metadata.setTimeoutValue(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default: 60 seconds. For large discographies, try 300-600 seconds (5-10
|
||||
minutes).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => metadata.setShowTimeoutDialog(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={metadata.handleConfirmFetch}>
|
||||
<Search className="h-4 w-4" />
|
||||
Fetch
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Album Fetch Dialog */}
|
||||
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
|
||||
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
|
||||
<div className="absolute right-4 top-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-70 hover:opacity-100"
|
||||
onClick={() => metadata.setShowAlbumDialog(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle>
|
||||
<DialogDescription>
|
||||
Do you want to fetch metadata for this album?
|
||||
</DialogDescription>
|
||||
{metadata.selectedAlbum && (
|
||||
<div className="py-2">
|
||||
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.selectedAlbum.name}</p>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={async () => {
|
||||
const albumUrl = await metadata.handleConfirmAlbumFetch();
|
||||
if (albumUrl) {
|
||||
setSpotifyUrl(albumUrl);
|
||||
}
|
||||
}}>
|
||||
<Search className="h-4 w-4" />
|
||||
Fetch Album
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<SearchBar
|
||||
url={spotifyUrl}
|
||||
loading={metadata.loading}
|
||||
onUrlChange={setSpotifyUrl}
|
||||
onFetch={handleFetchMetadata}
|
||||
history={fetchHistory}
|
||||
onHistorySelect={handleHistorySelect}
|
||||
onHistoryRemove={removeFromHistory}
|
||||
hasResult={!!metadata.metadata}
|
||||
/>
|
||||
|
||||
{metadata.metadata && renderMetadata()}
|
||||
{renderPage()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download Progress Toast - Bottom Left */}
|
||||
<DownloadProgressToast onClick={downloadQueue.openQueue} />
|
||||
|
||||
{/* Download Queue Dialog */}
|
||||
<DownloadQueue
|
||||
isOpen={downloadQueue.isOpen}
|
||||
onClose={downloadQueue.closeQueue}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen } from "lucide-react";
|
||||
import { Download, FolderOpen, ImageDown } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { SearchAndSort } from "./SearchAndSort";
|
||||
import { TrackList } from "./TrackList";
|
||||
import { DownloadProgress } from "./DownloadProgress";
|
||||
@@ -39,13 +40,21 @@ interface AlbumInfoProps {
|
||||
// Availability props
|
||||
checkingAvailabilityTrack?: string | null;
|
||||
availabilityMap?: Map<string, TrackAvailability>;
|
||||
// Cover props
|
||||
downloadedCovers?: Set<string>;
|
||||
failedCovers?: Set<string>;
|
||||
skippedCovers?: Set<string>;
|
||||
downloadingCoverTrack?: string | null;
|
||||
isBulkDownloadingCovers?: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => 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, position?: number) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
|
||||
onCheckAvailability?: (spotifyId: string) => void;
|
||||
onDownloadAllCovers?: () => void;
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
onStopDownload: () => void;
|
||||
@@ -77,13 +86,20 @@ export function AlbumInfo({
|
||||
downloadingLyricsTrack,
|
||||
checkingAvailabilityTrack,
|
||||
availabilityMap,
|
||||
downloadedCovers,
|
||||
failedCovers,
|
||||
skippedCovers,
|
||||
downloadingCoverTrack,
|
||||
isBulkDownloadingCovers,
|
||||
onSearchChange,
|
||||
onSortChange,
|
||||
onToggleTrack,
|
||||
onToggleSelectAll,
|
||||
onDownloadTrack,
|
||||
onDownloadLyrics,
|
||||
onDownloadCover,
|
||||
onCheckAvailability,
|
||||
onDownloadAllCovers,
|
||||
onDownloadAll,
|
||||
onDownloadSelected,
|
||||
onStopDownload,
|
||||
@@ -154,6 +170,22 @@ export function AlbumInfo({
|
||||
Download Selected ({selectedTracks.length})
|
||||
</Button>
|
||||
)}
|
||||
{onDownloadAllCovers && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onDownloadAllCovers}
|
||||
variant="outline"
|
||||
disabled={isBulkDownloadingCovers}
|
||||
>
|
||||
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download All Covers</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{downloadedTracks.size > 0 && (
|
||||
<Button onClick={onOpenFolder} variant="outline">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
@@ -204,6 +236,11 @@ export function AlbumInfo({
|
||||
onToggleSelectAll={onToggleSelectAll}
|
||||
onDownloadTrack={onDownloadTrack}
|
||||
onDownloadLyrics={onDownloadLyrics}
|
||||
onDownloadCover={onDownloadCover}
|
||||
downloadedCovers={downloadedCovers}
|
||||
failedCovers={failedCovers}
|
||||
skippedCovers={skippedCovers}
|
||||
downloadingCoverTrack={downloadingCoverTrack}
|
||||
onCheckAvailability={onCheckAvailability}
|
||||
onPageChange={onPageChange}
|
||||
onTrackClick={onTrackClick}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen } from "lucide-react";
|
||||
import { Download, FolderOpen, ImageDown } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { SearchAndSort } from "./SearchAndSort";
|
||||
import { TrackList } from "./TrackList";
|
||||
import { DownloadProgress } from "./DownloadProgress";
|
||||
@@ -44,13 +45,21 @@ interface ArtistInfoProps {
|
||||
// Availability props
|
||||
checkingAvailabilityTrack?: string | null;
|
||||
availabilityMap?: Map<string, TrackAvailability>;
|
||||
// Cover props
|
||||
downloadedCovers?: Set<string>;
|
||||
failedCovers?: Set<string>;
|
||||
skippedCovers?: Set<string>;
|
||||
downloadingCoverTrack?: string | null;
|
||||
isBulkDownloadingCovers?: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => 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, position?: number) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
|
||||
onCheckAvailability?: (spotifyId: string) => void;
|
||||
onDownloadAllCovers?: () => void;
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
onStopDownload: () => void;
|
||||
@@ -84,13 +93,20 @@ export function ArtistInfo({
|
||||
downloadingLyricsTrack,
|
||||
checkingAvailabilityTrack,
|
||||
availabilityMap,
|
||||
downloadedCovers,
|
||||
failedCovers,
|
||||
skippedCovers,
|
||||
downloadingCoverTrack,
|
||||
isBulkDownloadingCovers,
|
||||
onSearchChange,
|
||||
onSortChange,
|
||||
onToggleTrack,
|
||||
onToggleSelectAll,
|
||||
onDownloadTrack,
|
||||
onDownloadLyrics,
|
||||
onDownloadCover,
|
||||
onCheckAvailability,
|
||||
onDownloadAllCovers,
|
||||
onDownloadAll,
|
||||
onDownloadSelected,
|
||||
onStopDownload,
|
||||
@@ -196,6 +212,23 @@ export function ArtistInfo({
|
||||
Download Selected ({selectedTracks.length})
|
||||
</Button>
|
||||
)}
|
||||
{onDownloadAllCovers && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onDownloadAllCovers}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isBulkDownloadingCovers}
|
||||
>
|
||||
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download All Covers</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{downloadedTracks.size > 0 && (
|
||||
<Button onClick={onOpenFolder} size="sm" variant="outline">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
@@ -243,6 +276,11 @@ export function ArtistInfo({
|
||||
onToggleSelectAll={onToggleSelectAll}
|
||||
onDownloadTrack={onDownloadTrack}
|
||||
onDownloadLyrics={onDownloadLyrics}
|
||||
onDownloadCover={onDownloadCover}
|
||||
downloadedCovers={downloadedCovers}
|
||||
failedCovers={failedCovers}
|
||||
skippedCovers={skippedCovers}
|
||||
downloadingCoverTrack={downloadingCoverTrack}
|
||||
onCheckAvailability={onCheckAvailability}
|
||||
onPageChange={onPageChange}
|
||||
onAlbumClick={onAlbumClick}
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { useState, useCallback } 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";
|
||||
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function AudioAnalysisDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
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];
|
||||
|
||||
// Check if it's a FLAC file
|
||||
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]);
|
||||
|
||||
// Register drag and drop handlers when dialog is open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
OnFileDrop((x, y, paths) => {
|
||||
handleFileDrop(x, y, paths);
|
||||
}, true);
|
||||
|
||||
return () => {
|
||||
OnFileDropOff();
|
||||
};
|
||||
}
|
||||
}, [open, handleFileDrop]);
|
||||
|
||||
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 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-16 w-16 mb-4 transition-colors ${isDragging ? "text-primary" : "text-muted-foreground/50"}`} />
|
||||
<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">
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 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,159 @@
|
||||
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">
|
||||
{onBack && (
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
|
||||
</div>
|
||||
|
||||
{/* File Selection */}
|
||||
{!result && !analyzing && (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center py-24 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Trash2, Copy, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { logger, type LogEntry } from "@/lib/logger";
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
info: "text-blue-500",
|
||||
success: "text-green-500",
|
||||
warning: "text-yellow-500",
|
||||
error: "text-red-500",
|
||||
debug: "text-gray-500",
|
||||
};
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function DebugLoggerPage() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = logger.subscribe(() => {
|
||||
setLogs(logger.getLogs());
|
||||
});
|
||||
setLogs(logger.getLogs());
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
const handleClear = () => {
|
||||
logger.clear();
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
const logText = logs
|
||||
.map((log) => `[${formatTime(log.timestamp)}] [${log.level}] ${log.message}`)
|
||||
.join("\n");
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(logText);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 500);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy logs:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Debug Logs</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={handleCopy}
|
||||
disabled={logs.length === 0}
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={handleClear}
|
||||
disabled={logs.length === 0}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-[calc(100vh-220px)] overflow-y-auto bg-muted/50 rounded-lg p-4 font-mono text-xs"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-muted-foreground lowercase">no logs yet...</p>
|
||||
) : (
|
||||
logs.map((log, i) => (
|
||||
<div key={i} className="flex gap-2 py-0.5">
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
[{formatTime(log.timestamp)}]
|
||||
</span>
|
||||
<span className={`shrink-0 w-16 ${levelColors[log.level]}`}>
|
||||
[{log.level}]
|
||||
</span>
|
||||
<span className="break-all">{log.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,35 @@
|
||||
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
|
||||
import { Download } from "lucide-react";
|
||||
import { useDownloadQueueData } from "@/hooks/useDownloadQueueData";
|
||||
import { Download, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function DownloadProgressToast() {
|
||||
interface DownloadProgressToastProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) {
|
||||
const progress = useDownloadProgress();
|
||||
const queueInfo = useDownloadQueueData();
|
||||
|
||||
if (!progress.is_downloading) {
|
||||
// Show indicator if there are any queued or downloading items
|
||||
// Don't show for completed/failed/skipped only
|
||||
const hasActiveDownloads = queueInfo.queue.some(
|
||||
item => item.status === "queued" || item.status === "downloading"
|
||||
);
|
||||
|
||||
if (!hasActiveDownloads) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
|
||||
<div className="bg-background border rounded-lg shadow-lg p-3">
|
||||
<div className="fixed bottom-4 left-[calc(56px+1rem)] z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background border rounded-lg shadow-lg p-3 h-auto hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Download className="h-4 w-4 text-primary animate-bounce" />
|
||||
<Download className={`h-4 w-4 text-primary ${progress.is_downloading ? 'animate-bounce' : ''}`} />
|
||||
<div className="flex flex-col min-w-[80px]">
|
||||
<p className="text-sm font-medium font-mono tabular-nums">
|
||||
{progress.mb_downloaded.toFixed(2)} MB
|
||||
@@ -23,8 +40,9 @@ export function DownloadProgressToast() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground ml-1" />
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Settings } from "@/components/Settings";
|
||||
import { AudioAnalysisDialog } from "@/components/AudioAnalysisDialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
import { formatRelativeTime } from "@/lib/relative-time";
|
||||
|
||||
interface HeaderProps {
|
||||
version: string;
|
||||
hasUpdate: boolean;
|
||||
releaseDate?: string | null;
|
||||
}
|
||||
|
||||
export function Header({ version, hasUpdate }: HeaderProps) {
|
||||
export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="text-center space-y-2">
|
||||
@@ -31,16 +31,24 @@ export function Header({ version, hasUpdate }: HeaderProps) {
|
||||
SpotiFLAC
|
||||
</h1>
|
||||
<div className="relative">
|
||||
<Badge variant="default" asChild>
|
||||
<a
|
||||
href="https://github.com/afkarxyz/SpotiFLAC/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
v{version}
|
||||
</a>
|
||||
</Badge>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="default" asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/releases")}
|
||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
v{version}
|
||||
</button>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
{hasUpdate && releaseDate && (
|
||||
<TooltipContent>
|
||||
<p>{formatRelativeTime(releaseDate)}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
{hasUpdate && (
|
||||
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
@@ -53,29 +61,6 @@ export function Header({ version, hasUpdate }: HeaderProps) {
|
||||
Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music — no account required.
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute right-0 top-0 flex gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon" asChild>
|
||||
<a
|
||||
href="https://github.com/afkarxyz/SpotiFLAC/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub Issues"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Report bug or request feature</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<AudioAnalysisDialog />
|
||||
<Settings />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen } from "lucide-react";
|
||||
import { Download, FolderOpen, ImageDown } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { SearchAndSort } from "./SearchAndSort";
|
||||
import { TrackList } from "./TrackList";
|
||||
import { DownloadProgress } from "./DownloadProgress";
|
||||
@@ -43,13 +44,21 @@ interface PlaylistInfoProps {
|
||||
// Availability props
|
||||
checkingAvailabilityTrack?: string | null;
|
||||
availabilityMap?: Map<string, TrackAvailability>;
|
||||
// Cover props
|
||||
downloadedCovers?: Set<string>;
|
||||
failedCovers?: Set<string>;
|
||||
skippedCovers?: Set<string>;
|
||||
downloadingCoverTrack?: string | null;
|
||||
isBulkDownloadingCovers?: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => 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, position?: number) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
|
||||
onCheckAvailability?: (spotifyId: string) => void;
|
||||
onDownloadAllCovers?: () => void;
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
onStopDownload: () => void;
|
||||
@@ -82,13 +91,20 @@ export function PlaylistInfo({
|
||||
downloadingLyricsTrack,
|
||||
checkingAvailabilityTrack,
|
||||
availabilityMap,
|
||||
downloadedCovers,
|
||||
failedCovers,
|
||||
skippedCovers,
|
||||
downloadingCoverTrack,
|
||||
isBulkDownloadingCovers,
|
||||
onSearchChange,
|
||||
onSortChange,
|
||||
onToggleTrack,
|
||||
onToggleSelectAll,
|
||||
onDownloadTrack,
|
||||
onDownloadLyrics,
|
||||
onDownloadCover,
|
||||
onCheckAvailability,
|
||||
onDownloadAllCovers,
|
||||
onDownloadAll,
|
||||
onDownloadSelected,
|
||||
onStopDownload,
|
||||
@@ -145,6 +161,22 @@ export function PlaylistInfo({
|
||||
Download Selected ({selectedTracks.length})
|
||||
</Button>
|
||||
)}
|
||||
{onDownloadAllCovers && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onDownloadAllCovers}
|
||||
variant="outline"
|
||||
disabled={isBulkDownloadingCovers}
|
||||
>
|
||||
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download All Covers</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{downloadedTracks.size > 0 && (
|
||||
<Button onClick={onOpenFolder} variant="outline">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
@@ -191,10 +223,15 @@ export function PlaylistInfo({
|
||||
downloadingLyricsTrack={downloadingLyricsTrack}
|
||||
checkingAvailabilityTrack={checkingAvailabilityTrack}
|
||||
availabilityMap={availabilityMap}
|
||||
downloadedCovers={downloadedCovers}
|
||||
failedCovers={failedCovers}
|
||||
skippedCovers={skippedCovers}
|
||||
downloadingCoverTrack={downloadingCoverTrack}
|
||||
onToggleTrack={onToggleTrack}
|
||||
onToggleSelectAll={onToggleSelectAll}
|
||||
onDownloadTrack={onDownloadTrack}
|
||||
onDownloadLyrics={onDownloadLyrics}
|
||||
onDownloadCover={onDownloadCover}
|
||||
onCheckAvailability={onCheckAvailability}
|
||||
onPageChange={onPageChange}
|
||||
onAlbumClick={onAlbumClick}
|
||||
|
||||
@@ -16,11 +16,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw, Info, X } from "lucide-react";
|
||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, type Settings as SettingsType } from "@/lib/settings";
|
||||
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw, Info, X, Volume2 } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FolderPreset, type FilenamePreset } from "@/lib/settings";
|
||||
import { themes, applyTheme } from "@/lib/themes";
|
||||
import { SelectFolder } from "../../wailsjs/go/main/App";
|
||||
|
||||
@@ -281,7 +281,7 @@ export function Settings() {
|
||||
|
||||
{/* Theme Mode Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme-mode">Theme</Label>
|
||||
<Label htmlFor="theme-mode">Mode</Label>
|
||||
<Select value={tempSettings.themeMode} onValueChange={handleThemeModeChange}>
|
||||
<SelectTrigger id="theme-mode">
|
||||
<SelectValue placeholder="Select theme mode" />
|
||||
@@ -294,9 +294,9 @@ export function Settings() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Theme Color Selection */}
|
||||
{/* Accent Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme">Theme Color</Label>
|
||||
<Label htmlFor="theme">Accent</Label>
|
||||
<Select value={tempSettings.theme} onValueChange={handleThemeChange}>
|
||||
<SelectTrigger id="theme">
|
||||
<SelectValue placeholder="Select a theme" />
|
||||
@@ -324,81 +324,117 @@ export function Settings() {
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="space-y-4">
|
||||
{/* Filename Format */}
|
||||
{/* Folder Structure */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Filename Format</Label>
|
||||
<RadioGroup
|
||||
value={tempSettings.filenameFormat}
|
||||
onValueChange={(value) => setTempSettings(prev => ({ ...prev, filenameFormat: value as any }))}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Folder Structure</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Select
|
||||
value={tempSettings.folderPreset}
|
||||
onValueChange={(value: FolderPreset) => {
|
||||
const preset = FOLDER_PRESETS[value];
|
||||
setTempSettings(prev => ({
|
||||
...prev,
|
||||
folderPreset: value,
|
||||
folderTemplate: value === "custom" ? prev.folderTemplate : preset.template
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="title-artist" id="title-artist" />
|
||||
<Label htmlFor="title-artist" className="cursor-pointer font-normal text-sm">Title - Artist</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="artist-title" id="artist-title" />
|
||||
<Label htmlFor="artist-title" className="cursor-pointer font-normal text-sm">Artist - Title</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="title" id="title" />
|
||||
<Label htmlFor="title" className="cursor-pointer font-normal text-sm">Title</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (
|
||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tempSettings.folderPreset === "custom" && (
|
||||
<InputWithContext
|
||||
value={tempSettings.folderTemplate}
|
||||
onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))}
|
||||
placeholder="{artist}/{album}"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
)}
|
||||
{tempSettings.folderTemplate && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{album\}/g, "1989").replace(/\{year\}/g, "2014")}/</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t" />
|
||||
|
||||
{/* Folder Settings */}
|
||||
{/* Filename Format */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium text-sm">Folder Settings</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="track-number"
|
||||
checked={tempSettings.trackNumber}
|
||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, trackNumber: checked as boolean }))}
|
||||
/>
|
||||
<Label htmlFor="track-number" className="cursor-pointer text-sm">Track Number</Label>
|
||||
<Label className="text-sm">Filename Format</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<p className="text-xs text-center">Adds track numbers to filenames. Uses album track numbers when Album Subfolder is enabled, otherwise uses playlist position</p>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="artist-subfolder"
|
||||
checked={tempSettings.artistSubfolder}
|
||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, artistSubfolder: checked as boolean }))}
|
||||
<Select
|
||||
value={tempSettings.filenamePreset}
|
||||
onValueChange={(value: FilenamePreset) => {
|
||||
const preset = FILENAME_PRESETS[value];
|
||||
setTempSettings(prev => ({
|
||||
...prev,
|
||||
filenamePreset: value,
|
||||
filenameTemplate: value === "custom" ? prev.filenameTemplate : preset.template
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (
|
||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tempSettings.filenamePreset === "custom" && (
|
||||
<InputWithContext
|
||||
value={tempSettings.filenameTemplate}
|
||||
onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))}
|
||||
placeholder="{track}. {title}"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<Label htmlFor="artist-subfolder" className="cursor-pointer text-sm">Artist Subfolder</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<p className="text-xs text-center">Playlist only</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{tempSettings.filenameTemplate && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{title\}/g, "Shake It Off").replace(/\{track\}/g, "01").replace(/\{year\}/g, "2014")}.flac</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t" />
|
||||
|
||||
{/* Sound Effects */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="album-subfolder"
|
||||
checked={tempSettings.albumSubfolder}
|
||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, albumSubfolder: checked as boolean }))}
|
||||
/>
|
||||
<Label htmlFor="album-subfolder" className="cursor-pointer text-sm">Album Subfolder</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<p className="text-xs text-center">Playlist & Discography</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Volume2 className="h-4 w-4 text-muted-foreground" />
|
||||
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
|
||||
</div>
|
||||
<Switch
|
||||
id="sfx-enabled"
|
||||
checked={tempSettings.sfxEnabled}
|
||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputWithContext } from "@/components/ui/input-with-context";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { FolderOpen, Save, RotateCcw, Info, Volume2 } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings";
|
||||
import { themes, applyTheme } from "@/lib/themes";
|
||||
import { SelectFolder } from "../../wailsjs/go/main/App";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
|
||||
// Service Icons
|
||||
const TidalIcon = () => (
|
||||
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||
<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>
|
||||
);
|
||||
|
||||
const DeezerIcon = () => (
|
||||
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||
<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>
|
||||
);
|
||||
|
||||
const QobuzIcon = () => (
|
||||
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||
<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>
|
||||
);
|
||||
|
||||
const AmazonIcon = () => (
|
||||
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
|
||||
<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>
|
||||
);
|
||||
|
||||
export function SettingsPage() {
|
||||
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
|
||||
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
|
||||
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
|
||||
|
||||
useEffect(() => {
|
||||
applyThemeMode(savedSettings.themeMode);
|
||||
applyTheme(savedSettings.theme);
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = () => {
|
||||
if (savedSettings.themeMode === "auto") {
|
||||
applyThemeMode("auto");
|
||||
applyTheme(savedSettings.theme);
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}, [savedSettings.themeMode, savedSettings.theme]);
|
||||
|
||||
useEffect(() => {
|
||||
applyThemeMode(tempSettings.themeMode);
|
||||
applyTheme(tempSettings.theme);
|
||||
applyFont(tempSettings.fontFamily);
|
||||
setTimeout(() => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'));
|
||||
}, 0);
|
||||
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadDefaults = async () => {
|
||||
if (!savedSettings.downloadPath) {
|
||||
const settingsWithDefaults = await getSettingsWithDefaults();
|
||||
setSavedSettings(settingsWithDefaults);
|
||||
setTempSettings(settingsWithDefaults);
|
||||
}
|
||||
};
|
||||
loadDefaults();
|
||||
}, []);
|
||||
|
||||
const handleSave = () => {
|
||||
saveSettings(tempSettings);
|
||||
setSavedSettings(tempSettings);
|
||||
toast.success("Settings saved");
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
const defaultSettings = await resetToDefaultSettings();
|
||||
setTempSettings(defaultSettings);
|
||||
setSavedSettings(defaultSettings);
|
||||
applyThemeMode(defaultSettings.themeMode);
|
||||
applyTheme(defaultSettings.theme);
|
||||
applyFont(defaultSettings.fontFamily);
|
||||
toast.success("Settings reset to default");
|
||||
};
|
||||
|
||||
const handleBrowseFolder = async () => {
|
||||
try {
|
||||
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
|
||||
if (selectedPath && selectedPath.trim() !== "") {
|
||||
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error selecting folder:", error);
|
||||
toast.error(`Error selecting folder: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Left Column */}
|
||||
<div className="space-y-4">
|
||||
{/* Download Path */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="download-path">Download Path</Label>
|
||||
<div className="flex gap-2">
|
||||
<InputWithContext
|
||||
id="download-path"
|
||||
value={tempSettings.downloadPath}
|
||||
onChange={(e) => setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))}
|
||||
placeholder="C:\Users\YourUsername\Music"
|
||||
/>
|
||||
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="downloader">Source</Label>
|
||||
<Select
|
||||
value={tempSettings.downloader}
|
||||
onValueChange={(value: "auto" | "deezer" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}
|
||||
>
|
||||
<SelectTrigger id="downloader">
|
||||
<SelectValue placeholder="Select a source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="tidal">
|
||||
<span className="flex items-center"><TidalIcon />Tidal</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="deezer">
|
||||
<span className="flex items-center"><DeezerIcon />Deezer</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="qobuz">
|
||||
<span className="flex items-center"><QobuzIcon />Qobuz</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="amazon">
|
||||
<span className="flex items-center"><AmazonIcon />Amazon Music</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Theme Mode */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme-mode">Mode</Label>
|
||||
<Select
|
||||
value={tempSettings.themeMode}
|
||||
onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}
|
||||
>
|
||||
<SelectTrigger id="theme-mode">
|
||||
<SelectValue placeholder="Select theme mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Accent */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme">Accent</Label>
|
||||
<Select
|
||||
value={tempSettings.theme}
|
||||
onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}
|
||||
>
|
||||
<SelectTrigger id="theme">
|
||||
<SelectValue placeholder="Select a theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{themes.map((theme) => (
|
||||
<SelectItem key={theme.name} value={theme.name}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full border border-border"
|
||||
style={{
|
||||
backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary
|
||||
}}
|
||||
/>
|
||||
{theme.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Font */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="font">Font</Label>
|
||||
<Select
|
||||
value={tempSettings.fontFamily}
|
||||
onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}
|
||||
>
|
||||
<SelectTrigger id="font">
|
||||
<SelectValue placeholder="Select a font" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FONT_OPTIONS.map((font) => (
|
||||
<SelectItem key={font.value} value={font.value}>
|
||||
<span style={{ fontFamily: font.fontFamily }}>{font.label}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="space-y-4">
|
||||
{/* Folder Structure */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Folder Structure</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Select
|
||||
value={tempSettings.folderPreset}
|
||||
onValueChange={(value: FolderPreset) => {
|
||||
const preset = FOLDER_PRESETS[value];
|
||||
setTempSettings(prev => ({
|
||||
...prev,
|
||||
folderPreset: value,
|
||||
folderTemplate: value === "custom" ? prev.folderTemplate : preset.template
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (
|
||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tempSettings.folderPreset === "custom" && (
|
||||
<InputWithContext
|
||||
value={tempSettings.folderTemplate}
|
||||
onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))}
|
||||
placeholder="{artist}/{album}"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
)}
|
||||
{tempSettings.folderTemplate && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{album\}/g, "1989").replace(/\{year\}/g, "2014")}/</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4" />
|
||||
|
||||
{/* Filename Format */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Filename Format</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Select
|
||||
value={tempSettings.filenamePreset}
|
||||
onValueChange={(value: FilenamePreset) => {
|
||||
const preset = FILENAME_PRESETS[value];
|
||||
setTempSettings(prev => ({
|
||||
...prev,
|
||||
filenamePreset: value,
|
||||
filenameTemplate: value === "custom" ? prev.filenameTemplate : preset.template
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (
|
||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tempSettings.filenamePreset === "custom" && (
|
||||
<InputWithContext
|
||||
value={tempSettings.filenameTemplate}
|
||||
onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))}
|
||||
placeholder="{track}. {title}"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
)}
|
||||
{tempSettings.filenameTemplate && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{title\}/g, "Shake It Off").replace(/\{track\}/g, "01").replace(/\{year\}/g, "2014")}.flac</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4" />
|
||||
|
||||
{/* Sound Effects */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Volume2 className="h-4 w-4 text-muted-foreground" />
|
||||
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
|
||||
<Switch
|
||||
id="sfx-enabled"
|
||||
checked={tempSettings.sfxEnabled}
|
||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 justify-between pt-4 border-t">
|
||||
<Button variant="outline" onClick={handleReset} className="gap-1.5">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Reset to Default
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="gap-1.5">
|
||||
<Save className="h-4 w-4" />
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Home, Settings, Bug, Activity, LayoutGrid } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openExternal } from "@/lib/utils";
|
||||
|
||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis";
|
||||
|
||||
interface SidebarProps {
|
||||
currentPage: PageType;
|
||||
onPageChange: (page: PageType) => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
const navItems = [
|
||||
{ id: "main" as PageType, icon: Home, label: "Home" },
|
||||
{ id: "settings" as PageType, icon: Settings, label: "Settings" },
|
||||
{ id: "audio-analysis" as PageType, icon: Activity, label: "Audio Quality Analyzer" },
|
||||
{ id: "debug" as PageType, icon: Bug, label: "Debug Logs" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
{navItems.map((item) => (
|
||||
<Tooltip key={item.id} delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === item.id ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange(item.id)}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* GitHub - below debug */}
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")}
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Report Bug</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Other Projects at bottom */}
|
||||
<div className="mt-auto">
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => openExternal("https://exyezed.cc/")}
|
||||
>
|
||||
<LayoutGrid className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Other Projects</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { X, Minus, Square } from "lucide-react";
|
||||
import { X, Minus, Maximize } from "lucide-react";
|
||||
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
|
||||
|
||||
export function TitleBar() {
|
||||
const [hoveredButton, setHoveredButton] = useState<string | null>(null);
|
||||
|
||||
const handleMinimize = () => {
|
||||
WindowMinimise();
|
||||
};
|
||||
@@ -21,48 +18,36 @@ export function TitleBar() {
|
||||
<>
|
||||
{/* Draggable area */}
|
||||
<div
|
||||
className="fixed top-0 left-0 right-0 h-12 z-40"
|
||||
className="fixed top-0 left-14 right-0 h-10 z-40 bg-background/80 backdrop-blur-sm"
|
||||
style={{ "--wails-draggable": "drag" } as React.CSSProperties}
|
||||
onDoubleClick={handleMaximize}
|
||||
/>
|
||||
|
||||
{/* Window control buttons */}
|
||||
<div className="fixed top-4 left-4 z-50 flex gap-2">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
onMouseEnter={() => setHoveredButton("close")}
|
||||
onMouseLeave={() => setHoveredButton(null)}
|
||||
className="w-3 h-3 rounded-full bg-red-500 hover:bg-red-600 transition-colors flex items-center justify-center"
|
||||
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
||||
aria-label="Close"
|
||||
>
|
||||
{hoveredButton === "close" && (
|
||||
<X className="w-2 h-2 text-red-900" strokeWidth={3} />
|
||||
)}
|
||||
</button>
|
||||
{/* Window control buttons - Windows style, right side */}
|
||||
<div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5">
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
onMouseEnter={() => setHoveredButton("minimize")}
|
||||
onMouseLeave={() => setHoveredButton(null)}
|
||||
className="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-600 transition-colors flex items-center justify-center"
|
||||
className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded"
|
||||
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
||||
aria-label="Minimize"
|
||||
>
|
||||
{hoveredButton === "minimize" && (
|
||||
<Minus className="w-2 h-2 text-yellow-900" strokeWidth={3} />
|
||||
)}
|
||||
<Minus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMaximize}
|
||||
onMouseEnter={() => setHoveredButton("maximize")}
|
||||
onMouseLeave={() => setHoveredButton(null)}
|
||||
className="w-3 h-3 rounded-full bg-green-500 hover:bg-green-600 transition-colors flex items-center justify-center"
|
||||
className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded"
|
||||
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
||||
aria-label="Maximize"
|
||||
>
|
||||
{hoveredButton === "maximize" && (
|
||||
<Square className="w-1.5 h-1.5 text-green-900" strokeWidth={3} />
|
||||
)}
|
||||
<Maximize className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="w-8 h-7 flex items-center justify-center hover:bg-destructive hover:text-white transition-colors rounded"
|
||||
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen, CheckCircle, XCircle, FileText, SkipForward, Globe } from "lucide-react";
|
||||
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -16,15 +17,18 @@ interface TrackInfoProps {
|
||||
downloadingTrack: string | null;
|
||||
isDownloaded: boolean;
|
||||
isFailed: boolean;
|
||||
isSkipped: boolean;
|
||||
downloadingLyricsTrack?: string | null;
|
||||
downloadedLyrics?: boolean;
|
||||
failedLyrics?: boolean;
|
||||
skippedLyrics?: boolean;
|
||||
checkingAvailability?: boolean;
|
||||
availability?: TrackAvailability;
|
||||
downloadingCover?: boolean;
|
||||
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string) => void;
|
||||
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string) => void;
|
||||
onOpenFolder: () => void;
|
||||
}
|
||||
|
||||
@@ -34,85 +38,72 @@ export function TrackInfo({
|
||||
downloadingTrack,
|
||||
isDownloaded,
|
||||
isFailed,
|
||||
isSkipped,
|
||||
downloadingLyricsTrack,
|
||||
downloadedLyrics,
|
||||
failedLyrics,
|
||||
skippedLyrics,
|
||||
checkingAvailability,
|
||||
availability,
|
||||
downloadingCover,
|
||||
onDownload,
|
||||
onDownloadLyrics,
|
||||
onCheckAvailability,
|
||||
onDownloadCover,
|
||||
onOpenFolder,
|
||||
}: TrackInfoProps) {
|
||||
const [isHoveringCover, setIsHoveringCover] = useState(false);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="shrink-0">
|
||||
<div
|
||||
className="shrink-0 relative"
|
||||
onMouseEnter={() => setIsHoveringCover(true)}
|
||||
onMouseLeave={() => setIsHoveringCover(false)}
|
||||
>
|
||||
{track.images && (
|
||||
<img
|
||||
src={track.images}
|
||||
alt={track.name}
|
||||
className="w-48 h-48 rounded-md shadow-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
{/* Availability Icons - below cover art */}
|
||||
{availability && (
|
||||
<div className="flex items-center justify-center gap-2 mt-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className={`${availability.tidal ? "text-green-500" : "text-red-500"}`}>
|
||||
<TidalIcon className="w-5 h-5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Tidal</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className={`${availability.deezer ? "text-green-500" : "text-red-500"}`}>
|
||||
<DeezerIcon className="w-5 h-5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Deezer</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className={`${availability.amazon ? "text-green-500" : "text-red-500"}`}>
|
||||
<AmazonIcon className="w-5 h-5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Amazon Music</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className={`${availability.qobuz ? "text-green-500" : "text-red-500"}`}>
|
||||
<QobuzIcon className="w-5 h-5" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Qobuz</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<>
|
||||
<img
|
||||
src={track.images}
|
||||
alt={track.name}
|
||||
className="w-48 h-48 rounded-md shadow-lg object-cover"
|
||||
/>
|
||||
{isHoveringCover && onDownloadCover && (
|
||||
<div className="absolute inset-0 bg-black/50 rounded-md flex items-center justify-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="cursor-pointer"
|
||||
onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name)}
|
||||
disabled={downloadingCover}
|
||||
>
|
||||
{downloadingCover ? <Spinner /> : <ImageDown className="h-5 w-5" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Cover</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-4 min-w-0">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<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" />
|
||||
)}
|
||||
{isFailed && (
|
||||
) : isFailed ? (
|
||||
<XCircle className="h-6 w-6 text-red-500 shrink-0" />
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-lg text-muted-foreground">{track.artists}</p>
|
||||
</div>
|
||||
@@ -141,26 +132,6 @@ export function TrackInfo({
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{track.spotify_id && onCheckAvailability && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)}
|
||||
variant="outline"
|
||||
disabled={checkingAvailability}
|
||||
>
|
||||
{checkingAvailability ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Globe className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Check Availability</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{track.spotify_id && onDownloadLyrics && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -172,7 +143,7 @@ export function TrackInfo({
|
||||
{downloadingLyricsTrack === track.spotify_id ? (
|
||||
<Spinner />
|
||||
) : skippedLyrics ? (
|
||||
<SkipForward className="h-4 w-4 text-yellow-500" />
|
||||
<FileCheck className="h-4 w-4 text-yellow-500" />
|
||||
) : downloadedLyrics ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : failedLyrics ? (
|
||||
@@ -187,6 +158,37 @@ export function TrackInfo({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{isDownloaded && (
|
||||
<Button onClick={onOpenFolder} variant="outline">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Download, CheckCircle, XCircle, SkipForward, FileText, Globe } from "lucide-react";
|
||||
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -42,11 +42,17 @@ interface TrackListProps {
|
||||
// Availability props
|
||||
checkingAvailabilityTrack?: string | null;
|
||||
availabilityMap?: Map<string, TrackAvailability>;
|
||||
// Cover props
|
||||
downloadedCovers?: Set<string>;
|
||||
failedCovers?: Set<string>;
|
||||
skippedCovers?: Set<string>;
|
||||
downloadingCoverTrack?: string | null;
|
||||
onToggleTrack: (isrc: string) => 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, durationMs?: number, position?: number) => void;
|
||||
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
|
||||
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
|
||||
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void;
|
||||
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
|
||||
@@ -75,11 +81,16 @@ export function TrackList({
|
||||
downloadingLyricsTrack,
|
||||
checkingAvailabilityTrack,
|
||||
availabilityMap,
|
||||
downloadedCovers,
|
||||
failedCovers,
|
||||
skippedCovers,
|
||||
downloadingCoverTrack,
|
||||
onToggleTrack,
|
||||
onToggleSelectAll,
|
||||
onDownloadTrack,
|
||||
onDownloadLyrics,
|
||||
onCheckAvailability,
|
||||
onDownloadCover,
|
||||
onPageChange,
|
||||
onAlbumClick,
|
||||
onArtistClick,
|
||||
@@ -210,7 +221,7 @@ export function TrackList({
|
||||
<span className="font-medium">{track.name}</span>
|
||||
)}
|
||||
{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) ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
|
||||
) : failedTracks.has(track.isrc) ? (
|
||||
@@ -286,23 +297,101 @@ export function TrackList({
|
||||
<td className="p-4 align-middle text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{track.isrc && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, isArtistDiscography)
|
||||
}
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={isDownloading || downloadingTrack === track.isrc}
|
||||
>
|
||||
{downloadingTrack === track.isrc ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1)
|
||||
}
|
||||
size="sm"
|
||||
disabled={isDownloading || downloadingTrack === track.isrc}
|
||||
>
|
||||
{downloadingTrack === track.isrc ? (
|
||||
<Spinner />
|
||||
) : skippedTracks.has(track.isrc) ? (
|
||||
<FileCheck className="h-4 w-4" />
|
||||
) : downloadedTracks.has(track.isrc) ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : failedTracks.has(track.isrc) ? (
|
||||
<XCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{downloadingTrack === track.isrc ? (
|
||||
<p>Downloading...</p>
|
||||
) : skippedTracks.has(track.isrc) ? (
|
||||
<p>Already exists</p>
|
||||
) : downloadedTracks.has(track.isrc) ? (
|
||||
<p>Downloaded</p>
|
||||
) : failedTracks.has(track.isrc) ? (
|
||||
<p>Failed</p>
|
||||
) : (
|
||||
<p>Download Track</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{track.spotify_id && onDownloadLyrics && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1)
|
||||
}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={downloadingLyricsTrack === track.spotify_id}
|
||||
>
|
||||
{downloadingLyricsTrack === track.spotify_id ? (
|
||||
<Spinner />
|
||||
) : skippedLyrics?.has(track.spotify_id) ? (
|
||||
<FileCheck className="h-4 w-4 text-yellow-500" />
|
||||
) : downloadedLyrics?.has(track.spotify_id) ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : failedLyrics?.has(track.spotify_id) ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Lyric</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{track.images && onDownloadCover && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const trackId = track.spotify_id || `${track.name}-${track.artists}`;
|
||||
onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId);
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}
|
||||
>
|
||||
{downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? (
|
||||
<Spinner />
|
||||
) : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
|
||||
<FileCheck className="h-4 w-4 text-yellow-500" />
|
||||
) : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<ImageDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Cover</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{track.spotify_id && onCheckAvailability && (
|
||||
<Tooltip>
|
||||
@@ -336,35 +425,6 @@ export function TrackList({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{track.spotify_id && onDownloadLyrics && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() =>
|
||||
onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1)
|
||||
}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={downloadingLyricsTrack === track.spotify_id}
|
||||
>
|
||||
{downloadingLyricsTrack === track.spotify_id ? (
|
||||
<Spinner />
|
||||
) : skippedLyrics?.has(track.spotify_id) ? (
|
||||
<SkipForward className="h-4 w-4 text-yellow-500" />
|
||||
) : downloadedLyrics?.has(track.spotify_id) ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : failedLyrics?.has(track.spotify_id) ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download Lyric</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -60,7 +60,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -32,7 +32,7 @@ const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProp
|
||||
setHasSelection(start !== end);
|
||||
};
|
||||
|
||||
// Check clipboard permission
|
||||
// Check clipboard permission when user explicitly opens the context menu.
|
||||
const checkClipboard = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
@@ -42,10 +42,6 @@ const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProp
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
checkClipboard();
|
||||
}, []);
|
||||
|
||||
const handleCut = async () => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
@@ -156,7 +152,13 @@ const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProp
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu onOpenChange={checkClipboard}>
|
||||
<ContextMenu
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
checkClipboard();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ContextMenuTrigger asChild>
|
||||
<Input
|
||||
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 }
|
||||
@@ -36,6 +36,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
left: "calc(56px + 1rem)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"pointer-events-none block size-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0 data-[state=checked]:bg-primary-foreground data-[state=unchecked]:bg-background"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { downloadCover } from "@/lib/api";
|
||||
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { TrackMetadata } from "@/types/api";
|
||||
|
||||
export function useCover() {
|
||||
const [downloadingCover, setDownloadingCover] = useState(false);
|
||||
const [downloadingCoverTrack, setDownloadingCoverTrack] = useState<string | null>(null);
|
||||
const [downloadedCovers, setDownloadedCovers] = useState<Set<string>>(new Set());
|
||||
const [failedCovers, setFailedCovers] = useState<Set<string>>(new Set());
|
||||
const [skippedCovers, setSkippedCovers] = useState<Set<string>>(new Set());
|
||||
const [isBulkDownloadingCovers, setIsBulkDownloadingCovers] = useState(false);
|
||||
const [coverDownloadProgress, setCoverDownloadProgress] = useState(0);
|
||||
const stopBulkDownloadRef = useRef(false);
|
||||
|
||||
const handleDownloadCover = async (
|
||||
coverUrl: string,
|
||||
trackName: string,
|
||||
artistName: string,
|
||||
albumName?: string,
|
||||
playlistName?: string,
|
||||
position?: number,
|
||||
trackId?: string
|
||||
) => {
|
||||
if (!coverUrl) {
|
||||
toast.error("No cover URL found for this track");
|
||||
return;
|
||||
}
|
||||
|
||||
const id = trackId || `${trackName}-${artistName}`;
|
||||
logger.info(`downloading cover: ${trackName} - ${artistName}`);
|
||||
const settings = getSettings();
|
||||
setDownloadingCover(true);
|
||||
setDownloadingCoverTrack(id);
|
||||
|
||||
try {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
|
||||
// Build output path using template system
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName,
|
||||
album: albumName,
|
||||
title: trackName,
|
||||
track: position,
|
||||
playlist: playlistName,
|
||||
};
|
||||
|
||||
// For playlist/discography, prepend the folder name
|
||||
if (playlistName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
|
||||
}
|
||||
|
||||
// Apply folder template
|
||||
if (settings.folderTemplate) {
|
||||
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
||||
if (folderPath) {
|
||||
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||
for (const part of parts) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await downloadCover({
|
||||
cover_url: coverUrl,
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate || "{title}",
|
||||
track_number: settings.trackNumber,
|
||||
position: position || 0,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
if (response.already_exists) {
|
||||
toast.info("Cover file already exists");
|
||||
setSkippedCovers((prev) => new Set(prev).add(id));
|
||||
} else {
|
||||
toast.success("Cover downloaded successfully");
|
||||
setDownloadedCovers((prev) => new Set(prev).add(id));
|
||||
}
|
||||
setFailedCovers((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
toast.error(response.error || "Failed to download cover");
|
||||
setFailedCovers((prev) => new Set(prev).add(id));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to download cover");
|
||||
setFailedCovers((prev) => new Set(prev).add(id));
|
||||
} finally {
|
||||
setDownloadingCover(false);
|
||||
setDownloadingCoverTrack(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadAllCovers = async (
|
||||
tracks: TrackMetadata[],
|
||||
playlistName?: string
|
||||
) => {
|
||||
if (tracks.length === 0) {
|
||||
toast.error("No tracks to download covers");
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
setIsBulkDownloadingCovers(true);
|
||||
setCoverDownloadProgress(0);
|
||||
stopBulkDownloadRef.current = false;
|
||||
|
||||
let completed = 0;
|
||||
let success = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
if (stopBulkDownloadRef.current) {
|
||||
toast.info("Cover download stopped");
|
||||
break;
|
||||
}
|
||||
|
||||
const track = tracks[i];
|
||||
if (!track.images) {
|
||||
completed++;
|
||||
setCoverDownloadProgress(Math.round((completed / tracks.length) * 100));
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = track.spotify_id || `${track.name}-${track.artists}`;
|
||||
setDownloadingCoverTrack(id);
|
||||
|
||||
try {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
|
||||
// Build output path using template system
|
||||
const templateData: TemplateData = {
|
||||
artist: track.artists,
|
||||
album: track.album_name,
|
||||
title: track.name,
|
||||
track: i + 1,
|
||||
playlist: playlistName,
|
||||
};
|
||||
|
||||
// For playlist/discography, prepend the folder name
|
||||
if (playlistName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
|
||||
}
|
||||
|
||||
// Apply folder template
|
||||
if (settings.folderTemplate) {
|
||||
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
||||
if (folderPath) {
|
||||
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||
for (const part of parts) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await downloadCover({
|
||||
cover_url: track.images,
|
||||
track_name: track.name,
|
||||
artist_name: track.artists,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate || "{title}",
|
||||
track_number: settings.trackNumber,
|
||||
position: i + 1,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
if (response.already_exists) {
|
||||
skipped++;
|
||||
setSkippedCovers((prev) => new Set(prev).add(id));
|
||||
} else {
|
||||
success++;
|
||||
setDownloadedCovers((prev) => new Set(prev).add(id));
|
||||
}
|
||||
} else {
|
||||
failed++;
|
||||
setFailedCovers((prev) => new Set(prev).add(id));
|
||||
}
|
||||
} catch {
|
||||
failed++;
|
||||
setFailedCovers((prev) => new Set(prev).add(id));
|
||||
}
|
||||
|
||||
completed++;
|
||||
setCoverDownloadProgress(Math.round((completed / tracks.length) * 100));
|
||||
}
|
||||
|
||||
setDownloadingCoverTrack(null);
|
||||
setIsBulkDownloadingCovers(false);
|
||||
setCoverDownloadProgress(0);
|
||||
|
||||
if (!stopBulkDownloadRef.current) {
|
||||
toast.success(`Covers: ${success} downloaded, ${skipped} skipped, ${failed} failed`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopCoverDownload = () => {
|
||||
stopBulkDownloadRef.current = true;
|
||||
};
|
||||
|
||||
const resetCoverState = () => {
|
||||
setDownloadedCovers(new Set());
|
||||
setFailedCovers(new Set());
|
||||
setSkippedCovers(new Set());
|
||||
};
|
||||
|
||||
return {
|
||||
downloadingCover,
|
||||
downloadingCoverTrack,
|
||||
downloadedCovers,
|
||||
failedCovers,
|
||||
skippedCovers,
|
||||
isBulkDownloadingCovers,
|
||||
coverDownloadProgress,
|
||||
handleDownloadCover,
|
||||
handleDownloadAllCovers,
|
||||
handleStopCoverDownload,
|
||||
resetCoverState,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { downloadTrack } from "@/lib/api";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -27,10 +27,10 @@ export function useDownload() {
|
||||
artistName?: string,
|
||||
albumName?: string,
|
||||
playlistName?: string,
|
||||
isArtistDiscography?: boolean,
|
||||
position?: number,
|
||||
spotifyId?: string,
|
||||
durationMs?: number
|
||||
durationMs?: number,
|
||||
releaseYear?: string
|
||||
) => {
|
||||
let service = settings.downloader;
|
||||
|
||||
@@ -40,26 +40,42 @@ export function useDownload() {
|
||||
let outputDir = settings.downloadPath;
|
||||
let useAlbumTrackNumber = false;
|
||||
|
||||
// Build template data for folder path
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName,
|
||||
album: albumName,
|
||||
title: trackName,
|
||||
track: position,
|
||||
year: releaseYear,
|
||||
playlist: playlistName,
|
||||
isrc: isrc,
|
||||
};
|
||||
|
||||
// For playlist/discography downloads, always create a folder with the playlist/artist name
|
||||
if (playlistName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
|
||||
}
|
||||
|
||||
if (isArtistDiscography) {
|
||||
if (settings.albumSubfolder && albumName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
|
||||
useAlbumTrackNumber = true; // Use album track number for discography with album subfolder
|
||||
}
|
||||
} 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; // Use album track number when both artist and album subfolders are used
|
||||
// Apply folder template if available
|
||||
if (settings.folderTemplate) {
|
||||
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
||||
if (folderPath) {
|
||||
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||
for (const part of parts) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
|
||||
}
|
||||
}
|
||||
|
||||
// Use album track number if template contains {album}
|
||||
if (settings.folderTemplate.includes("{album}")) {
|
||||
useAlbumTrackNumber = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Always add item to queue before downloading
|
||||
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
|
||||
const itemID = await AddToDownloadQueue(isrc, trackName || "", artistName || "", albumName || "");
|
||||
|
||||
if (service === "auto") {
|
||||
// Get all streaming URLs once from song.link API
|
||||
let streamingURLs: any = null;
|
||||
@@ -88,13 +104,14 @@ export function useDownload() {
|
||||
artist_name: artistName,
|
||||
album_name: albumName,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameFormat,
|
||||
filename_format: settings.filenameTemplate,
|
||||
track_number: settings.trackNumber,
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
service_url: streamingURLs.tidal_url,
|
||||
duration: durationSeconds,
|
||||
item_id: itemID, // Pass the same itemID through all attempts
|
||||
});
|
||||
|
||||
if (tidalResponse.success) {
|
||||
@@ -119,12 +136,13 @@ export function useDownload() {
|
||||
artist_name: artistName,
|
||||
album_name: albumName,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameFormat,
|
||||
filename_format: settings.filenameTemplate,
|
||||
track_number: settings.trackNumber,
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
service_url: streamingURLs.deezer_url,
|
||||
item_id: itemID,
|
||||
});
|
||||
|
||||
if (deezerResponse.success) {
|
||||
@@ -149,12 +167,13 @@ export function useDownload() {
|
||||
artist_name: artistName,
|
||||
album_name: albumName,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameFormat,
|
||||
filename_format: settings.filenameTemplate,
|
||||
track_number: settings.trackNumber,
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
service_url: streamingURLs.amazon_url,
|
||||
item_id: itemID,
|
||||
});
|
||||
|
||||
if (amazonResponse.success) {
|
||||
@@ -169,13 +188,37 @@ export function useDownload() {
|
||||
|
||||
// Try Qobuz as last fallback
|
||||
logger.debug(`trying qobuz (fallback) for: ${trackName} - ${artistName}`);
|
||||
service = "qobuz";
|
||||
const qobuzResponse = await downloadTrack({
|
||||
isrc,
|
||||
service: "qobuz",
|
||||
query,
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
album_name: albumName,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameTemplate,
|
||||
track_number: settings.trackNumber,
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
duration: durationMs ? Math.round(durationMs / 1000) : undefined,
|
||||
item_id: itemID,
|
||||
});
|
||||
|
||||
// If Qobuz also failed, mark the item as failed
|
||||
if (!qobuzResponse.success) {
|
||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||
await MarkDownloadItemFailed(itemID, qobuzResponse.error || "All services failed");
|
||||
}
|
||||
|
||||
return qobuzResponse;
|
||||
}
|
||||
|
||||
// Convert duration from ms to seconds for backend (if not already done above)
|
||||
// Single service download (not auto-fallback)
|
||||
// Convert duration from ms to seconds for backend
|
||||
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
|
||||
|
||||
return await downloadTrack({
|
||||
const singleServiceResponse = await downloadTrack({
|
||||
isrc,
|
||||
service: service as "deezer" | "tidal" | "qobuz" | "amazon",
|
||||
query,
|
||||
@@ -183,13 +226,234 @@ export function useDownload() {
|
||||
artist_name: artistName,
|
||||
album_name: albumName,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameFormat,
|
||||
filename_format: settings.filenameTemplate,
|
||||
track_number: settings.trackNumber,
|
||||
position,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
spotify_id: spotifyId,
|
||||
duration: durationSecondsForFallback,
|
||||
item_id: itemID, // Pass itemID for tracking
|
||||
});
|
||||
|
||||
// Mark as failed if download failed for single-service attempt
|
||||
if (!singleServiceResponse.success) {
|
||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||
await MarkDownloadItemFailed(itemID, singleServiceResponse.error || "Download failed");
|
||||
}
|
||||
|
||||
return singleServiceResponse;
|
||||
};
|
||||
|
||||
const downloadWithItemID = async (
|
||||
isrc: string,
|
||||
settings: any,
|
||||
itemID: string,
|
||||
trackName?: string,
|
||||
artistName?: string,
|
||||
albumName?: string,
|
||||
folderName?: string,
|
||||
position?: number,
|
||||
spotifyId?: string,
|
||||
durationMs?: number,
|
||||
isAlbum?: boolean,
|
||||
releaseYear?: string
|
||||
) => {
|
||||
let service = settings.downloader;
|
||||
|
||||
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
||||
const os = settings.operatingSystem;
|
||||
|
||||
let outputDir = settings.downloadPath;
|
||||
let useAlbumTrackNumber = false;
|
||||
|
||||
// Build template data for folder path
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName,
|
||||
album: albumName,
|
||||
title: trackName,
|
||||
track: position,
|
||||
year: releaseYear,
|
||||
playlist: folderName,
|
||||
isrc: isrc,
|
||||
};
|
||||
|
||||
// For playlist/discography downloads, always create a folder with the playlist/artist name
|
||||
if (folderName && !isAlbum) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(folderName, os));
|
||||
}
|
||||
|
||||
// Apply folder template if available
|
||||
if (settings.folderTemplate) {
|
||||
// Parse and apply folder template
|
||||
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
||||
if (folderPath) {
|
||||
// Split by / and sanitize each part
|
||||
const parts = folderPath.split("/").filter(p => p.trim());
|
||||
for (const part of parts) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
|
||||
}
|
||||
}
|
||||
|
||||
// Use album track number if template contains {album}
|
||||
if (settings.folderTemplate.includes("{album}")) {
|
||||
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.filenameTemplate,
|
||||
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.filenameTemplate,
|
||||
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.filenameTemplate,
|
||||
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.filenameTemplate,
|
||||
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.filenameTemplate,
|
||||
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 (
|
||||
@@ -199,8 +463,8 @@ export function useDownload() {
|
||||
albumName?: string,
|
||||
spotifyId?: string,
|
||||
playlistName?: string,
|
||||
isArtistDiscography?: boolean,
|
||||
durationMs?: number
|
||||
durationMs?: number,
|
||||
position?: number
|
||||
) => {
|
||||
if (!isrc) {
|
||||
toast.error("No ISRC found for this track");
|
||||
@@ -220,8 +484,7 @@ export function useDownload() {
|
||||
artistName,
|
||||
albumName,
|
||||
playlistName,
|
||||
isArtistDiscography,
|
||||
undefined, // Don't pass position for single track
|
||||
position, // Pass position for track numbering
|
||||
spotifyId,
|
||||
durationMs
|
||||
);
|
||||
@@ -254,8 +517,8 @@ export function useDownload() {
|
||||
const handleDownloadSelected = async (
|
||||
selectedTracks: string[],
|
||||
allTracks: TrackMetadata[],
|
||||
playlistName?: string,
|
||||
isArtistDiscography?: boolean
|
||||
folderName?: string,
|
||||
isAlbum?: boolean
|
||||
) => {
|
||||
if (selectedTracks.length === 0) {
|
||||
toast.error("No tracks selected");
|
||||
@@ -268,6 +531,20 @@ export function useDownload() {
|
||||
setBulkDownloadType("selected");
|
||||
setDownloadProgress(0);
|
||||
|
||||
// Pre-add ALL tracks to the queue before starting downloads
|
||||
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
|
||||
const itemIDs: string[] = [];
|
||||
for (const isrc of selectedTracks) {
|
||||
const track = allTracks.find((t) => t.isrc === isrc);
|
||||
const itemID = await AddToDownloadQueue(
|
||||
isrc,
|
||||
track?.name || "",
|
||||
track?.artists || "",
|
||||
track?.album_name || ""
|
||||
);
|
||||
itemIDs.push(itemID);
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
let skippedCount = 0;
|
||||
@@ -283,6 +560,7 @@ export function useDownload() {
|
||||
|
||||
const isrc = selectedTracks[i];
|
||||
const track = allTracks.find((t) => t.isrc === isrc);
|
||||
const itemID = itemIDs[i];
|
||||
|
||||
setDownloadingTrack(isrc);
|
||||
|
||||
@@ -291,18 +569,23 @@ export function useDownload() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Use sequential numbering (1, 2, 3...) for selected tracks
|
||||
const response = await downloadWithAutoFallback(
|
||||
// Extract year from release_date (format: YYYY-MM-DD or YYYY)
|
||||
const releaseYear = track?.release_date?.substring(0, 4);
|
||||
|
||||
// Download with pre-created itemID
|
||||
const response = await downloadWithItemID(
|
||||
isrc,
|
||||
settings,
|
||||
itemID,
|
||||
track?.name,
|
||||
track?.artists,
|
||||
track?.album_name,
|
||||
playlistName,
|
||||
isArtistDiscography,
|
||||
folderName,
|
||||
i + 1, // Sequential position based on selection order
|
||||
track?.spotify_id,
|
||||
track?.duration_ms
|
||||
track?.duration_ms,
|
||||
isAlbum,
|
||||
releaseYear
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
@@ -329,6 +612,9 @@ export function useDownload() {
|
||||
errorCount++;
|
||||
logger.error(`error: ${track?.name} - ${err}`);
|
||||
setFailedTracks((prev) => new Set(prev).add(isrc));
|
||||
// Mark item as failed in queue
|
||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
setDownloadProgress(Math.round(((i + 1) / total) * 100));
|
||||
@@ -340,6 +626,10 @@ export function useDownload() {
|
||||
setBulkDownloadType(null);
|
||||
shouldStopDownloadRef.current = false;
|
||||
|
||||
// Cancel any remaining queued items
|
||||
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
|
||||
await CancelAllQueuedItems();
|
||||
|
||||
// Build summary message
|
||||
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
|
||||
if (errorCount === 0 && skippedCount === 0) {
|
||||
@@ -362,8 +652,8 @@ export function useDownload() {
|
||||
|
||||
const handleDownloadAll = async (
|
||||
tracks: TrackMetadata[],
|
||||
playlistName?: string,
|
||||
isArtistDiscography?: boolean
|
||||
folderName?: string,
|
||||
isAlbum?: boolean
|
||||
) => {
|
||||
const tracksWithIsrc = tracks.filter((track) => track.isrc);
|
||||
|
||||
@@ -378,6 +668,19 @@ export function useDownload() {
|
||||
setBulkDownloadType("all");
|
||||
setDownloadProgress(0);
|
||||
|
||||
// Pre-add ALL tracks to the queue before starting downloads
|
||||
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
|
||||
const itemIDs: string[] = [];
|
||||
for (const track of tracksWithIsrc) {
|
||||
const itemID = await AddToDownloadQueue(
|
||||
track.isrc,
|
||||
track.name,
|
||||
track.artists,
|
||||
track.album_name || ""
|
||||
);
|
||||
itemIDs.push(itemID);
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
let skippedCount = 0;
|
||||
@@ -392,22 +695,28 @@ export function useDownload() {
|
||||
}
|
||||
|
||||
const track = tracksWithIsrc[i];
|
||||
const itemID = itemIDs[i];
|
||||
|
||||
setDownloadingTrack(track.isrc);
|
||||
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
||||
|
||||
try {
|
||||
const response = await downloadWithAutoFallback(
|
||||
// Extract year from release_date (format: YYYY-MM-DD or YYYY)
|
||||
const releaseYear = track.release_date?.substring(0, 4);
|
||||
|
||||
const response = await downloadWithItemID(
|
||||
track.isrc,
|
||||
settings,
|
||||
itemID,
|
||||
track.name,
|
||||
track.artists,
|
||||
track.album_name,
|
||||
playlistName,
|
||||
isArtistDiscography,
|
||||
folderName,
|
||||
i + 1,
|
||||
track.spotify_id,
|
||||
track.duration_ms
|
||||
track.duration_ms,
|
||||
isAlbum,
|
||||
releaseYear
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
@@ -434,6 +743,9 @@ export function useDownload() {
|
||||
errorCount++;
|
||||
logger.error(`error: ${track.name} - ${err}`);
|
||||
setFailedTracks((prev) => new Set(prev).add(track.isrc));
|
||||
// Mark item as failed in queue
|
||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
setDownloadProgress(Math.round(((i + 1) / total) * 100));
|
||||
@@ -445,6 +757,10 @@ export function useDownload() {
|
||||
setBulkDownloadType(null);
|
||||
shouldStopDownloadRef.current = false;
|
||||
|
||||
// Cancel any remaining queued items
|
||||
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
|
||||
await CancelQueued();
|
||||
|
||||
// Build summary message
|
||||
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
|
||||
if (errorCount === 0 && skippedCount === 0) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { GetDownloadQueue } from "../../wailsjs/go/main/App";
|
||||
import { backend } from "../../wailsjs/go/models";
|
||||
|
||||
export function useDownloadQueueData() {
|
||||
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(
|
||||
new backend.DownloadQueueInfo({
|
||||
is_downloading: false,
|
||||
queue: [],
|
||||
current_speed: 0,
|
||||
total_downloaded: 0,
|
||||
session_start_time: 0,
|
||||
queued_count: 0,
|
||||
completed_count: 0,
|
||||
failed_count: 0,
|
||||
skipped_count: 0,
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchQueue = async () => {
|
||||
try {
|
||||
const info = await GetDownloadQueue();
|
||||
setQueueInfo(info);
|
||||
} catch (error) {
|
||||
console.error("Failed to get download queue:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetch
|
||||
fetchQueue();
|
||||
|
||||
// Poll every 200ms
|
||||
const interval = setInterval(fetchQueue, 200);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return queueInfo;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export function useDownloadQueueDialog() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const openQueue = () => setIsOpen(true);
|
||||
const closeQueue = () => setIsOpen(false);
|
||||
const toggleQueue = () => setIsOpen((prev) => !prev);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
openQueue,
|
||||
closeQueue,
|
||||
toggleQueue,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { downloadLyrics } from "@/lib/api";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -17,7 +17,6 @@ export function useLyrics() {
|
||||
artistName: string,
|
||||
albumName?: string,
|
||||
playlistName?: string,
|
||||
isArtistDiscography?: boolean,
|
||||
position?: number
|
||||
) => {
|
||||
if (!spotifyId) {
|
||||
@@ -33,33 +32,42 @@ export function useLyrics() {
|
||||
const os = settings.operatingSystem;
|
||||
let outputDir = settings.downloadPath;
|
||||
|
||||
// Build output path similar to audio download
|
||||
// Build output path using template system
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName,
|
||||
album: albumName,
|
||||
title: trackName,
|
||||
track: position,
|
||||
playlist: playlistName,
|
||||
};
|
||||
|
||||
// For playlist/discography, prepend the folder name
|
||||
if (playlistName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
|
||||
}
|
||||
|
||||
if (isArtistDiscography) {
|
||||
if (settings.albumSubfolder && albumName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
|
||||
}
|
||||
} else {
|
||||
if (settings.artistSubfolder && artistName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
|
||||
}
|
||||
if (settings.albumSubfolder && albumName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
|
||||
// Apply folder template
|
||||
if (settings.folderTemplate) {
|
||||
const folderPath = parseTemplate(settings.folderTemplate, templateData);
|
||||
if (folderPath) {
|
||||
const parts = folderPath.split("/").filter((p: string) => p.trim());
|
||||
for (const part of parts) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(part, os));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
|
||||
|
||||
const response = await downloadLyrics({
|
||||
spotify_id: spotifyId,
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameFormat,
|
||||
filename_format: settings.filenameTemplate || "{title}",
|
||||
track_number: settings.trackNumber,
|
||||
position: position || 0,
|
||||
use_album_track_number: settings.albumSubfolder,
|
||||
use_album_track_number: useAlbumTrackNumber,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
|
||||
+10
-1
@@ -5,8 +5,10 @@ import type {
|
||||
HealthResponse,
|
||||
LyricsDownloadRequest,
|
||||
LyricsDownloadResponse,
|
||||
CoverDownloadRequest,
|
||||
CoverDownloadResponse,
|
||||
} from "@/types/api";
|
||||
import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics } from "../../wailsjs/go/main/App";
|
||||
import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics, DownloadCover } from "../../wailsjs/go/main/App";
|
||||
import { main } from "../../wailsjs/go/models";
|
||||
|
||||
export async function fetchSpotifyMetadata(
|
||||
@@ -48,3 +50,10 @@ export async function downloadLyrics(
|
||||
const req = new main.LyricsDownloadRequest(request);
|
||||
return await DownloadLyrics(req);
|
||||
}
|
||||
|
||||
export async function downloadCover(
|
||||
request: CoverDownloadRequest
|
||||
): Promise<CoverDownloadResponse> {
|
||||
const req = new main.CoverDownloadRequest(request);
|
||||
return await DownloadCover(req);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Format a date to relative time string with max 2 units
|
||||
* e.g., "23 hours 32 minutes ago", "1 day 14 hours ago"
|
||||
*/
|
||||
export function formatRelativeTime(date: Date | string | number): string {
|
||||
const now = new Date();
|
||||
const target = new Date(date);
|
||||
const diffMs = now.getTime() - target.getTime();
|
||||
|
||||
if (diffMs < 0) return "just now";
|
||||
|
||||
const seconds = Math.floor(diffMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
const weeks = Math.floor(days / 7);
|
||||
const months = Math.floor(days / 30);
|
||||
const years = Math.floor(days / 365);
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (years > 0) {
|
||||
parts.push(`${years} ${years === 1 ? "year" : "years"}`);
|
||||
const remainingMonths = Math.floor((days % 365) / 30);
|
||||
if (remainingMonths > 0) {
|
||||
parts.push(`${remainingMonths} ${remainingMonths === 1 ? "month" : "months"}`);
|
||||
}
|
||||
} else if (months > 0) {
|
||||
parts.push(`${months} ${months === 1 ? "month" : "months"}`);
|
||||
const remainingDays = days % 30;
|
||||
if (remainingDays > 0) {
|
||||
parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`);
|
||||
}
|
||||
} else if (weeks > 0) {
|
||||
parts.push(`${weeks} ${weeks === 1 ? "week" : "weeks"}`);
|
||||
const remainingDays = days % 7;
|
||||
if (remainingDays > 0) {
|
||||
parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`);
|
||||
}
|
||||
} else if (days > 0) {
|
||||
parts.push(`${days} ${days === 1 ? "day" : "days"}`);
|
||||
const remainingHours = hours % 24;
|
||||
if (remainingHours > 0) {
|
||||
parts.push(`${remainingHours} ${remainingHours === 1 ? "hour" : "hours"}`);
|
||||
}
|
||||
} else if (hours > 0) {
|
||||
parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`);
|
||||
const remainingMinutes = minutes % 60;
|
||||
if (remainingMinutes > 0) {
|
||||
parts.push(`${remainingMinutes} ${remainingMinutes === 1 ? "minute" : "minutes"}`);
|
||||
}
|
||||
} else if (minutes > 0) {
|
||||
parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`);
|
||||
} else {
|
||||
return "just now";
|
||||
}
|
||||
|
||||
return "Released " + parts.slice(0, 2).join(" ") + " ago";
|
||||
}
|
||||
@@ -1,17 +1,65 @@
|
||||
import { GetDefaults } from "../../wailsjs/go/main/App";
|
||||
|
||||
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk";
|
||||
|
||||
// Folder structure presets
|
||||
export type FolderPreset = "none" | "artist" | "album" | "artist-album" | "artist-year-album" | "custom";
|
||||
|
||||
// Filename format presets
|
||||
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "custom";
|
||||
|
||||
export interface Settings {
|
||||
downloadPath: string;
|
||||
downloader: "auto" | "deezer" | "tidal" | "qobuz" | "amazon";
|
||||
theme: string;
|
||||
themeMode: "auto" | "light" | "dark";
|
||||
filenameFormat: "title-artist" | "artist-title" | "title";
|
||||
artistSubfolder: boolean;
|
||||
albumSubfolder: boolean;
|
||||
fontFamily: FontFamily;
|
||||
// New template system
|
||||
folderPreset: FolderPreset;
|
||||
folderTemplate: string;
|
||||
filenamePreset: FilenamePreset;
|
||||
filenameTemplate: string;
|
||||
// Legacy settings (kept for migration)
|
||||
filenameFormat?: "title-artist" | "artist-title" | "title";
|
||||
artistSubfolder?: boolean;
|
||||
albumSubfolder?: boolean;
|
||||
trackNumber: boolean;
|
||||
sfxEnabled: boolean;
|
||||
operatingSystem: "Windows" | "linux/MacOS"
|
||||
}
|
||||
|
||||
// Folder preset templates
|
||||
export const FOLDER_PRESETS: Record<FolderPreset, { label: string; template: string }> = {
|
||||
"none": { label: "No Subfolder", template: "" },
|
||||
"artist": { label: "Artist", template: "{artist}" },
|
||||
"album": { label: "Album", template: "{album}" },
|
||||
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
|
||||
"artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" },
|
||||
"custom": { label: "Custom...", template: "" },
|
||||
};
|
||||
|
||||
// Filename preset templates
|
||||
export const FILENAME_PRESETS: Record<FilenamePreset, { label: string; template: string }> = {
|
||||
"title": { label: "Title", template: "{title}" },
|
||||
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
|
||||
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
|
||||
"track-title": { label: "Track. Title", template: "{track}. {title}" },
|
||||
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
|
||||
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
|
||||
"custom": { label: "Custom...", template: "" },
|
||||
};
|
||||
|
||||
// Available template variables
|
||||
export const TEMPLATE_VARIABLES = [
|
||||
{ key: "{artist}", description: "Artist name", example: "Taylor Swift" },
|
||||
{ key: "{album}", description: "Album name", example: "1989" },
|
||||
{ key: "{title}", description: "Track title", example: "Shake It Off" },
|
||||
{ key: "{track}", description: "Track number", example: "01" },
|
||||
{ key: "{year}", description: "Release year", example: "2014" },
|
||||
{ key: "{isrc}", description: "ISRC code", example: "USCJY1431309" },
|
||||
{ key: "{playlist}", description: "Playlist name", example: "My Playlist" },
|
||||
];
|
||||
|
||||
// Auto-detect operating system
|
||||
function detectOS(): "Windows" | "linux/MacOS" {
|
||||
const platform = window.navigator.platform.toLowerCase();
|
||||
@@ -26,13 +74,35 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
downloader: "auto",
|
||||
theme: "yellow",
|
||||
themeMode: "auto",
|
||||
filenameFormat: "title-artist",
|
||||
artistSubfolder: false,
|
||||
albumSubfolder: false,
|
||||
fontFamily: "google-sans",
|
||||
folderPreset: "none",
|
||||
folderTemplate: "",
|
||||
filenamePreset: "title-artist",
|
||||
filenameTemplate: "{title} - {artist}",
|
||||
trackNumber: false,
|
||||
sfxEnabled: true,
|
||||
operatingSystem: detectOS()
|
||||
};
|
||||
|
||||
export const FONT_OPTIONS: { value: FontFamily; label: string; fontFamily: string }[] = [
|
||||
{ value: "google-sans", label: "Google Sans Flex", fontFamily: '"Google Sans Flex", system-ui, sans-serif' },
|
||||
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
|
||||
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
|
||||
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' },
|
||||
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
|
||||
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
|
||||
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
|
||||
{ value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' },
|
||||
];
|
||||
|
||||
export function applyFont(fontFamily: FontFamily): void {
|
||||
const font = FONT_OPTIONS.find(f => f.value === fontFamily);
|
||||
if (font) {
|
||||
document.documentElement.style.setProperty('--font-sans', font.fontFamily);
|
||||
document.body.style.fontFamily = font.fontFamily;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDefaultPath(): Promise<string> {
|
||||
try {
|
||||
const data = await GetDefaults();
|
||||
@@ -55,6 +125,37 @@ export function getSettings(): Settings {
|
||||
parsed.themeMode = parsed.darkMode ? 'dark' : 'light';
|
||||
delete parsed.darkMode;
|
||||
}
|
||||
// Migrate old folder/filename settings to new template system
|
||||
if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) {
|
||||
const hasArtist = parsed.artistSubfolder;
|
||||
const hasAlbum = parsed.albumSubfolder;
|
||||
if (hasArtist && hasAlbum) {
|
||||
parsed.folderPreset = "artist-album";
|
||||
parsed.folderTemplate = "{artist}/{album}";
|
||||
} else if (hasArtist) {
|
||||
parsed.folderPreset = "artist";
|
||||
parsed.folderTemplate = "{artist}";
|
||||
} else if (hasAlbum) {
|
||||
parsed.folderPreset = "album";
|
||||
parsed.folderTemplate = "{album}";
|
||||
} else {
|
||||
parsed.folderPreset = "none";
|
||||
parsed.folderTemplate = "";
|
||||
}
|
||||
}
|
||||
if (!('filenamePreset' in parsed) && 'filenameFormat' in parsed) {
|
||||
const format = parsed.filenameFormat;
|
||||
if (format === "title-artist") {
|
||||
parsed.filenamePreset = "artist-title";
|
||||
parsed.filenameTemplate = "{artist} - {title}";
|
||||
} else if (format === "artist-title") {
|
||||
parsed.filenamePreset = "artist-title";
|
||||
parsed.filenameTemplate = "{artist} - {title}";
|
||||
} else {
|
||||
parsed.filenamePreset = "title";
|
||||
parsed.filenameTemplate = "{title}";
|
||||
}
|
||||
}
|
||||
// Always use detected OS (don't persist it)
|
||||
parsed.operatingSystem = detectOS();
|
||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||
@@ -65,6 +166,34 @@ export function getSettings(): Settings {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
|
||||
// Parse template and replace variables with actual values
|
||||
export interface TemplateData {
|
||||
artist?: string;
|
||||
album?: string;
|
||||
title?: string;
|
||||
track?: number;
|
||||
year?: string;
|
||||
isrc?: string;
|
||||
playlist?: string;
|
||||
}
|
||||
|
||||
export function parseTemplate(template: string, data: TemplateData): string {
|
||||
if (!template) return "";
|
||||
|
||||
let result = template;
|
||||
|
||||
// Replace each variable
|
||||
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
|
||||
result = result.replace(/\{album\}/g, data.album || "Unknown Album");
|
||||
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
|
||||
result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00");
|
||||
result = result.replace(/\{year\}/g, data.year || "0000");
|
||||
result = result.replace(/\{isrc\}/g, data.isrc || "");
|
||||
result = result.replace(/\{playlist\}/g, data.playlist || "");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getSettingsWithDefaults(): Promise<Settings> {
|
||||
const settings = getSettings();
|
||||
|
||||
|
||||
@@ -6,38 +6,42 @@ import {
|
||||
playInfoSound,
|
||||
} from "./audio";
|
||||
import { logger } from "./logger";
|
||||
import { getSettings } from "./settings";
|
||||
|
||||
const toastStyle = {
|
||||
className: "font-mono lowercase",
|
||||
};
|
||||
|
||||
// Helper to check if SFX is enabled
|
||||
const isSfxEnabled = () => getSettings().sfxEnabled;
|
||||
|
||||
// Wrapper functions for toast with sound effects
|
||||
export const toastWithSound = {
|
||||
success: (message: string, data?: any) => {
|
||||
const msg = message.toLowerCase();
|
||||
logger.success(msg);
|
||||
playSuccessSound();
|
||||
if (isSfxEnabled()) playSuccessSound();
|
||||
return toast.success(msg, { ...toastStyle, ...data });
|
||||
},
|
||||
|
||||
error: (message: string, data?: any) => {
|
||||
const msg = message.toLowerCase();
|
||||
logger.error(msg);
|
||||
playErrorSound();
|
||||
if (isSfxEnabled()) playErrorSound();
|
||||
return toast.error(msg, { ...toastStyle, ...data });
|
||||
},
|
||||
|
||||
warning: (message: string, data?: any) => {
|
||||
const msg = message.toLowerCase();
|
||||
logger.warning(msg);
|
||||
playWarningSound();
|
||||
if (isSfxEnabled()) playWarningSound();
|
||||
return toast.warning(msg, { ...toastStyle, ...data });
|
||||
},
|
||||
|
||||
info: (message: string, data?: any) => {
|
||||
const msg = message.toLowerCase();
|
||||
logger.info(msg);
|
||||
playInfoSound();
|
||||
if (isSfxEnabled()) playInfoSound();
|
||||
return toast.info(msg, { ...toastStyle, ...data });
|
||||
},
|
||||
|
||||
@@ -45,7 +49,7 @@ export const toastWithSound = {
|
||||
message: (message: string, data?: any) => {
|
||||
const msg = message.toLowerCase();
|
||||
logger.info(msg);
|
||||
playInfoSound();
|
||||
if (isSfxEnabled()) playInfoSound();
|
||||
return toast(msg, { ...toastStyle, ...data });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { BrowserOpenURL } from "../../wailsjs/runtime/runtime"
|
||||
import type { Settings } from "./settings";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
@@ -44,4 +45,15 @@ export function buildOutputPath(settings: Settings, folder?: string) {
|
||||
const sanitized = folder ? sanitizePath(folder, os) : undefined;
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,10 @@ import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { DebugLogger } from "@/components/DebugLogger";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<Toaster position="bottom-left" duration={1000} />
|
||||
<div className="fixed bottom-2 left-2 z-50">
|
||||
<DebugLogger />
|
||||
</div>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
@@ -125,6 +125,7 @@ export interface DownloadRequest {
|
||||
spotify_id?: string;
|
||||
service_url?: string;
|
||||
duration?: number; // Track duration in seconds for better matching
|
||||
item_id?: string; // Optional queue item ID for multi-service fallback tracking
|
||||
}
|
||||
|
||||
export interface DownloadResponse {
|
||||
@@ -133,6 +134,7 @@ export interface DownloadResponse {
|
||||
file?: string;
|
||||
error?: string;
|
||||
already_exists?: boolean;
|
||||
item_id?: string; // Queue item ID for tracking
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
@@ -198,4 +200,22 @@ export interface TrackAvailability {
|
||||
qobuz_url?: string;
|
||||
}
|
||||
|
||||
export interface CoverDownloadRequest {
|
||||
cover_url: string;
|
||||
track_name: string;
|
||||
artist_name: string;
|
||||
output_dir?: string;
|
||||
filename_format?: string;
|
||||
track_number?: boolean;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface CoverDownloadResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
file?: string;
|
||||
error?: string;
|
||||
already_exists?: boolean;
|
||||
}
|
||||
|
||||
|
||||
|
||||
+14
-2
@@ -6,5 +6,17 @@
|
||||
"eu-katze.qqdl.site",
|
||||
"katze.qqdl.site",
|
||||
"wolf.qqdl.site",
|
||||
"tidal.kinoplus.online"
|
||||
]
|
||||
"tidal.kinoplus.online",
|
||||
"tidal-api.binimum.org",
|
||||
"tidal-api-2.binimum.org",
|
||||
"dev-api.squid.wtf",
|
||||
"triton.squid.wtf",
|
||||
"ohio.monochrome.tf",
|
||||
"virginia.monochrome.tf",
|
||||
"oregon.monochrome.tf",
|
||||
"california.monochrome.tf",
|
||||
"frankfurt.monochrome.tf",
|
||||
"london.monochrome.tf",
|
||||
"singapore.monochrome.tf",
|
||||
"jakarta.monochrome.tf"
|
||||
]
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "6.2"
|
||||
"version": "6.7"
|
||||
}
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"info": {
|
||||
"productName": "SpotiFLAC",
|
||||
"productVersion": "6.4"
|
||||
"productVersion": "6.7"
|
||||
},
|
||||
"wailsjsdir": "./frontend",
|
||||
"assetdir": "./frontend/dist",
|
||||
|
||||
Reference in New Issue
Block a user