Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a2dbe4e32 | |||
| ee2976143a | |||
| 1c9bba0140 | |||
| 10236f00c6 | |||
| a49bb560bd | |||
| bb9e2dcbb6 | |||
| 7aeefc1fe5 |
@@ -58,3 +58,4 @@ temp/
|
||||
|
||||
# Build notes (optional - uncomment if you don't want to commit)
|
||||
# BUILD_NOTES.md
|
||||
build.txt
|
||||
@@ -13,9 +13,11 @@ Get Spotify tracks in true FLAC from Tidal/Deezer — no account required.
|
||||
|
||||
</div>
|
||||
|
||||
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases)
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||

|
||||
|
||||
## Lossless Audio Check
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ type DownloadRequest struct {
|
||||
ISRC string `json:"isrc"`
|
||||
Service string `json:"service"`
|
||||
Query string `json:"query,omitempty"`
|
||||
TrackName string `json:"track_name,omitempty"`
|
||||
ArtistName string `json:"artist_name,omitempty"`
|
||||
AlbumName string `json:"album_name,omitempty"`
|
||||
ApiURL string `json:"api_url,omitempty"`
|
||||
OutputDir string `json:"output_dir,omitempty"`
|
||||
AudioFormat string `json:"audio_format,omitempty"`
|
||||
@@ -118,14 +121,20 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
|
||||
if req.ApiURL == "" || req.ApiURL == "auto" {
|
||||
downloader := backend.NewTidalDownloader("")
|
||||
filename, err = downloader.DownloadWithFallback(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber)
|
||||
filename, err = downloader.DownloadWithFallback(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.TrackName, req.ArtistName, req.AlbumName)
|
||||
} else {
|
||||
downloader := backend.NewTidalDownloader(req.ApiURL)
|
||||
filename, err = downloader.Download(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber)
|
||||
filename, err = downloader.Download(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.TrackName, req.ArtistName, req.AlbumName)
|
||||
}
|
||||
} else if req.Service == "qobuz" {
|
||||
downloader := backend.NewQobuzDownloader()
|
||||
err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.TrackName, req.ArtistName, req.AlbumName)
|
||||
if err == nil {
|
||||
filename = "Downloaded via Qobuz"
|
||||
}
|
||||
} else {
|
||||
downloader := backend.NewDeezerDownloader()
|
||||
err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.FilenameFormat, req.TrackNumber)
|
||||
err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.TrackName, req.ArtistName, req.AlbumName)
|
||||
if err == nil {
|
||||
filename = "Downloaded via Deezer"
|
||||
}
|
||||
@@ -159,6 +168,11 @@ func (a *App) OpenFolder(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SelectFolder opens a folder selection dialog and returns the selected path
|
||||
func (a *App) SelectFolder(defaultPath string) (string, error) {
|
||||
return backend.SelectFolderDialog(a.ctx, defaultPath)
|
||||
}
|
||||
|
||||
// GetDefaults returns the default configuration
|
||||
func (a *App) GetDefaults() map[string]string {
|
||||
return map[string]string{
|
||||
|
||||
+37
-17
@@ -1,6 +1,7 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -57,7 +58,9 @@ func NewDeezerDownloader() *DeezerDownloader {
|
||||
}
|
||||
|
||||
func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) {
|
||||
url := fmt.Sprintf("https://api.deezer.com/2.0/track/isrc:%s", isrc)
|
||||
// Decode base64 API URL
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlemVyLmNvbS8yLjAvdHJhY2svaXNyYzo=")
|
||||
url := fmt.Sprintf("%s%s", string(apiBase), isrc)
|
||||
|
||||
resp, err := d.client.Get(url)
|
||||
if err != nil {
|
||||
@@ -82,7 +85,9 @@ func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) {
|
||||
}
|
||||
|
||||
func (d *DeezerDownloader) GetDownloadURL(trackID int64) (string, error) {
|
||||
url := fmt.Sprintf("https://api.deezmate.com/dl/%d", trackID)
|
||||
// Decode base64 API URL
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlem1hdGUuY29tL2RsLw==")
|
||||
url := fmt.Sprintf("%s%d", string(apiBase), trackID)
|
||||
|
||||
resp, err := d.client.Get(url)
|
||||
if err != nil {
|
||||
@@ -183,7 +188,7 @@ func buildFilename(title, artist string, trackNumber int, format string, include
|
||||
return filename + ".flac"
|
||||
}
|
||||
|
||||
func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string, includeTrackNumber bool) error {
|
||||
func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string, includeTrackNumber bool, spotifyTrackName, spotifyArtistName, spotifyAlbumName string) error {
|
||||
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
|
||||
|
||||
track, err := d.GetTrackByISRC(isrc)
|
||||
@@ -191,21 +196,36 @@ func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string
|
||||
return err
|
||||
}
|
||||
|
||||
artists := track.Artist.Name
|
||||
if len(track.Contributors) > 0 {
|
||||
var mainArtists []string
|
||||
for _, contrib := range track.Contributors {
|
||||
if contrib.Role == "Main" {
|
||||
mainArtists = append(mainArtists, contrib.Name)
|
||||
// Use Spotify metadata if provided, otherwise fallback to Deezer metadata
|
||||
artists := spotifyArtistName
|
||||
trackTitle := spotifyTrackName
|
||||
albumTitle := spotifyAlbumName
|
||||
|
||||
if artists == "" {
|
||||
artists = track.Artist.Name
|
||||
if len(track.Contributors) > 0 {
|
||||
var mainArtists []string
|
||||
for _, contrib := range track.Contributors {
|
||||
if contrib.Role == "Main" {
|
||||
mainArtists = append(mainArtists, contrib.Name)
|
||||
}
|
||||
}
|
||||
if len(mainArtists) > 0 {
|
||||
artists = strings.Join(mainArtists, ", ")
|
||||
}
|
||||
}
|
||||
if len(mainArtists) > 0 {
|
||||
artists = strings.Join(mainArtists, ", ")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Found track: %s - %s\n", artists, track.Title)
|
||||
fmt.Printf("Album: %s\n", track.Album.Title)
|
||||
if trackTitle == "" {
|
||||
trackTitle = track.Title
|
||||
}
|
||||
|
||||
if albumTitle == "" {
|
||||
albumTitle = track.Album.Title
|
||||
}
|
||||
|
||||
fmt.Printf("Found track: %s - %s\n", artists, trackTitle)
|
||||
fmt.Printf("Album: %s\n", albumTitle)
|
||||
|
||||
downloadURL, err := d.GetDownloadURL(track.ID)
|
||||
if err != nil {
|
||||
@@ -213,7 +233,7 @@ func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string
|
||||
}
|
||||
|
||||
safeArtist := sanitizeFilename(artists)
|
||||
safeTitle := sanitizeFilename(track.Title)
|
||||
safeTitle := sanitizeFilename(trackTitle)
|
||||
|
||||
// Build filename based on format settings
|
||||
filename := buildFilename(safeTitle, safeArtist, track.TrackPos, filenameFormat, includeTrackNumber)
|
||||
@@ -239,9 +259,9 @@ func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string
|
||||
|
||||
fmt.Println("Embedding metadata and cover art...")
|
||||
metadata := Metadata{
|
||||
Title: track.Title,
|
||||
Title: trackTitle,
|
||||
Artist: artists,
|
||||
Album: track.Album.Title,
|
||||
Album: albumTitle,
|
||||
Date: track.ReleaseDate,
|
||||
TrackNumber: track.TrackPos,
|
||||
DiscNumber: track.DiskNumber,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
|
||||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
func OpenFolderInExplorer(path string) error {
|
||||
@@ -21,3 +24,27 @@ func OpenFolderInExplorer(path string) error {
|
||||
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error) {
|
||||
// If defaultPath is empty, use default music path
|
||||
if defaultPath == "" {
|
||||
defaultPath = GetDefaultMusicPath()
|
||||
}
|
||||
|
||||
options := wailsRuntime.OpenDialogOptions{
|
||||
Title: "Select Download Folder",
|
||||
DefaultDirectory: defaultPath,
|
||||
}
|
||||
|
||||
selectedPath, err := wailsRuntime.OpenDirectoryDialog(ctx, options)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// If user cancelled, selectedPath will be empty
|
||||
if selectedPath == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return selectedPath, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type QobuzDownloader struct {
|
||||
client *http.Client
|
||||
appID string
|
||||
}
|
||||
|
||||
type QobuzSearchResponse struct {
|
||||
Query string `json:"query"`
|
||||
Tracks struct {
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Total int `json:"total"`
|
||||
Items []QobuzTrack `json:"items"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
type QobuzTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Version string `json:"version"`
|
||||
Duration int `json:"duration"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
MediaNumber int `json:"media_number"`
|
||||
ISRC string `json:"isrc"`
|
||||
Copyright string `json:"copyright"`
|
||||
MaximumBitDepth int `json:"maximum_bit_depth"`
|
||||
MaximumSamplingRate float64 `json:"maximum_sampling_rate"`
|
||||
Hires bool `json:"hires"`
|
||||
HiresStreamable bool `json:"hires_streamable"`
|
||||
ReleaseDateOriginal string `json:"release_date_original"`
|
||||
Performer struct {
|
||||
Name string `json:"name"`
|
||||
ID int64 `json:"id"`
|
||||
} `json:"performer"`
|
||||
Album struct {
|
||||
Title string `json:"title"`
|
||||
ID string `json:"id"`
|
||||
Image struct {
|
||||
Small string `json:"small"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Large string `json:"large"`
|
||||
} `json:"image"`
|
||||
Artist struct {
|
||||
Name string `json:"name"`
|
||||
ID int64 `json:"id"`
|
||||
} `json:"artist"`
|
||||
Label struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"label"`
|
||||
} `json:"album"`
|
||||
}
|
||||
|
||||
type QobuzStreamResponse struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func NewQobuzDownloader() *QobuzDownloader {
|
||||
return &QobuzDownloader{
|
||||
client: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
appID: "798273057",
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
|
||||
// Decode base64 API URL
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, q.appID)
|
||||
|
||||
resp, err := q.client.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search track: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp QobuzSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if len(searchResp.Tracks.Items) == 0 {
|
||||
return nil, fmt.Errorf("track not found for ISRC: %s", isrc)
|
||||
}
|
||||
|
||||
return &searchResp.Tracks.Items[0], nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||
// Map quality to Qobuz quality code
|
||||
// Qobuz uses: 5 (MP3 320), 6 (FLAC 16-bit), 7 (FLAC 24-bit), 27 (Hi-Res)
|
||||
qualityCode := "27" // Default to Hi-Res
|
||||
|
||||
fmt.Printf("Getting download URL for track ID: %d\n", trackID)
|
||||
|
||||
// Decode base64 API URLs
|
||||
primaryBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWIueWVldC5zdS9hcGkvc3RyZWFtP3RyYWNrSWQ9")
|
||||
|
||||
// Try primary API first
|
||||
primaryURL := fmt.Sprintf("%s%d&quality=%s", string(primaryBase), trackID, qualityCode)
|
||||
|
||||
resp, err := q.client.Get(primaryURL)
|
||||
if err == nil && resp.StatusCode == 200 {
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Primary API response: %s\n", string(body))
|
||||
|
||||
var streamResp QobuzStreamResponse
|
||||
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
|
||||
fmt.Printf("Got download URL from primary API\n")
|
||||
return streamResp.URL, nil
|
||||
}
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// Fallback to secondary API
|
||||
fmt.Println("Primary API failed, trying fallback...")
|
||||
fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==")
|
||||
fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode)
|
||||
|
||||
resp, err = q.client.Get(fallbackURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Fallback API error response: %s\n", string(body))
|
||||
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Fallback API response: %s\n", string(body))
|
||||
|
||||
var streamResp QobuzStreamResponse
|
||||
if err := json.Unmarshal(body, &streamResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if streamResp.URL == "" {
|
||||
return "", fmt.Errorf("no download URL available")
|
||||
}
|
||||
|
||||
fmt.Printf("Got download URL from fallback API\n")
|
||||
return streamResp.URL, nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
|
||||
fmt.Println("Starting file download...")
|
||||
resp, err := q.client.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
fmt.Printf("Creating file: %s\n", filepath)
|
||||
out, err := os.Create(filepath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
fmt.Println("Writing file content...")
|
||||
written, err := io.Copy(out, resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Downloaded %d bytes\n", written)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("no cover URL provided")
|
||||
}
|
||||
|
||||
resp, err := q.client.Get(coverURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download cover: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("cover download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(filepath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create cover file: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
func buildQobuzFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool) string {
|
||||
var filename string
|
||||
|
||||
// Build base filename based on format
|
||||
switch format {
|
||||
case "artist-title":
|
||||
filename = fmt.Sprintf("%s - %s", artist, title)
|
||||
case "title":
|
||||
filename = title
|
||||
default: // "title-artist"
|
||||
filename = fmt.Sprintf("%s - %s", title, artist)
|
||||
}
|
||||
|
||||
// Add track number prefix if enabled
|
||||
if includeTrackNumber && trackNumber > 0 {
|
||||
filename = fmt.Sprintf("%02d. %s", trackNumber, filename)
|
||||
}
|
||||
|
||||
return filename + ".flac"
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, spotifyTrackName, spotifyArtistName, spotifyAlbumName string) error {
|
||||
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
track, err := q.SearchByISRC(isrc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use Spotify metadata if provided, otherwise fallback to Qobuz metadata
|
||||
artists := spotifyArtistName
|
||||
trackTitle := spotifyTrackName
|
||||
albumTitle := spotifyAlbumName
|
||||
|
||||
if artists == "" {
|
||||
artists = track.Performer.Name
|
||||
if track.Album.Artist.Name != "" {
|
||||
artists = track.Album.Artist.Name
|
||||
}
|
||||
}
|
||||
|
||||
if trackTitle == "" {
|
||||
trackTitle = track.Title
|
||||
if track.Version != "" && track.Version != "null" {
|
||||
trackTitle = fmt.Sprintf("%s (%s)", track.Title, track.Version)
|
||||
}
|
||||
}
|
||||
|
||||
if albumTitle == "" {
|
||||
albumTitle = track.Album.Title
|
||||
}
|
||||
|
||||
fmt.Printf("Found track: %s - %s\n", artists, trackTitle)
|
||||
fmt.Printf("Album: %s\n", albumTitle)
|
||||
|
||||
qualityInfo := "Standard"
|
||||
if track.Hires {
|
||||
qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate)
|
||||
}
|
||||
fmt.Printf("Quality: %s\n", qualityInfo)
|
||||
|
||||
fmt.Println("Getting download URL...")
|
||||
downloadURL, err := q.GetDownloadURL(track.ID, quality)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
if downloadURL == "" {
|
||||
return fmt.Errorf("received empty download URL")
|
||||
}
|
||||
|
||||
// Show partial URL for security
|
||||
urlPreview := downloadURL
|
||||
if len(downloadURL) > 60 {
|
||||
urlPreview = downloadURL[:60] + "..."
|
||||
}
|
||||
fmt.Printf("Download URL obtained: %s\n", urlPreview)
|
||||
|
||||
safeArtist := sanitizeFilename(artists)
|
||||
safeTitle := sanitizeFilename(trackTitle)
|
||||
|
||||
// Build filename based on format settings
|
||||
filename := buildQobuzFilename(safeTitle, safeArtist, track.TrackNumber, filenameFormat, includeTrackNumber)
|
||||
filepath := filepath.Join(outputDir, filename)
|
||||
|
||||
fmt.Printf("Downloading FLAC file to: %s\n", filepath)
|
||||
if err := q.DownloadFile(downloadURL, filepath); err != nil {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Downloaded: %s\n", filepath)
|
||||
|
||||
coverPath := ""
|
||||
if track.Album.Image.Large != "" {
|
||||
coverPath = filepath + ".cover.jpg"
|
||||
fmt.Println("Downloading cover art...")
|
||||
if err := q.DownloadCoverArt(track.Album.Image.Large, coverPath); err != nil {
|
||||
fmt.Printf("Warning: Failed to download cover art: %v\n", err)
|
||||
} else {
|
||||
defer os.Remove(coverPath)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Embedding metadata and cover art...")
|
||||
|
||||
releaseYear := ""
|
||||
if len(track.ReleaseDateOriginal) >= 4 {
|
||||
releaseYear = track.ReleaseDateOriginal[:4]
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: trackTitle,
|
||||
Artist: artists,
|
||||
Album: albumTitle,
|
||||
Date: releaseYear,
|
||||
TrackNumber: track.TrackNumber,
|
||||
DiscNumber: track.MediaNumber,
|
||||
ISRC: track.ISRC,
|
||||
}
|
||||
|
||||
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
|
||||
return fmt.Errorf("failed to embed metadata: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Metadata embedded successfully!")
|
||||
return nil
|
||||
}
|
||||
+46
-25
@@ -81,7 +81,9 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
||||
resp, err := http.Get("https://raw.githubusercontent.com/afkarxyz/SpotiFLAC/refs/heads/main/tidal.json")
|
||||
// Decode base64 API URL
|
||||
apiURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2Fma2FyeHl6L1Nwb3RpRkxBQy9yZWZzL2hlYWRzL21haW4vdGlkYWwuanNvbg==")
|
||||
resp, err := http.Get(string(apiURL))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch API list: %w", err)
|
||||
}
|
||||
@@ -107,7 +109,9 @@ func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
||||
func (t *TidalDownloader) GetAccessToken() (string, error) {
|
||||
data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID)
|
||||
|
||||
req, err := http.NewRequest("POST", "https://auth.tidal.com/v1/oauth2/token", strings.NewReader(data))
|
||||
// Decode base64 API URL
|
||||
authURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hdXRoLnRpZGFsLmNvbS92MS9vYXV0aDIvdG9rZW4=")
|
||||
req, err := http.NewRequest("POST", string(authURL), strings.NewReader(data))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -142,8 +146,9 @@ func (t *TidalDownloader) SearchTracks(query string) (*TidalSearchResponse, erro
|
||||
return nil, fmt.Errorf("failed to get access token: %w", err)
|
||||
}
|
||||
|
||||
// URL encode the query parameter
|
||||
searchURL := fmt.Sprintf("https://api.tidal.com/v1/search/tracks?query=%s&limit=25&offset=0&countryCode=US", url.QueryEscape(query))
|
||||
// Decode base64 API URL and encode the query parameter
|
||||
searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9")
|
||||
searchURL := fmt.Sprintf("%s%s&limit=25&offset=0&countryCode=US", string(searchBase), url.QueryEscape(query))
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
@@ -265,7 +270,9 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
|
||||
func (t *TidalDownloader) DownloadAlbumArt(albumID string) ([]byte, error) {
|
||||
albumID = strings.ReplaceAll(albumID, "-", "/")
|
||||
artURL := fmt.Sprintf("https://resources.tidal.com/images/%s/1280x1280.jpg", albumID)
|
||||
// Decode base64 API URL
|
||||
imageBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9yZXNvdXJjZXMudGlkYWwuY29tL2ltYWdlcy8=")
|
||||
artURL := fmt.Sprintf("%s%s/1280x1280.jpg", string(imageBase), albumID)
|
||||
|
||||
resp, err := t.client.Get(artURL)
|
||||
if err != nil {
|
||||
@@ -306,7 +313,7 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool) (string, error) {
|
||||
func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, spotifyTrackName, spotifyArtistName, spotifyAlbumName string) (string, error) {
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("directory error: %w", err)
|
||||
@@ -322,26 +329,40 @@ func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameForm
|
||||
return "", fmt.Errorf("no track ID found")
|
||||
}
|
||||
|
||||
var artists []string
|
||||
if len(trackInfo.Artists) > 0 {
|
||||
for _, artist := range trackInfo.Artists {
|
||||
if artist.Name != "" {
|
||||
artists = append(artists, artist.Name)
|
||||
}
|
||||
}
|
||||
} else if trackInfo.Artist.Name != "" {
|
||||
artists = append(artists, trackInfo.Artist.Name)
|
||||
}
|
||||
// Use Spotify metadata if provided, otherwise fallback to Tidal metadata
|
||||
artistName := spotifyArtistName
|
||||
trackTitle := spotifyTrackName
|
||||
albumTitle := spotifyAlbumName
|
||||
|
||||
artistName := "Unknown Artist"
|
||||
if len(artists) > 0 {
|
||||
artistName = strings.Join(artists, ", ")
|
||||
if artistName == "" {
|
||||
var artists []string
|
||||
if len(trackInfo.Artists) > 0 {
|
||||
for _, artist := range trackInfo.Artists {
|
||||
if artist.Name != "" {
|
||||
artists = append(artists, artist.Name)
|
||||
}
|
||||
}
|
||||
} else if trackInfo.Artist.Name != "" {
|
||||
artists = append(artists, trackInfo.Artist.Name)
|
||||
}
|
||||
|
||||
artistName = "Unknown Artist"
|
||||
if len(artists) > 0 {
|
||||
artistName = strings.Join(artists, ", ")
|
||||
}
|
||||
}
|
||||
artistName = sanitizeFilename(artistName)
|
||||
|
||||
trackTitle := sanitizeFilename(trackInfo.Title)
|
||||
if trackTitle == "" {
|
||||
trackTitle = fmt.Sprintf("track_%d", trackInfo.ID)
|
||||
trackTitle = trackInfo.Title
|
||||
if trackTitle == "" {
|
||||
trackTitle = fmt.Sprintf("track_%d", trackInfo.ID)
|
||||
}
|
||||
}
|
||||
trackTitle = sanitizeFilename(trackTitle)
|
||||
|
||||
if albumTitle == "" {
|
||||
albumTitle = trackInfo.Album.Title
|
||||
}
|
||||
|
||||
// Build filename based on format settings
|
||||
@@ -387,9 +408,9 @@ func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameForm
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: trackInfo.Title,
|
||||
Title: trackTitle,
|
||||
Artist: artistName,
|
||||
Album: trackInfo.Album.Title,
|
||||
Album: albumTitle,
|
||||
Date: releaseYear,
|
||||
TrackNumber: trackInfo.TrackNumber,
|
||||
DiscNumber: trackInfo.VolumeNumber,
|
||||
@@ -406,7 +427,7 @@ func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameForm
|
||||
return outputFilename, nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) DownloadWithFallback(query, isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool) (string, error) {
|
||||
func (t *TidalDownloader) DownloadWithFallback(query, isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, spotifyTrackName, spotifyArtistName, spotifyAlbumName string) (string, error) {
|
||||
apis, err := t.GetAvailableAPIs()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no APIs available for fallback: %w", err)
|
||||
@@ -418,7 +439,7 @@ func (t *TidalDownloader) DownloadWithFallback(query, isrc, outputDir, quality,
|
||||
|
||||
fallbackDownloader := NewTidalDownloader(apiURL)
|
||||
|
||||
result, err := fallbackDownloader.Download(query, isrc, outputDir, quality, filenameFormat, includeTrackNumber)
|
||||
result, err := fallbackDownloader.Download(query, isrc, outputDir, quality, filenameFormat, includeTrackNumber, spotifyTrackName, spotifyArtistName, spotifyAlbumName)
|
||||
if err == nil {
|
||||
fmt.Printf("✓ Success with: %s\n", apiURL)
|
||||
return result, nil
|
||||
|
||||
+239
-1056
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,164 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { SearchAndSort } from "./SearchAndSort";
|
||||
import { TrackList } from "./TrackList";
|
||||
import { DownloadProgress } from "./DownloadProgress";
|
||||
import type { TrackMetadata } from "@/types/api";
|
||||
|
||||
interface AlbumInfoProps {
|
||||
albumInfo: {
|
||||
name: string;
|
||||
artists: string;
|
||||
images: string;
|
||||
release_date: string;
|
||||
total_tracks: number;
|
||||
};
|
||||
trackList: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
selectedTracks: string[];
|
||||
downloadedTracks: Set<string>;
|
||||
downloadingTrack: string | null;
|
||||
isDownloading: boolean;
|
||||
bulkDownloadType: "all" | "selected" | null;
|
||||
downloadProgress: number;
|
||||
currentDownloadInfo: { name: string; artists: string } | null;
|
||||
currentPage: number;
|
||||
itemsPerPage: number;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string) => void;
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
onStopDownload: () => void;
|
||||
onOpenFolder: () => void;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function AlbumInfo({
|
||||
albumInfo,
|
||||
trackList,
|
||||
searchQuery,
|
||||
sortBy,
|
||||
selectedTracks,
|
||||
downloadedTracks,
|
||||
downloadingTrack,
|
||||
isDownloading,
|
||||
bulkDownloadType,
|
||||
downloadProgress,
|
||||
currentDownloadInfo,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
onSearchChange,
|
||||
onSortChange,
|
||||
onToggleTrack,
|
||||
onToggleSelectAll,
|
||||
onDownloadTrack,
|
||||
onDownloadAll,
|
||||
onDownloadSelected,
|
||||
onStopDownload,
|
||||
onOpenFolder,
|
||||
onPageChange,
|
||||
}: AlbumInfoProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
{albumInfo.images && (
|
||||
<img
|
||||
src={albumInfo.images}
|
||||
alt={albumInfo.name}
|
||||
className="w-48 h-48 rounded-md shadow-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Album</p>
|
||||
<h2 className="text-4xl font-bold">{albumInfo.name}</h2>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">{albumInfo.artists}</span>
|
||||
<span>•</span>
|
||||
<span>{albumInfo.release_date}</span>
|
||||
<span>•</span>
|
||||
<span>{albumInfo.total_tracks} songs</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={onDownloadAll}
|
||||
className="gap-2"
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading && bulkDownloadType === "all" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
Download All
|
||||
</Button>
|
||||
{selectedTracks.length > 0 && (
|
||||
<Button
|
||||
onClick={onDownloadSelected}
|
||||
variant="secondary"
|
||||
className="gap-2"
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
Download Selected ({selectedTracks.length})
|
||||
</Button>
|
||||
)}
|
||||
{downloadedTracks.size > 0 && (
|
||||
<Button onClick={onOpenFolder} variant="outline" className="gap-2">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Open Folder
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{isDownloading && (
|
||||
<DownloadProgress
|
||||
progress={downloadProgress}
|
||||
currentTrack={currentDownloadInfo}
|
||||
onStop={onStopDownload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-4">
|
||||
<SearchAndSort
|
||||
searchQuery={searchQuery}
|
||||
sortBy={sortBy}
|
||||
onSearchChange={onSearchChange}
|
||||
onSortChange={onSortChange}
|
||||
/>
|
||||
<TrackList
|
||||
tracks={trackList}
|
||||
searchQuery={searchQuery}
|
||||
sortBy={sortBy}
|
||||
selectedTracks={selectedTracks}
|
||||
downloadedTracks={downloadedTracks}
|
||||
downloadingTrack={downloadingTrack}
|
||||
isDownloading={isDownloading}
|
||||
currentPage={currentPage}
|
||||
itemsPerPage={itemsPerPage}
|
||||
showCheckboxes={true}
|
||||
hideAlbumColumn={true}
|
||||
onToggleTrack={onToggleTrack}
|
||||
onToggleSelectAll={onToggleSelectAll}
|
||||
onDownloadTrack={onDownloadTrack}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { SearchAndSort } from "./SearchAndSort";
|
||||
import { TrackList } from "./TrackList";
|
||||
import { DownloadProgress } from "./DownloadProgress";
|
||||
import type { TrackMetadata } from "@/types/api";
|
||||
|
||||
interface ArtistInfoProps {
|
||||
artistInfo: {
|
||||
name: string;
|
||||
images: string;
|
||||
followers: number;
|
||||
genres: string[];
|
||||
};
|
||||
albumList: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
images: string;
|
||||
release_date: string;
|
||||
album_type: string;
|
||||
external_urls: string;
|
||||
}>;
|
||||
trackList: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
selectedTracks: string[];
|
||||
downloadedTracks: Set<string>;
|
||||
downloadingTrack: string | null;
|
||||
isDownloading: boolean;
|
||||
bulkDownloadType: "all" | "selected" | null;
|
||||
downloadProgress: number;
|
||||
currentDownloadInfo: { name: string; artists: string } | null;
|
||||
currentPage: number;
|
||||
itemsPerPage: number;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string) => void;
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
onStopDownload: () => void;
|
||||
onOpenFolder: () => void;
|
||||
onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function ArtistInfo({
|
||||
artistInfo,
|
||||
albumList,
|
||||
trackList,
|
||||
searchQuery,
|
||||
sortBy,
|
||||
selectedTracks,
|
||||
downloadedTracks,
|
||||
downloadingTrack,
|
||||
isDownloading,
|
||||
bulkDownloadType,
|
||||
downloadProgress,
|
||||
currentDownloadInfo,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
onSearchChange,
|
||||
onSortChange,
|
||||
onToggleTrack,
|
||||
onToggleSelectAll,
|
||||
onDownloadTrack,
|
||||
onDownloadAll,
|
||||
onDownloadSelected,
|
||||
onStopDownload,
|
||||
onOpenFolder,
|
||||
onAlbumClick,
|
||||
onPageChange,
|
||||
}: ArtistInfoProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
{artistInfo.images && (
|
||||
<img
|
||||
src={artistInfo.images}
|
||||
alt={artistInfo.name}
|
||||
className="w-48 h-48 rounded-full shadow-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm font-medium">Artist</p>
|
||||
<h2 className="text-4xl font-bold">{artistInfo.name}</h2>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span>{artistInfo.followers.toLocaleString()} followers</span>
|
||||
{artistInfo.genres.length > 0 && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{artistInfo.genres.join(", ")}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{albumList.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-2xl font-bold">Discography</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{albumList.map((album) => (
|
||||
<div
|
||||
key={album.id}
|
||||
className="group cursor-pointer"
|
||||
onClick={() =>
|
||||
onAlbumClick({
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
external_urls: album.external_urls,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="relative mb-4">
|
||||
{album.images && (
|
||||
<img
|
||||
src={album.images}
|
||||
alt={album.name}
|
||||
className="w-full aspect-square object-cover rounded-md shadow-md transition-shadow group-hover:shadow-xl"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="font-semibold truncate">{album.name}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{album.release_date?.split("-")[0]} • {album.album_type}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trackList.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-2xl font-bold">Popular Tracks</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={onDownloadAll}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading && bulkDownloadType === "all" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
Download All
|
||||
</Button>
|
||||
{selectedTracks.length > 0 && (
|
||||
<Button
|
||||
onClick={onDownloadSelected}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="gap-2"
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
Download Selected ({selectedTracks.length})
|
||||
</Button>
|
||||
)}
|
||||
{downloadedTracks.size > 0 && (
|
||||
<Button onClick={onOpenFolder} size="sm" variant="outline" className="gap-2">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Open Folder
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isDownloading && (
|
||||
<DownloadProgress
|
||||
progress={downloadProgress}
|
||||
currentTrack={currentDownloadInfo}
|
||||
onStop={onStopDownload}
|
||||
/>
|
||||
)}
|
||||
<SearchAndSort
|
||||
searchQuery={searchQuery}
|
||||
sortBy={sortBy}
|
||||
onSearchChange={onSearchChange}
|
||||
onSortChange={onSortChange}
|
||||
/>
|
||||
<TrackList
|
||||
tracks={trackList}
|
||||
searchQuery={searchQuery}
|
||||
sortBy={sortBy}
|
||||
selectedTracks={selectedTracks}
|
||||
downloadedTracks={downloadedTracks}
|
||||
downloadingTrack={downloadingTrack}
|
||||
isDownloading={isDownloading}
|
||||
currentPage={currentPage}
|
||||
itemsPerPage={itemsPerPage}
|
||||
showCheckboxes={true}
|
||||
hideAlbumColumn={false}
|
||||
onToggleTrack={onToggleTrack}
|
||||
onToggleSelectAll={onToggleSelectAll}
|
||||
onDownloadTrack={onDownloadTrack}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { StopCircle } from "lucide-react";
|
||||
|
||||
interface DownloadProgressProps {
|
||||
progress: number;
|
||||
currentTrack: { name: string; artists: string } | null;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
|
||||
return (
|
||||
<div className="w-full space-y-2 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={progress} className="h-2 flex-1" />
|
||||
<Button variant="destructive" size="sm" onClick={onStop}>
|
||||
<StopCircle className="h-4 w-4 mr-2" />
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{progress}% -{" "}
|
||||
{currentTrack
|
||||
? `${currentTrack.name} - ${currentTrack.artists}`
|
||||
: "Preparing download..."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Settings } from "@/components/Settings";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface HeaderProps {
|
||||
version: string;
|
||||
hasUpdate: boolean;
|
||||
}
|
||||
|
||||
export function Header({ version, hasUpdate }: HeaderProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<img
|
||||
src="/icon.svg"
|
||||
alt="SpotiFLAC"
|
||||
className="w-12 h-12 cursor-pointer"
|
||||
onClick={() => window.location.reload()}
|
||||
/>
|
||||
<h1
|
||||
className="text-4xl font-bold cursor-pointer"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
SpotiFLAC
|
||||
</h1>
|
||||
<div className="relative">
|
||||
<Badge variant="default" asChild>
|
||||
<a
|
||||
href="https://github.com/afkarxyz/SpotiFLAC/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
v{version}
|
||||
</a>
|
||||
</Badge>
|
||||
{hasUpdate && (
|
||||
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Get Spotify tracks in true FLAC from Tidal/Deezer — no account required.
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute right-0 top-0 flex gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon" asChild>
|
||||
<a
|
||||
href="https://github.com/afkarxyz/SpotiFLAC/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub Issues"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Report bug or request feature</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Settings />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { SearchAndSort } from "./SearchAndSort";
|
||||
import { TrackList } from "./TrackList";
|
||||
import { DownloadProgress } from "./DownloadProgress";
|
||||
import type { TrackMetadata } from "@/types/api";
|
||||
|
||||
interface PlaylistInfoProps {
|
||||
playlistInfo: {
|
||||
owner: {
|
||||
name: string;
|
||||
display_name: string;
|
||||
images: string;
|
||||
};
|
||||
tracks: {
|
||||
total: number;
|
||||
};
|
||||
followers: {
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
trackList: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
selectedTracks: string[];
|
||||
downloadedTracks: Set<string>;
|
||||
downloadingTrack: string | null;
|
||||
isDownloading: boolean;
|
||||
bulkDownloadType: "all" | "selected" | null;
|
||||
downloadProgress: number;
|
||||
currentDownloadInfo: { name: string; artists: string } | null;
|
||||
currentPage: number;
|
||||
itemsPerPage: number;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string) => void;
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
onStopDownload: () => void;
|
||||
onOpenFolder: () => void;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function PlaylistInfo({
|
||||
playlistInfo,
|
||||
trackList,
|
||||
searchQuery,
|
||||
sortBy,
|
||||
selectedTracks,
|
||||
downloadedTracks,
|
||||
downloadingTrack,
|
||||
isDownloading,
|
||||
bulkDownloadType,
|
||||
downloadProgress,
|
||||
currentDownloadInfo,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
onSearchChange,
|
||||
onSortChange,
|
||||
onToggleTrack,
|
||||
onToggleSelectAll,
|
||||
onDownloadTrack,
|
||||
onDownloadAll,
|
||||
onDownloadSelected,
|
||||
onStopDownload,
|
||||
onOpenFolder,
|
||||
onPageChange,
|
||||
}: PlaylistInfoProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
{playlistInfo.owner.images && (
|
||||
<img
|
||||
src={playlistInfo.owner.images}
|
||||
alt={playlistInfo.owner.name}
|
||||
className="w-48 h-48 rounded-md shadow-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Playlist</p>
|
||||
<h2 className="text-4xl font-bold">{playlistInfo.owner.name}</h2>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">{playlistInfo.owner.display_name}</span>
|
||||
<span>•</span>
|
||||
<span>{playlistInfo.tracks.total} songs</span>
|
||||
<span>•</span>
|
||||
<span>{playlistInfo.followers.total.toLocaleString()} followers</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={onDownloadAll}
|
||||
className="gap-2"
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading && bulkDownloadType === "all" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
Download All
|
||||
</Button>
|
||||
{selectedTracks.length > 0 && (
|
||||
<Button
|
||||
onClick={onDownloadSelected}
|
||||
variant="secondary"
|
||||
className="gap-2"
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading && bulkDownloadType === "selected" ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
Download Selected ({selectedTracks.length})
|
||||
</Button>
|
||||
)}
|
||||
{downloadedTracks.size > 0 && (
|
||||
<Button onClick={onOpenFolder} variant="outline" className="gap-2">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Open Folder
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{isDownloading && (
|
||||
<DownloadProgress
|
||||
progress={downloadProgress}
|
||||
currentTrack={currentDownloadInfo}
|
||||
onStop={onStopDownload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-4">
|
||||
<SearchAndSort
|
||||
searchQuery={searchQuery}
|
||||
sortBy={sortBy}
|
||||
onSearchChange={onSearchChange}
|
||||
onSortChange={onSortChange}
|
||||
/>
|
||||
<TrackList
|
||||
tracks={trackList}
|
||||
searchQuery={searchQuery}
|
||||
sortBy={sortBy}
|
||||
selectedTracks={selectedTracks}
|
||||
downloadedTracks={downloadedTracks}
|
||||
downloadingTrack={downloadingTrack}
|
||||
isDownloading={isDownloading}
|
||||
currentPage={currentPage}
|
||||
itemsPerPage={itemsPerPage}
|
||||
showCheckboxes={true}
|
||||
hideAlbumColumn={false}
|
||||
onToggleTrack={onToggleTrack}
|
||||
onToggleSelectAll={onToggleSelectAll}
|
||||
onDownloadTrack={onDownloadTrack}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Search, ArrowUpDown } from "lucide-react";
|
||||
|
||||
interface SearchAndSortProps {
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SearchAndSort({
|
||||
searchQuery,
|
||||
sortBy,
|
||||
onSearchChange,
|
||||
onSortChange,
|
||||
}: SearchAndSortProps) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search tracks..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={sortBy} onValueChange={onSortChange}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<ArrowUpDown className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="title-asc">Title (A-Z)</SelectItem>
|
||||
<SelectItem value="title-desc">Title (Z-A)</SelectItem>
|
||||
<SelectItem value="artist-asc">Artist (A-Z)</SelectItem>
|
||||
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
|
||||
<SelectItem value="duration-asc">Duration (Short)</SelectItem>
|
||||
<SelectItem value="duration-desc">Duration (Long)</SelectItem>
|
||||
<SelectItem value="downloaded">Downloaded</SelectItem>
|
||||
<SelectItem value="not-downloaded">Not Downloaded</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Search, Info, XCircle } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface SearchBarProps {
|
||||
url: string;
|
||||
loading: boolean;
|
||||
onUrlChange: (url: string) => void;
|
||||
onFetch: () => void;
|
||||
}
|
||||
|
||||
export function SearchBar({ url, loading, onUrlChange, onFetch }: SearchBarProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="px-6 py-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="spotify-url">Spotify URL</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Supports track, album, playlist, and artist URLs</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="spotify-url"
|
||||
placeholder="https://open.spotify.com/..."
|
||||
value={url}
|
||||
onChange={(e) => onUrlChange(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && onFetch()}
|
||||
className="pr-8"
|
||||
/>
|
||||
{url && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => onUrlChange("")}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={onFetch} disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Spinner />
|
||||
Fetching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="h-4 w-4" />
|
||||
Fetch
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,28 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw } from "lucide-react";
|
||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, type Settings as SettingsType } from "@/lib/settings";
|
||||
import { themes, applyTheme } from "@/lib/themes";
|
||||
import { OpenFolder } from "../../wailsjs/go/main/App";
|
||||
import { SelectFolder } from "../../wailsjs/go/main/App";
|
||||
|
||||
// Service Icons
|
||||
const TidalIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="inline-block w-[1.1em] h-[1.1em] mr-2">
|
||||
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
|
||||
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DeezerIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="inline-block w-[1.1em] h-[1.1em] mr-2">
|
||||
<path d="M18.77 5.55c.19-1.07.46-1.75.76-1.75.56 0 1.02 2.34 1.02 5.23 0 2.89-.46 5.23-1.02 5.23-.23 0-.44-.4-.61-1.06-.27 2.43-.83 4.11-1.48 4.11-.5 0-.96-1-1.26-2.6-.2 3.03-.73 5.17-1.33 5.17-.39 0-.73-.85-.99-2.23-.31 2.85-1.03 4.85-1.86 4.85-.83 0-1.55-2-1.86-4.85-.25 1.38-.6 2.23-.99 2.23-.6 0-1.12-2.14-1.33-5.16-.3 1.58-.75 2.6-1.26 2.6-.65 0-1.2-1.68-1.48-4.12-.17.66-.38 1.06-.61 1.06-.56 0-1.02-2.34-1.02-5.23 0-2.89.46-5.23 1.02-5.23.3 0 .57.68.76 1.75C5.53 3.7 6 2.5 6.56 2.5c.66 0 1.22 1.7 1.49 4.17.26-1.8.66-2.94 1.1-2.94.63 0 1.16 2.25 1.36 5.4.36-1.62.9-2.63 1.5-2.63.58 0 1.12 1.01 1.49 2.62.2-3.14.72-5.4 1.35-5.4.44 0 .84 1.15 1.1 2.95.27-2.47.84-4.17 1.49-4.17.55 0 1.03 1.2 1.33 3.05ZM2 8.52c0-1.3.26-2.34.58-2.34.32 0 .57 1.05.57 2.34 0 1.29-.25 2.34-.57 2.34-.32 0-.58-1.05-.58-2.34Zm18.85 0c0-1.3.25-2.34.57-2.34.32 0 .58 1.05.58 2.34 0 1.29-.26 2.34-.58 2.34-.32 0-.57-1.05-.57-2.34Z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const QobuzIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className="inline-block w-[1.1em] h-[1.1em] mr-2">
|
||||
<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 function Settings() {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -134,7 +155,7 @@ export function Settings() {
|
||||
setTempSettings((prev) => ({ ...prev, downloadPath: value }));
|
||||
};
|
||||
|
||||
const handleDownloaderChange = (value: "auto" | "deezer" | "tidal") => {
|
||||
const handleDownloaderChange = (value: "auto" | "deezer" | "tidal" | "qobuz") => {
|
||||
setTempSettings((prev) => ({ ...prev, downloader: value }));
|
||||
};
|
||||
|
||||
@@ -147,17 +168,19 @@ export function Settings() {
|
||||
};
|
||||
|
||||
const handleBrowseFolder = async () => {
|
||||
if (!tempSettings.downloadPath) {
|
||||
alert("Please enter a download path first");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Call backend to open folder in file explorer
|
||||
await OpenFolder(tempSettings.downloadPath);
|
||||
// Call backend to open folder selection dialog
|
||||
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
|
||||
console.log("Selected path:", selectedPath);
|
||||
|
||||
if (selectedPath && selectedPath.trim() !== "") {
|
||||
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
|
||||
} else {
|
||||
console.log("No folder selected or user cancelled");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error opening folder:", error);
|
||||
alert(`Error opening folder: ${error}`);
|
||||
console.error("Error selecting folder:", error);
|
||||
alert(`Error selecting folder: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -184,8 +207,8 @@ export function Settings() {
|
||||
placeholder="C:\Users\YourUsername\Music"
|
||||
/>
|
||||
<Button type="button" onClick={handleBrowseFolder}>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Open
|
||||
<FolderOpen className="h-4 w-4 mr-2" />
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,9 +224,32 @@ export function Settings() {
|
||||
<SelectValue placeholder="Select a source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto (Tidal → Deezer)</SelectItem>
|
||||
<SelectItem value="tidal">Tidal</SelectItem>
|
||||
<SelectItem value="deezer">Deezer</SelectItem>
|
||||
<SelectItem value="auto">
|
||||
<span className="flex items-center">
|
||||
<TidalIcon />
|
||||
<DeezerIcon />
|
||||
<QobuzIcon />
|
||||
Auto (Tidal → Deezer → Qobuz)
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="tidal">
|
||||
<span className="flex items-center">
|
||||
<TidalIcon />
|
||||
Tidal
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="deezer">
|
||||
<span className="flex items-center">
|
||||
<DeezerIcon />
|
||||
Deezer
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="qobuz">
|
||||
<span className="flex items-center">
|
||||
<QobuzIcon />
|
||||
Qobuz
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Download, FolderOpen } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { TrackMetadata } from "@/types/api";
|
||||
|
||||
interface TrackInfoProps {
|
||||
track: TrackMetadata & { album_name: string; release_date: string };
|
||||
isDownloading: boolean;
|
||||
downloadingTrack: string | null;
|
||||
isDownloaded: boolean;
|
||||
onDownload: (isrc: string, name: string, artists: string) => void;
|
||||
onOpenFolder: () => void;
|
||||
}
|
||||
|
||||
export function TrackInfo({
|
||||
track,
|
||||
isDownloading,
|
||||
downloadingTrack,
|
||||
isDownloaded,
|
||||
onDownload,
|
||||
onOpenFolder,
|
||||
}: TrackInfoProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="px-6">
|
||||
<div className="flex gap-6 items-start">
|
||||
{track.images && (
|
||||
<img
|
||||
src={track.images}
|
||||
alt={track.name}
|
||||
className="w-48 h-48 rounded-md shadow-lg object-cover shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 space-y-4 min-w-0">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
|
||||
<p className="text-lg text-muted-foreground">{track.artists}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Album</p>
|
||||
<p className="font-medium truncate">{track.album_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Release Date</p>
|
||||
<p className="font-medium">{track.release_date}</p>
|
||||
</div>
|
||||
</div>
|
||||
{track.isrc && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => onDownload(track.isrc, track.name, track.artists)}
|
||||
disabled={isDownloading || downloadingTrack === track.isrc}
|
||||
>
|
||||
{downloadingTrack === track.isrc ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{isDownloaded && (
|
||||
<Button onClick={onOpenFolder} variant="outline" className="gap-2">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Open Folder
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Download, CheckCircle } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import type { TrackMetadata } from "@/types/api";
|
||||
|
||||
interface TrackListProps {
|
||||
tracks: TrackMetadata[];
|
||||
searchQuery: string;
|
||||
sortBy: string;
|
||||
selectedTracks: string[];
|
||||
downloadedTracks: Set<string>;
|
||||
downloadingTrack: string | null;
|
||||
isDownloading: boolean;
|
||||
currentPage: number;
|
||||
itemsPerPage: number;
|
||||
showCheckboxes?: boolean;
|
||||
hideAlbumColumn?: boolean;
|
||||
onToggleTrack: (isrc: string) => void;
|
||||
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
|
||||
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function TrackList({
|
||||
tracks,
|
||||
searchQuery,
|
||||
sortBy,
|
||||
selectedTracks,
|
||||
downloadedTracks,
|
||||
downloadingTrack,
|
||||
isDownloading,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
showCheckboxes = false,
|
||||
hideAlbumColumn = false,
|
||||
onToggleTrack,
|
||||
onToggleSelectAll,
|
||||
onDownloadTrack,
|
||||
onPageChange,
|
||||
}: TrackListProps) {
|
||||
let filteredTracks = tracks.filter((track) => {
|
||||
if (!searchQuery) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
track.name.toLowerCase().includes(query) ||
|
||||
track.artists.toLowerCase().includes(query) ||
|
||||
track.album_name.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
// Apply sorting
|
||||
if (sortBy === "title-asc") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => a.name.localeCompare(b.name));
|
||||
} else if (sortBy === "title-desc") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => b.name.localeCompare(a.name));
|
||||
} else if (sortBy === "artist-asc") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => a.artists.localeCompare(b.artists));
|
||||
} else if (sortBy === "artist-desc") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => b.artists.localeCompare(a.artists));
|
||||
} else if (sortBy === "duration-asc") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => a.duration_ms - b.duration_ms);
|
||||
} else if (sortBy === "duration-desc") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => b.duration_ms - a.duration_ms);
|
||||
} else if (sortBy === "downloaded") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||
const aDownloaded = downloadedTracks.has(a.isrc);
|
||||
const bDownloaded = downloadedTracks.has(b.isrc);
|
||||
return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0);
|
||||
});
|
||||
} else if (sortBy === "not-downloaded") {
|
||||
filteredTracks = [...filteredTracks].sort((a, b) => {
|
||||
const aDownloaded = downloadedTracks.has(a.isrc);
|
||||
const bDownloaded = downloadedTracks.has(b.isrc);
|
||||
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0);
|
||||
});
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(filteredTracks.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedTracks = filteredTracks.slice(startIndex, endIndex);
|
||||
|
||||
const tracksWithIsrc = filteredTracks.filter((track) => track.isrc);
|
||||
const allSelected =
|
||||
tracksWithIsrc.length > 0 &&
|
||||
tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc));
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = Math.floor((ms % 60000) / 1000);
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
{showCheckboxes && (
|
||||
<th className="h-12 px-4 text-left align-middle w-12">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={() => onToggleSelectAll(filteredTracks)}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
|
||||
#
|
||||
</th>
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
|
||||
Title
|
||||
</th>
|
||||
{!hideAlbumColumn && (
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
|
||||
Album
|
||||
</th>
|
||||
)}
|
||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24">
|
||||
Duration
|
||||
</th>
|
||||
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedTracks.map((track, index) => (
|
||||
<tr key={index} className="border-b transition-colors hover:bg-muted/50">
|
||||
{showCheckboxes && (
|
||||
<td className="p-4 align-middle">
|
||||
{track.isrc && (
|
||||
<Checkbox
|
||||
checked={selectedTracks.includes(track.isrc)}
|
||||
onCheckedChange={() => onToggleTrack(track.isrc)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground">
|
||||
{startIndex + index + 1}
|
||||
</td>
|
||||
<td className="p-4 align-middle">
|
||||
<div className="flex items-center gap-3">
|
||||
{track.images && (
|
||||
<img
|
||||
src={track.images}
|
||||
alt={track.name}
|
||||
className="w-10 h-10 rounded object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{track.name}</span>
|
||||
{downloadedTracks.has(track.isrc) && (
|
||||
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{track.artists}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{!hideAlbumColumn && (
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden md:table-cell">
|
||||
{track.album_name}
|
||||
</td>
|
||||
)}
|
||||
<td className="p-4 align-middle text-sm text-muted-foreground hidden lg:table-cell">
|
||||
{formatDuration(track.duration_ms)}
|
||||
</td>
|
||||
<td className="p-4 align-middle text-center">
|
||||
{track.isrc && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name)
|
||||
}
|
||||
size="sm"
|
||||
disabled={isDownloading || downloadingTrack === track.isrc}
|
||||
>
|
||||
{downloadingTrack === track.isrc ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage > 1) onPageChange(currentPage - 1);
|
||||
}}
|
||||
className={
|
||||
currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onPageChange(page);
|
||||
}}
|
||||
isActive={currentPage === page}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage < totalPages) onPageChange(currentPage + 1);
|
||||
}}
|
||||
className={
|
||||
currentPage === totalPages
|
||||
? "pointer-events-none opacity-50"
|
||||
: "cursor-pointer"
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { downloadTrack } from "@/lib/api";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import { joinPath, sanitizePath } from "@/lib/utils";
|
||||
import type { TrackMetadata } from "@/types/api";
|
||||
|
||||
export function useDownload() {
|
||||
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [downloadingTrack, setDownloadingTrack] = useState<string | null>(null);
|
||||
const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null);
|
||||
const [downloadedTracks, setDownloadedTracks] = useState<Set<string>>(new Set());
|
||||
const [currentDownloadInfo, setCurrentDownloadInfo] = useState<{
|
||||
name: string;
|
||||
artists: string;
|
||||
} | null>(null);
|
||||
const shouldStopDownloadRef = useRef(false);
|
||||
|
||||
const downloadWithAutoFallback = async (
|
||||
isrc: string,
|
||||
settings: any,
|
||||
trackName?: string,
|
||||
artistName?: string,
|
||||
albumName?: string,
|
||||
playlistName?: string,
|
||||
isArtistDiscography?: boolean
|
||||
) => {
|
||||
let service = settings.downloader;
|
||||
|
||||
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
|
||||
const os = settings.operatingSystem;
|
||||
|
||||
let outputDir = settings.downloadPath;
|
||||
|
||||
if (playlistName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
|
||||
|
||||
if (isArtistDiscography) {
|
||||
if (settings.albumSubfolder && albumName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
|
||||
}
|
||||
} else {
|
||||
if (settings.artistSubfolder && artistName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
|
||||
}
|
||||
|
||||
if (settings.albumSubfolder && albumName) {
|
||||
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (service === "auto") {
|
||||
// Try Tidal first
|
||||
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,
|
||||
});
|
||||
|
||||
if (tidalResponse.success) {
|
||||
return tidalResponse;
|
||||
}
|
||||
} catch (tidalErr) {
|
||||
// Tidal failed, continue to Deezer
|
||||
}
|
||||
|
||||
// Try Deezer second
|
||||
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,
|
||||
});
|
||||
|
||||
if (deezerResponse.success) {
|
||||
return deezerResponse;
|
||||
}
|
||||
} catch (deezerErr) {
|
||||
// Deezer failed, continue to Qobuz
|
||||
}
|
||||
|
||||
// Try Qobuz as last fallback
|
||||
service = "qobuz";
|
||||
}
|
||||
|
||||
return await downloadTrack({
|
||||
isrc,
|
||||
service: service as "deezer" | "tidal" | "qobuz",
|
||||
query,
|
||||
track_name: trackName,
|
||||
artist_name: artistName,
|
||||
album_name: albumName,
|
||||
output_dir: outputDir,
|
||||
filename_format: settings.filenameFormat,
|
||||
track_number: settings.trackNumber,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadTrack = async (
|
||||
isrc: string,
|
||||
trackName?: string,
|
||||
artistName?: string,
|
||||
albumName?: string
|
||||
) => {
|
||||
if (!isrc) {
|
||||
toast.error("No ISRC found for this track");
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
setDownloadingTrack(isrc);
|
||||
|
||||
try {
|
||||
const response = await downloadWithAutoFallback(
|
||||
isrc,
|
||||
settings,
|
||||
trackName,
|
||||
artistName,
|
||||
albumName,
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
toast.success(response.message);
|
||||
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
||||
} else {
|
||||
toast.error(response.error || "Download failed");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Download failed");
|
||||
} finally {
|
||||
setDownloadingTrack(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadSelected = async (
|
||||
selectedTracks: string[],
|
||||
allTracks: TrackMetadata[],
|
||||
playlistName?: string,
|
||||
isArtistDiscography?: boolean
|
||||
) => {
|
||||
if (selectedTracks.length === 0) {
|
||||
toast.error("No tracks selected");
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
setIsDownloading(true);
|
||||
setBulkDownloadType("selected");
|
||||
setDownloadProgress(0);
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
const total = selectedTracks.length;
|
||||
|
||||
for (let i = 0; i < selectedTracks.length; i++) {
|
||||
if (shouldStopDownloadRef.current) {
|
||||
toast.info(
|
||||
`Download stopped. ${successCount} tracks downloaded, ${selectedTracks.length - i} skipped.`
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const isrc = selectedTracks[i];
|
||||
const track = allTracks.find((t) => t.isrc === isrc);
|
||||
|
||||
setDownloadingTrack(isrc);
|
||||
|
||||
if (track) {
|
||||
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await downloadWithAutoFallback(
|
||||
isrc,
|
||||
settings,
|
||||
track?.name,
|
||||
track?.artists,
|
||||
track?.album_name,
|
||||
playlistName,
|
||||
isArtistDiscography
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
successCount++;
|
||||
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
||||
} else {
|
||||
errorCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
errorCount++;
|
||||
}
|
||||
|
||||
setDownloadProgress(Math.round(((i + 1) / total) * 100));
|
||||
}
|
||||
|
||||
setDownloadingTrack(null);
|
||||
setCurrentDownloadInfo(null);
|
||||
setIsDownloading(false);
|
||||
setBulkDownloadType(null);
|
||||
shouldStopDownloadRef.current = false;
|
||||
|
||||
if (errorCount === 0) {
|
||||
toast.success(`Downloaded ${successCount} tracks successfully`);
|
||||
} else {
|
||||
toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadAll = async (
|
||||
tracks: TrackMetadata[],
|
||||
playlistName?: string,
|
||||
isArtistDiscography?: boolean
|
||||
) => {
|
||||
const tracksWithIsrc = tracks.filter((track) => track.isrc);
|
||||
|
||||
if (tracksWithIsrc.length === 0) {
|
||||
toast.error("No tracks available for download");
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
setIsDownloading(true);
|
||||
setBulkDownloadType("all");
|
||||
setDownloadProgress(0);
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
const total = tracksWithIsrc.length;
|
||||
|
||||
for (let i = 0; i < tracksWithIsrc.length; i++) {
|
||||
if (shouldStopDownloadRef.current) {
|
||||
toast.info(
|
||||
`Download stopped. ${successCount} tracks downloaded, ${tracksWithIsrc.length - i} skipped.`
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const track = tracksWithIsrc[i];
|
||||
|
||||
setDownloadingTrack(track.isrc);
|
||||
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
||||
|
||||
try {
|
||||
const response = await downloadWithAutoFallback(
|
||||
track.isrc,
|
||||
settings,
|
||||
track.name,
|
||||
track.artists,
|
||||
track.album_name,
|
||||
playlistName,
|
||||
isArtistDiscography
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
successCount++;
|
||||
setDownloadedTracks((prev) => new Set(prev).add(track.isrc));
|
||||
} else {
|
||||
errorCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
errorCount++;
|
||||
}
|
||||
|
||||
setDownloadProgress(Math.round(((i + 1) / total) * 100));
|
||||
}
|
||||
|
||||
setDownloadingTrack(null);
|
||||
setCurrentDownloadInfo(null);
|
||||
setIsDownloading(false);
|
||||
setBulkDownloadType(null);
|
||||
shouldStopDownloadRef.current = false;
|
||||
|
||||
if (errorCount === 0) {
|
||||
toast.success(`Downloaded ${successCount} tracks successfully`);
|
||||
} else {
|
||||
toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopDownload = () => {
|
||||
shouldStopDownloadRef.current = true;
|
||||
toast.info("Stopping download...");
|
||||
};
|
||||
|
||||
const resetDownloadedTracks = () => {
|
||||
setDownloadedTracks(new Set());
|
||||
};
|
||||
|
||||
return {
|
||||
downloadProgress,
|
||||
isDownloading,
|
||||
downloadingTrack,
|
||||
bulkDownloadType,
|
||||
downloadedTracks,
|
||||
currentDownloadInfo,
|
||||
handleDownloadTrack,
|
||||
handleDownloadSelected,
|
||||
handleDownloadAll,
|
||||
handleStopDownload,
|
||||
resetDownloadedTracks,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState } from "react";
|
||||
import { fetchSpotifyMetadata } from "@/lib/api";
|
||||
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||
import type { SpotifyMetadataResponse } from "@/types/api";
|
||||
|
||||
export function useMetadata() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [metadata, setMetadata] = useState<SpotifyMetadataResponse | null>(null);
|
||||
const [showTimeoutDialog, setShowTimeoutDialog] = useState(false);
|
||||
const [timeoutValue, setTimeoutValue] = useState(60);
|
||||
const [pendingUrl, setPendingUrl] = useState("");
|
||||
const [showAlbumDialog, setShowAlbumDialog] = useState(false);
|
||||
const [selectedAlbum, setSelectedAlbum] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
} | null>(null);
|
||||
|
||||
const fetchMetadataDirectly = async (url: string) => {
|
||||
setLoading(true);
|
||||
setMetadata(null);
|
||||
|
||||
try {
|
||||
const data = await fetchSpotifyMetadata(url);
|
||||
setMetadata(data);
|
||||
toast.success("Metadata fetched successfully");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to fetch metadata");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFetchMetadata = async (url: string) => {
|
||||
if (!url.trim()) {
|
||||
toast.error("Please enter a Spotify URL");
|
||||
return;
|
||||
}
|
||||
|
||||
let urlToFetch = url.trim();
|
||||
const isArtistUrl = urlToFetch.includes("/artist/");
|
||||
|
||||
if (isArtistUrl && !urlToFetch.includes("/discography")) {
|
||||
urlToFetch = urlToFetch.replace(/\/$/, "") + "/discography/all";
|
||||
}
|
||||
|
||||
if (isArtistUrl) {
|
||||
setPendingUrl(urlToFetch);
|
||||
setShowTimeoutDialog(true);
|
||||
} else {
|
||||
await fetchMetadataDirectly(urlToFetch);
|
||||
}
|
||||
|
||||
return urlToFetch;
|
||||
};
|
||||
|
||||
const handleConfirmFetch = async () => {
|
||||
setShowTimeoutDialog(false);
|
||||
setLoading(true);
|
||||
setMetadata(null);
|
||||
|
||||
try {
|
||||
const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
|
||||
setMetadata(data);
|
||||
toast.success("Metadata fetched successfully");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to fetch metadata");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAlbumClick = (album: {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: string;
|
||||
}) => {
|
||||
setSelectedAlbum(album);
|
||||
setShowAlbumDialog(true);
|
||||
};
|
||||
|
||||
const handleConfirmAlbumFetch = async () => {
|
||||
if (!selectedAlbum) return;
|
||||
|
||||
setShowAlbumDialog(false);
|
||||
setLoading(true);
|
||||
setMetadata(null);
|
||||
|
||||
try {
|
||||
const data = await fetchSpotifyMetadata(selectedAlbum.external_urls);
|
||||
setMetadata(data);
|
||||
toast.success("Album metadata fetched successfully");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to fetch album metadata");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSelectedAlbum(null);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
loading,
|
||||
metadata,
|
||||
showTimeoutDialog,
|
||||
setShowTimeoutDialog,
|
||||
timeoutValue,
|
||||
setTimeoutValue,
|
||||
showAlbumDialog,
|
||||
setShowAlbumDialog,
|
||||
selectedAlbum,
|
||||
handleFetchMetadata,
|
||||
handleConfirmFetch,
|
||||
handleAlbumClick,
|
||||
handleConfirmAlbumFetch,
|
||||
};
|
||||
}
|
||||
@@ -2,13 +2,14 @@ import { GetDefaults } from "../../wailsjs/go/main/App";
|
||||
|
||||
export interface Settings {
|
||||
downloadPath: string;
|
||||
downloader: "auto" | "deezer" | "tidal";
|
||||
downloader: "auto" | "deezer" | "tidal" | "qobuz";
|
||||
theme: string;
|
||||
themeMode: "auto" | "light" | "dark";
|
||||
filenameFormat: "title-artist" | "artist-title" | "title";
|
||||
artistSubfolder: boolean;
|
||||
albumSubfolder: boolean;
|
||||
trackNumber: boolean;
|
||||
operatingSystem: "Windows" | "linux/MacOS"
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
@@ -20,16 +21,17 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
artistSubfolder: false,
|
||||
albumSubfolder: false,
|
||||
trackNumber: false,
|
||||
operatingSystem: "Windows"
|
||||
};
|
||||
|
||||
async function fetchDefaultPath(): Promise<string> {
|
||||
try {
|
||||
const data = await GetDefaults();
|
||||
return data.downloadPath || "C:\\Users\\Public\\Music";
|
||||
return data.downloadPath || "";
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch default path:", error);
|
||||
return "";
|
||||
}
|
||||
return "C:\\Users\\Public\\Music";
|
||||
}
|
||||
|
||||
const SETTINGS_KEY = "spotiflac-settings";
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import type { Settings } from "./settings";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
|
||||
export function sanitizePath(input: string, os: string): string {
|
||||
if (os === "Windows") {
|
||||
return input.replace(/[<>:"/\\|?*]/g, "_");
|
||||
}
|
||||
|
||||
// unix-based OS
|
||||
return input.replace(/\//g, "_");
|
||||
}
|
||||
|
||||
export function joinPath(os: string, ...parts: string[]): string {
|
||||
const sep = os === "Windows" ? "\\" : "/";
|
||||
|
||||
return parts
|
||||
.filter(Boolean)
|
||||
.map(p => p.replace(/^[/\\]+|[/\\]+$/g, ""))
|
||||
.join(sep);
|
||||
}
|
||||
|
||||
export function buildOutputPath(settings: Settings, folder?: string) {
|
||||
const os = settings.operatingSystem;
|
||||
|
||||
const base = settings.downloadPath || "";
|
||||
const sanitized = folder ? sanitizePath(folder, os) : undefined;
|
||||
|
||||
return sanitized ? joinPath(os, base, sanitized) : base;
|
||||
}
|
||||
@@ -97,8 +97,11 @@ export type SpotifyMetadataResponse =
|
||||
|
||||
export interface DownloadRequest {
|
||||
isrc: string;
|
||||
service: "deezer" | "tidal";
|
||||
service: "deezer" | "tidal" | "qobuz";
|
||||
query?: string;
|
||||
track_name?: string;
|
||||
artist_name?: string;
|
||||
album_name?: string;
|
||||
api_url?: string;
|
||||
output_dir?: string;
|
||||
audio_format?: string;
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "5.5"
|
||||
"version": "5.6"
|
||||
}
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"info": {
|
||||
"companyName": "afkarxyz",
|
||||
"productName": "SpotiFLAC",
|
||||
"productVersion": "5.6",
|
||||
"productVersion": "5.7",
|
||||
"copyright": "Copyright © 2025",
|
||||
"comments": "Get Spotify tracks in true FLAC from Tidal/Deezer — no account required."
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user