From 6ee3c2f65347be23a2e4f8a804ba111be92885fb Mon Sep 17 00:00:00 2001 From: afkarxyz Date: Mon, 24 Nov 2025 14:52:47 +0700 Subject: [PATCH] v6.1 --- app.go | 108 ++++++++-- backend/amazon.go | 28 ++- backend/deezer.go | 103 +++++++++- backend/metadata.go | 71 +++++++ backend/qobuz.go | 6 + backend/songlink.go | 150 ++++++++++++++ backend/spotify_metadata.go | 9 +- backend/tidal.go | 247 +++++++++++++++-------- frontend/src/App.tsx | 6 +- frontend/src/components/AlbumInfo.tsx | 3 + frontend/src/components/ArtistInfo.tsx | 3 + frontend/src/components/PlaylistInfo.tsx | 3 + frontend/src/components/SearchBar.tsx | 97 +++++---- frontend/src/components/TitleBar.tsx | 2 +- frontend/src/components/TrackInfo.tsx | 14 +- frontend/src/components/TrackList.tsx | 7 +- frontend/src/hooks/useDownload.ts | 191 ++++++++++++++---- frontend/src/types/api.ts | 1 + go.mod | 20 +- go.sum | 42 ++-- tidal.json | 5 +- wails.json | 2 +- 22 files changed, 865 insertions(+), 253 deletions(-) create mode 100644 backend/songlink.go diff --git a/app.go b/app.go index c443a82..6fe6e97 100644 --- a/app.go +++ b/app.go @@ -50,7 +50,8 @@ type DownloadRequest struct { TrackNumber bool `json:"track_number,omitempty"` Position int `json:"position,omitempty"` // Position in playlist/album (1-based) UseAlbumTrackNumber bool `json:"use_album_track_number,omitempty"` // Use album track number instead of playlist position - SpotifyID string `json:"spotify_id,omitempty"` // Spotify track ID for Amazon Music + 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 } // DownloadResponse represents the response structure for download operations @@ -62,6 +63,27 @@ type DownloadResponse struct { AlreadyExists bool `json:"already_exists,omitempty"` } +// GetStreamingURLs fetches all streaming URLs from song.link API +func (a *App) GetStreamingURLs(spotifyTrackID string) (string, error) { + if spotifyTrackID == "" { + return "", fmt.Errorf("spotify track ID is required") + } + + fmt.Printf("[GetStreamingURLs] Called for track ID: %s\n", spotifyTrackID) + client := backend.NewSongLinkClient() + urls, err := client.GetAllURLsFromSpotify(spotifyTrackID) + if err != nil { + return "", err + } + + jsonData, err := json.Marshal(urls) + if err != nil { + return "", fmt.Errorf("failed to encode response: %v", err) + } + + return string(jsonData), nil +} + // GetSpotifyMetadata fetches metadata from Spotify func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) { if req.URL == "" { @@ -120,7 +142,18 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { req.FilenameFormat = "title-artist" } - // Early check: if we have track metadata, check if file already exists + // Early check: Check if file with same ISRC already exists + if existingFile, exists := backend.CheckISRCExists(req.OutputDir, req.ISRC); exists { + fmt.Printf("File with ISRC %s already exists: %s\n", req.ISRC, existingFile) + return DownloadResponse{ + Success: true, + Message: "File with same ISRC already exists", + File: existingFile, + AlreadyExists: true, + }, nil + } + + // Fallback: if we have track metadata, check if file already exists by filename if req.TrackName != "" && req.ArtistName != "" { expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.FilenameFormat, req.TrackNumber, req.Position, req.UseAlbumTrackNumber) expectedPath := filepath.Join(req.OutputDir, expectedFilename) @@ -139,34 +172,71 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { backend.SetDownloading(true) defer backend.SetDownloading(false) - if req.Service == "amazon" { - if req.SpotifyID == "" { - return DownloadResponse{ - Success: false, - Error: "Spotify ID is required for Amazon Music", - }, fmt.Errorf("Spotify ID is required for Amazon Music") - } + switch req.Service { + case "amazon": downloader := backend.NewAmazonDownloader() - filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) - } else if req.Service == "tidal" { - searchQuery := req.Query - if searchQuery == "" { - searchQuery = req.ISRC + if req.ServiceURL != "" { + // Use provided URL directly + filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) + } else { + if req.SpotifyID == "" { + return DownloadResponse{ + Success: false, + Error: "Spotify ID is required for Amazon Music", + }, fmt.Errorf("spotify ID is required for Amazon Music") + } + filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) } + case "tidal": if req.ApiURL == "" || req.ApiURL == "auto" { downloader := backend.NewTidalDownloader("") - filename, err = downloader.DownloadWithFallback(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) + if req.ServiceURL != "" { + // Use provided URL directly with fallback to multiple APIs + filename, err = downloader.DownloadByURLWithFallback(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) + } else { + if req.SpotifyID == "" { + return DownloadResponse{ + Success: false, + Error: "Spotify ID is required for Tidal", + }, fmt.Errorf("spotify ID is required for Tidal") + } + filename, err = downloader.DownloadWithFallback(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) + } } else { downloader := backend.NewTidalDownloader(req.ApiURL) - filename, err = downloader.Download(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) + if req.ServiceURL != "" { + // Use provided URL directly with specific API + filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) + } else { + if req.SpotifyID == "" { + return DownloadResponse{ + Success: false, + Error: "Spotify ID is required for Tidal", + }, fmt.Errorf("spotify ID is required for Tidal") + } + filename, err = downloader.Download(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) + } } - } else if req.Service == "qobuz" { + + case "qobuz": downloader := backend.NewQobuzDownloader() filename, err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) - } else { + + default: // deezer downloader := backend.NewDeezerDownloader() - filename, err = downloader.DownloadByISRC(req.ISRC, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) + if req.ServiceURL != "" { + // Use provided URL directly + filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) + } else { + if req.SpotifyID == "" { + return DownloadResponse{ + Success: false, + Error: "Spotify ID is required for Deezer", + }, fmt.Errorf("spotify ID is required for Deezer") + } + filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.UseAlbumTrackNumber) + } } if err != nil { diff --git a/backend/amazon.go b/backend/amazon.go index 4ce2699..ae5528c 100644 --- a/backend/amazon.go +++ b/backend/amazon.go @@ -356,7 +356,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str return "", fmt.Errorf("all regions failed. Last error: %v", lastError) } -func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { +func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { // Create output directory if needed if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { @@ -364,12 +364,19 @@ func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filena } } - // Get Amazon URL from Spotify track ID - amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID) - if err != nil { - return "", err + // Check if file with expected name already exists (Amazon doesn't provide ISRC before download) + if spotifyTrackName != "" && spotifyArtistName != "" { + expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) + expectedPath := filepath.Join(outputDir, expectedFilename) + + if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 { + fmt.Printf("File already exists: %s (%.2f MB)\n", expectedPath, float64(fileInfo.Size())/(1024*1024)) + return "EXISTS:" + expectedPath, nil + } } + fmt.Printf("Using Amazon URL: %s\n", amazonURL) + // Download from service filePath, err := a.DownloadFromService(amazonURL, outputDir) if err != nil { @@ -410,5 +417,16 @@ func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filena } fmt.Println("Done") + fmt.Println("✓ Downloaded successfully from Amazon Music") return filePath, nil } + +func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { + // Get Amazon URL from Spotify track ID + amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID) + if err != nil { + return "", err + } + + return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber) +} diff --git a/backend/deezer.go b/backend/deezer.go index 224a574..57627b9 100644 --- a/backend/deezer.go +++ b/backend/deezer.go @@ -56,10 +56,75 @@ func NewDeezerDownloader() *DeezerDownloader { } } -func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) { +func (d *DeezerDownloader) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) { // Decode base64 API URL - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlemVyLmNvbS8yLjAvdHJhY2svaXNyYzo=") - url := fmt.Sprintf("%s%s", string(apiBase), isrc) + 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), spotifyURL) + + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + fmt.Println("Getting Deezer URL...") + + resp, err := d.client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to get Deezer URL: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("API returned status %d", resp.StatusCode) + } + + var songLinkResp struct { + LinksByPlatform map[string]struct { + URL string `json:"url"` + } `json:"linksByPlatform"` + } + if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + deezerLink, ok := songLinkResp.LinksByPlatform["deezer"] + if !ok || deezerLink.URL == "" { + return "", fmt.Errorf("deezer link not found") + } + + deezerURL := deezerLink.URL + fmt.Printf("Found Deezer URL: %s\n", deezerURL) + return deezerURL, nil +} + +func (d *DeezerDownloader) GetTrackIDFromURL(deezerURL string) (int64, error) { + // Extract track ID from Deezer URL + // Format: https://www.deezer.com/track/3412534581 + parts := strings.Split(deezerURL, "/track/") + if len(parts) < 2 { + return 0, fmt.Errorf("invalid Deezer URL format") + } + + // Get the track ID part and remove any query parameters + trackIDStr := strings.Split(parts[1], "?")[0] + trackIDStr = strings.TrimSpace(trackIDStr) + + var trackID int64 + _, err := fmt.Sscanf(trackIDStr, "%d", &trackID) + if err != nil { + return 0, fmt.Errorf("failed to parse track ID: %w", err) + } + + return trackID, nil +} + +func (d *DeezerDownloader) GetTrackByID(trackID int64) (*DeezerTrack, error) { + // Decode base64 API URL + apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlemVyLmNvbS8yLjAvdHJhY2sv") + url := fmt.Sprintf("%s%d", string(apiBase), trackID) resp, err := d.client.Get(url) if err != nil { @@ -77,7 +142,7 @@ func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) { } if track.ID == 0 { - return nil, fmt.Errorf("track not found for ISRC: %s", isrc) + return nil, fmt.Errorf("track not found") } return &track, nil @@ -187,10 +252,17 @@ func buildFilename(title, artist string, trackNumber int, format string, include return filename + ".flac" } -func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { - fmt.Printf("Fetching track info for ISRC: %s\n", isrc) +func (d *DeezerDownloader) DownloadByURL(deezerURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { + fmt.Printf("Using Deezer URL: %s\n", deezerURL) - track, err := d.GetTrackByISRC(isrc) + // Extract track ID from URL + trackID, err := d.GetTrackIDFromURL(deezerURL) + if err != nil { + return "", err + } + + // Get track info by ID + track, err := d.GetTrackByID(trackID) if err != nil { return "", err } @@ -234,6 +306,12 @@ func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string safeArtist := sanitizeFilename(artists) safeTitle := sanitizeFilename(trackTitle) + // Check if file with same ISRC already exists + if existingFile, exists := CheckISRCExists(outputDir, track.ISRC); exists { + fmt.Printf("File with ISRC %s already exists: %s\n", track.ISRC, existingFile) + return "EXISTS:" + existingFile, nil + } + // Build filename based on format settings filename := buildFilename(safeTitle, safeArtist, track.TrackPos, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) filepath := filepath.Join(outputDir, filename) @@ -287,5 +365,16 @@ func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string } fmt.Println("Metadata embedded successfully!") + fmt.Println("✓ Downloaded successfully from Deezer") return filepath, nil } + +func (d *DeezerDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { + // Get Deezer URL from Spotify track ID + deezerURL, err := d.GetDeezerURLFromSpotify(spotifyTrackID) + if err != nil { + return "", err + } + + return d.DownloadByURL(deezerURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber) +} diff --git a/backend/metadata.go b/backend/metadata.go index 7bb8bed..f143338 100644 --- a/backend/metadata.go +++ b/backend/metadata.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strconv" + "strings" "github.com/go-flac/flacpicture" "github.com/go-flac/flacvorbis" @@ -111,3 +112,73 @@ func fileExists(path string) bool { _, err := os.Stat(path) return err == nil } + +// ReadISRCFromFile reads ISRC metadata from a FLAC file +func ReadISRCFromFile(filepath string) (string, error) { + if !fileExists(filepath) { + return "", fmt.Errorf("file does not exist") + } + + f, err := flac.ParseFile(filepath) + if err != nil { + return "", fmt.Errorf("failed to parse FLAC file: %w", err) + } + + // Find VorbisComment block + for _, block := range f.Meta { + if block.Type == flac.VorbisComment { + cmt, err := flacvorbis.ParseFromMetaDataBlock(*block) + if err != nil { + continue + } + + // Get ISRC field + isrcValues, err := cmt.Get(flacvorbis.FIELD_ISRC) + if err == nil && len(isrcValues) > 0 { + return isrcValues[0], nil + } + } + } + + return "", nil // No ISRC found +} + +// CheckISRCExists checks if a file with the given ISRC already exists in the directory +func CheckISRCExists(outputDir string, targetISRC string) (string, bool) { + if targetISRC == "" { + return "", false + } + + // Read all .flac files in directory + entries, err := os.ReadDir(outputDir) + if err != nil { + return "", false + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + // Check only .flac files + filename := entry.Name() + if len(filename) < 5 || filename[len(filename)-5:] != ".flac" { + continue + } + + filepath := fmt.Sprintf("%s/%s", outputDir, filename) + + // Read ISRC from file + isrc, err := ReadISRCFromFile(filepath) + if err != nil { + continue + } + + // Compare ISRC (case-insensitive) + if isrc != "" && strings.EqualFold(isrc, targetISRC) { + return filepath, true + } + } + + return "", false +} diff --git a/backend/qobuz.go b/backend/qobuz.go index 7a46049..b2cff6c 100644 --- a/backend/qobuz.go +++ b/backend/qobuz.go @@ -315,6 +315,12 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma safeArtist := sanitizeFilename(artists) safeTitle := sanitizeFilename(trackTitle) + // Check if file with same ISRC already exists + if existingFile, exists := CheckISRCExists(outputDir, track.ISRC); exists { + fmt.Printf("File with ISRC %s already exists: %s\n", track.ISRC, existingFile) + return "EXISTS:" + existingFile, nil + } + // Build filename based on format settings filename := buildQobuzFilename(safeTitle, safeArtist, track.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) filepath := filepath.Join(outputDir, filename) diff --git a/backend/songlink.go b/backend/songlink.go new file mode 100644 index 0000000..735866e --- /dev/null +++ b/backend/songlink.go @@ -0,0 +1,150 @@ +package backend + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +type SongLinkClient struct { + client *http.Client + lastAPICallTime time.Time + apiCallCount int + apiCallResetTime time.Time +} + +type SongLinkURLs struct { + TidalURL string `json:"tidal_url"` + DeezerURL string `json:"deezer_url"` + AmazonURL string `json:"amazon_url"` +} + +func NewSongLinkClient() *SongLinkClient { + return &SongLinkClient{ + client: &http.Client{ + Timeout: 30 * time.Second, + }, + apiCallResetTime: time.Now(), + } +} + +func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLinkURLs, 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.Println("Getting streaming URLs from song.link...") + + // 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 get URLs: %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) + } + + urls := &SongLinkURLs{} + + // Extract Tidal URL + if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { + urls.TidalURL = tidalLink.URL + fmt.Printf("✓ Tidal URL found\n") + } + + // Extract Deezer URL + if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { + urls.DeezerURL = deezerLink.URL + fmt.Printf("✓ Deezer URL found\n") + } + + // Extract Amazon URL + if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { + amazonURL := amazonLink.URL + // Convert album URL to track URL if needed + if len(amazonURL) > 0 { + urls.AmazonURL = amazonURL + fmt.Printf("✓ Amazon URL found\n") + } + } + + // Check if at least one URL was found + if urls.TidalURL == "" && urls.DeezerURL == "" && urls.AmazonURL == "" { + return nil, fmt.Errorf("no streaming URLs found") + } + + return urls, nil +} diff --git a/backend/spotify_metadata.go b/backend/spotify_metadata.go index 50ae6ff..b99e26c 100644 --- a/backend/spotify_metadata.go +++ b/backend/spotify_metadata.go @@ -271,11 +271,6 @@ type artistResponse struct { Popularity int `json:"popularity"` } -type artistAlbumsResponse struct { - Items []albumSimplified `json:"items"` - Next string `json:"next"` -} - type playlistRaw struct { Data playlistResponse BatchEnabled bool @@ -321,7 +316,7 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL return nil, err } - return c.processSpotifyData(ctx, raw, parsed.Type) + return c.processSpotifyData(ctx, raw) } func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed spotifyURI, token string, batch bool, delay time.Duration) (interface{}, error) { @@ -341,7 +336,7 @@ func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed sp } } -func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}, dataType string) (interface{}, error) { +func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}) (interface{}, error) { switch payload := raw.(type) { case *playlistRaw: return c.formatPlaylistData(payload), nil diff --git a/backend/tidal.go b/backend/tidal.go index b2b5a49..1bf6246 100644 --- a/backend/tidal.go +++ b/backend/tidal.go @@ -22,13 +22,6 @@ type TidalDownloader struct { apiURL string } -type TidalSearchResponse struct { - Limit int `json:"limit"` - Offset int `json:"offset"` - TotalNumberOfItems int `json:"totalNumberOfItems"` - Items []TidalTrack `json:"items"` -} - type TidalTrack struct { ID int64 `json:"id"` Title string `json:"title"` @@ -68,6 +61,26 @@ func NewTidalDownloader(apiURL string) *TidalDownloader { clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==") clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=") + // If apiURL is empty, try to get first available API + if apiURL == "" { + downloader := &TidalDownloader{ + client: &http.Client{ + Timeout: 60 * time.Second, + }, + timeout: 30 * time.Second, + maxRetries: 3, + clientID: string(clientID), + clientSecret: string(clientSecret), + apiURL: "", + } + + // Try to get available APIs + apis, err := downloader.GetAvailableAPIs() + if err == nil && len(apis) > 0 { + apiURL = apis[0] // Use first available API + } + } + return &TidalDownloader{ client: &http.Client{ Timeout: 60 * time.Second, @@ -155,17 +168,83 @@ func (t *TidalDownloader) GetAccessToken() (string, error) { return result.AccessToken, nil } -func (t *TidalDownloader) SearchTracks(query string) (*TidalSearchResponse, error) { +func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) { + // 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 "", fmt.Errorf("failed to create request: %w", err) + } + + fmt.Println("Getting Tidal URL...") + + resp, err := t.client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to get Tidal URL: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("API returned status %d", resp.StatusCode) + } + + var songLinkResp struct { + LinksByPlatform map[string]struct { + URL string `json:"url"` + } `json:"linksByPlatform"` + } + if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + tidalLink, ok := songLinkResp.LinksByPlatform["tidal"] + if !ok || tidalLink.URL == "" { + return "", fmt.Errorf("tidal link not found") + } + + tidalURL := tidalLink.URL + fmt.Printf("Found Tidal URL: %s\n", tidalURL) + return tidalURL, nil +} + +func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) { + // Extract track ID from Tidal URL + // Format: https://listen.tidal.com/track/441821360 + // or: https://tidal.com/browse/track/123456789 + parts := strings.Split(tidalURL, "/track/") + if len(parts) < 2 { + return 0, fmt.Errorf("invalid tidal URL format") + } + + // Get the track ID part and remove any query parameters + trackIDStr := strings.Split(parts[1], "?")[0] + trackIDStr = strings.TrimSpace(trackIDStr) + + var trackID int64 + _, err := fmt.Sscanf(trackIDStr, "%d", &trackID) + if err != nil { + return 0, fmt.Errorf("failed to parse track ID: %w", err) + } + + return trackID, nil +} + +func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) { token, err := t.GetAccessToken() if err != nil { return nil, fmt.Errorf("failed to get access token: %w", err) } - // 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)) + // Decode base64 API URL + trackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3RyYWNrcy8=") + trackURL := fmt.Sprintf("%s%d?countryCode=US", string(trackBase), trackID) - req, err := http.NewRequest("GET", searchURL, nil) + req, err := http.NewRequest("GET", trackURL, nil) if err != nil { return nil, err } @@ -180,106 +259,55 @@ func (t *TidalDownloader) SearchTracks(query string) (*TidalSearchResponse, erro if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("search failed: HTTP %d - %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("failed to get track info: HTTP %d - %s", resp.StatusCode, string(body)) } - var result TidalSearchResponse - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + var trackInfo TidalTrack + if err := json.NewDecoder(resp.Body).Decode(&trackInfo); err != nil { return nil, err } - return &result, nil -} - -func (t *TidalDownloader) GetTrackInfo(query, isrc string) (*TidalTrack, error) { - fmt.Printf("Fetching: %s", query) - if isrc != "" { - fmt.Printf(" (ISRC: %s)", isrc) - } - fmt.Println() - - result, err := t.SearchTracks(query) - if err != nil { - return nil, err - } - - if len(result.Items) == 0 { - return nil, fmt.Errorf("no tracks found for query: %s", query) - } - - var selectedTrack *TidalTrack - - if isrc != "" { - var isrcMatches []TidalTrack - for _, item := range result.Items { - if item.ISRC == isrc { - isrcMatches = append(isrcMatches, item) - } - } - - if len(isrcMatches) > 1 { - for _, item := range isrcMatches { - for _, tag := range item.MediaMetadata.Tags { - if tag == "HIRES_LOSSLESS" { - selectedTrack = &item - break - } - } - if selectedTrack != nil { - break - } - } - if selectedTrack == nil { - selectedTrack = &isrcMatches[0] - } - } else if len(isrcMatches) == 1 { - selectedTrack = &isrcMatches[0] - } else { - selectedTrack = &result.Items[0] - } - } else { - selectedTrack = &result.Items[0] - } - - if selectedTrack == nil { - return nil, fmt.Errorf("track not found") - } - - fmt.Printf("Found: %s (%s)\n", selectedTrack.Title, selectedTrack.AudioQuality) - return selectedTrack, nil + fmt.Printf("Found: %s (%s)\n", trackInfo.Title, trackInfo.AudioQuality) + return &trackInfo, nil } func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) { fmt.Println("Fetching URL...") url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality) + fmt.Printf("Tidal API URL: %s\n", url) resp, err := t.client.Get(url) if err != nil { + fmt.Printf("✗ Tidal API request failed: %v\n", err) return "", fmt.Errorf("failed to get download URL: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { + fmt.Printf("✗ Tidal API returned status code: %d\n", resp.StatusCode) return "", fmt.Errorf("API returned status code: %d", resp.StatusCode) } var apiResponses []TidalAPIResponse if err := json.NewDecoder(resp.Body).Decode(&apiResponses); err != nil { + fmt.Printf("✗ Failed to decode Tidal API response: %v\n", err) return "", fmt.Errorf("failed to decode response: %w", err) } if len(apiResponses) == 0 { + fmt.Println("✗ Tidal API returned empty response") return "", fmt.Errorf("no download URL in response") } for _, item := range apiResponses { if item.OriginalTrackURL != "" { - fmt.Println("URL found") + fmt.Println("✓ Tidal download URL found") return item.OriginalTrackURL, nil } } + fmt.Println("✗ No valid download URL in Tidal API response") return "", fmt.Errorf("download URL not found in response") } @@ -333,14 +361,23 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error { return nil } -func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { +func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { return "", fmt.Errorf("directory error: %w", err) } } - trackInfo, err := t.GetTrackInfo(query, isrc) + fmt.Printf("Using Tidal URL: %s\n", tidalURL) + + // Extract track ID from URL + trackID, err := t.GetTrackIDFromURL(tidalURL) + if err != nil { + return "", err + } + + // Get track info by ID + trackInfo, err := t.GetTrackInfoByID(trackID) if err != nil { return "", err } @@ -385,6 +422,12 @@ func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameForm albumTitle = trackInfo.Album.Title } + // Check if file with same ISRC already exists + if existingFile, exists := CheckISRCExists(outputDir, trackInfo.ISRC); exists { + fmt.Printf("File with ISRC %s already exists: %s\n", trackInfo.ISRC, existingFile) + return "EXISTS:" + existingFile, nil + } + // Build filename based on format settings filename := buildTidalFilename(trackTitle, artistName, trackInfo.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) outputFilename := filepath.Join(outputDir, filename) @@ -454,22 +497,68 @@ func (t *TidalDownloader) Download(query, isrc, outputDir, quality, filenameForm } fmt.Println("Done") + fmt.Println("✓ Downloaded successfully from Tidal") return outputFilename, nil } -func (t *TidalDownloader) DownloadWithFallback(query, isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { +func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { apis, err := t.GetAvailableAPIs() if err != nil { return "", fmt.Errorf("no APIs available for fallback: %w", err) } + var lastError error + for i, apiURL := range apis { + fmt.Printf("[Tidal API %d/%d] Trying: %s\n", i+1, len(apis), apiURL) + + fallbackDownloader := NewTidalDownloader(apiURL) + + result, err := fallbackDownloader.DownloadByURL(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber) + if err == nil { + fmt.Printf("✓ Success with: %s\n", apiURL) + return result, nil + } + + lastError = err + errMsg := err.Error() + if len(errMsg) > 80 { + errMsg = errMsg[:80] + } + fmt.Printf("✗ Failed with %s: %s\n", apiURL, errMsg) + } + + return "", fmt.Errorf("all %d Tidal APIs failed. Last error: %v", len(apis), lastError) +} + +func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { + // Get Tidal URL from Spotify track ID + tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) + if err != nil { + return "", err + } + + return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber) +} + +func (t *TidalDownloader) DownloadWithFallback(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) { + apis, err := t.GetAvailableAPIs() + if err != nil { + return "", fmt.Errorf("no APIs available for fallback: %w", err) + } + + // Get Tidal URL once + tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) + if err != nil { + return "", err + } + var lastError error for i, apiURL := range apis { fmt.Printf("[Auto Fallback %d/%d] Trying: %s\n", i+1, len(apis), apiURL) fallbackDownloader := NewTidalDownloader(apiURL) - result, err := fallbackDownloader.Download(query, isrc, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber) + result, err := fallbackDownloader.DownloadByURL(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useAlbumTrackNumber) if err == nil { fmt.Printf("✓ Success with: %s\n", apiURL) return result, nil diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8f7c4b6..b3f9a9e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -40,7 +40,7 @@ function App() { const [hasUpdate, setHasUpdate] = useState(false); const ITEMS_PER_PAGE = 50; - const CURRENT_VERSION = "6.0"; + const CURRENT_VERSION = "6.1"; const download = useDownload(); const metadata = useMetadata(); @@ -144,6 +144,7 @@ function App() { isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(track.isrc)} + isFailed={download.failedTracks.has(track.isrc)} onDownload={download.handleDownloadTrack} onOpenFolder={handleOpenFolder} /> @@ -160,6 +161,7 @@ function App() { sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} + failedTracks={download.failedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} @@ -193,6 +195,7 @@ function App() { sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} + failedTracks={download.failedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} @@ -231,6 +234,7 @@ function App() { sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} + failedTracks={download.failedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} diff --git a/frontend/src/components/AlbumInfo.tsx b/frontend/src/components/AlbumInfo.tsx index dff3e36..05ffac6 100644 --- a/frontend/src/components/AlbumInfo.tsx +++ b/frontend/src/components/AlbumInfo.tsx @@ -20,6 +20,7 @@ interface AlbumInfoProps { sortBy: string; selectedTracks: string[]; downloadedTracks: Set; + failedTracks: Set; downloadingTrack: string | null; isDownloading: boolean; bulkDownloadType: "all" | "selected" | null; @@ -46,6 +47,7 @@ export function AlbumInfo({ sortBy, selectedTracks, downloadedTracks, + failedTracks, downloadingTrack, isDownloading, bulkDownloadType, @@ -145,6 +147,7 @@ export function AlbumInfo({ sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} + failedTracks={failedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} diff --git a/frontend/src/components/ArtistInfo.tsx b/frontend/src/components/ArtistInfo.tsx index 4da3dda..8974cd1 100644 --- a/frontend/src/components/ArtistInfo.tsx +++ b/frontend/src/components/ArtistInfo.tsx @@ -27,6 +27,7 @@ interface ArtistInfoProps { sortBy: string; selectedTracks: string[]; downloadedTracks: Set; + failedTracks: Set; downloadingTrack: string | null; isDownloading: boolean; bulkDownloadType: "all" | "selected" | null; @@ -55,6 +56,7 @@ export function ArtistInfo({ sortBy, selectedTracks, downloadedTracks, + failedTracks, downloadingTrack, isDownloading, bulkDownloadType, @@ -197,6 +199,7 @@ export function ArtistInfo({ sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} + failedTracks={failedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} diff --git a/frontend/src/components/PlaylistInfo.tsx b/frontend/src/components/PlaylistInfo.tsx index f020648..6e55b22 100644 --- a/frontend/src/components/PlaylistInfo.tsx +++ b/frontend/src/components/PlaylistInfo.tsx @@ -26,6 +26,7 @@ interface PlaylistInfoProps { sortBy: string; selectedTracks: string[]; downloadedTracks: Set; + failedTracks: Set; downloadingTrack: string | null; isDownloading: boolean; bulkDownloadType: "all" | "selected" | null; @@ -52,6 +53,7 @@ export function PlaylistInfo({ sortBy, selectedTracks, downloadedTracks, + failedTracks, downloadingTrack, isDownloading, bulkDownloadType, @@ -151,6 +153,7 @@ export function PlaylistInfo({ sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={downloadedTracks} + failedTracks={failedTracks} downloadingTrack={downloadingTrack} isDownloading={isDownloading} currentPage={currentPage} diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx index b2504e8..08c78a9 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/components/SearchBar.tsx @@ -1,6 +1,5 @@ import { Button } from "@/components/ui/button"; import { InputWithContext } from "@/components/ui/input-with-context"; -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"; @@ -19,56 +18,52 @@ interface SearchBarProps { export function SearchBar({ url, loading, onUrlChange, onFetch }: SearchBarProps) { return ( - - -
-
- - - - - - -

Supports track, album, playlist, and artist URLs

-
-
-
-
-
- onUrlChange(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && onFetch()} - className="pr-8" - /> - {url && ( - - )} -
- -
+
+
+ + + + + + +

Supports track, album, playlist, and artist URLs

+
+
+
+
+
+ onUrlChange(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && onFetch()} + className="pr-8" + /> + {url && ( + + )}
- - + +
+
); } diff --git a/frontend/src/components/TitleBar.tsx b/frontend/src/components/TitleBar.tsx index f5a45f2..2a2db97 100644 --- a/frontend/src/components/TitleBar.tsx +++ b/frontend/src/components/TitleBar.tsx @@ -27,7 +27,7 @@ export function TitleBar() { /> {/* Window control buttons */} -
+