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