diff --git a/app.go b/app.go
index ddf4b25..1853ca6 100644
--- a/app.go
+++ b/app.go
@@ -389,11 +389,15 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
close(lyricsChan)
}
- go func() {
- client := backend.NewSongLinkClient()
- isrc, _ := client.GetISRC(req.SpotifyID)
- isrcChan <- isrc
- }()
+ if req.Service == "qobuz" {
+ go func() {
+ client := backend.NewSongLinkClient()
+ isrc, _ := client.GetISRCDirect(req.SpotifyID)
+ isrcChan <- isrc
+ }()
+ } else {
+ close(isrcChan)
+ }
} else {
close(lyricsChan)
close(isrcChan)
diff --git a/backend/amazon.go b/backend/amazon.go
index 9acaaa6..5be0650 100644
--- a/backend/amazon.go
+++ b/backend/amazon.go
@@ -5,7 +5,6 @@ import (
"fmt"
"io"
"net/http"
- "net/url"
"os"
"os/exec"
"path/filepath"
@@ -19,12 +18,6 @@ type AmazonDownloader struct {
regions []string
}
-type SongLinkResponse struct {
- LinksByPlatform map[string]struct {
- URL string `json:"url"`
- } `json:"linksByPlatform"`
-}
-
type AmazonStreamResponse struct {
StreamURL string `json:"streamUrl"`
DecryptionKey string `json:"decryptionKey"`
@@ -40,65 +33,17 @@ func NewAmazonDownloader() *AmazonDownloader {
}
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
-
- spotifyBase := "https://open.spotify.com/track/"
- spotifyURL := fmt.Sprintf("%s%s", spotifyBase, spotifyTrackID)
-
- apiBase := "https://api.song.link/v1-alpha.1/links?url="
- apiURL := fmt.Sprintf("%s%s", apiBase, url.QueryEscape(spotifyURL))
-
- req, err := http.NewRequest("GET", apiURL, nil)
- if err != nil {
- return "", fmt.Errorf("failed to create request: %w", err)
- }
- req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
-
fmt.Println("Getting Amazon URL...")
-
- resp, err := a.client.Do(req)
+ client := NewSongLinkClient()
+ urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, "")
if err != nil {
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
}
- defer resp.Body.Close()
- if resp.StatusCode != 200 {
- return "", fmt.Errorf("API returned status %d", resp.StatusCode)
- }
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return "", fmt.Errorf("failed to read response body: %w", err)
- }
-
- if len(body) == 0 {
- return "", fmt.Errorf("API returned empty response")
- }
-
- var songLinkResp SongLinkResponse
- if err := json.Unmarshal(body, &songLinkResp); err != nil {
-
- bodyStr := string(body)
- if len(bodyStr) > 200 {
- bodyStr = bodyStr[:200] + "..."
- }
- return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
- }
-
- amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]
- if !ok || amazonLink.URL == "" {
+ amazonURL := normalizeAmazonMusicURL(urls.AmazonURL)
+ if amazonURL == "" {
return "", fmt.Errorf("amazon Music link not found")
}
-
- amazonURL := amazonLink.URL
-
- if strings.Contains(amazonURL, "trackAsin=") {
- parts := strings.Split(amazonURL, "trackAsin=")
- if len(parts) > 1 {
- trackAsin := strings.Split(parts[1], "&")[0]
- amazonURL = fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", trackAsin)
- }
- }
-
fmt.Printf("Found Amazon URL: %s\n", amazonURL)
return amazonURL, nil
}
diff --git a/backend/qobuz.go b/backend/qobuz.go
index 7dffe75..665327d 100644
--- a/backend/qobuz.go
+++ b/backend/qobuz.go
@@ -365,7 +365,7 @@ func (q *QobuzDownloader) DownloadTrack(spotifyID, outputDir, quality, filenameF
var deezerISRC string
if spotifyID != "" {
songlinkClient := NewSongLinkClient()
- isrc, err := songlinkClient.GetISRC(spotifyID)
+ isrc, err := songlinkClient.GetISRCDirect(spotifyID)
if err != nil {
return "", fmt.Errorf("failed to get ISRC: %v", err)
}
diff --git a/backend/songlink.go b/backend/songlink.go
index f8eef51..ae869b7 100644
--- a/backend/songlink.go
+++ b/backend/songlink.go
@@ -2,19 +2,31 @@ package backend
import (
"encoding/json"
+ "errors"
"fmt"
+ "html"
"io"
"net/http"
+ "net/http/cookiejar"
"net/url"
+ "regexp"
"strings"
"time"
)
+const songLinkUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"
+
+var (
+ errSongLinkRateLimited = errors.New("song.link rate limited")
+ isrcPattern = regexp.MustCompile(`\b([A-Z]{2}[A-Z0-9]{3}\d{7})\b`)
+ csrfTokenPattern = regexp.MustCompile(`name=["']csrfmiddlewaretoken["'][^>]*value=["']([^"']+)["']`)
+ songstatsScriptPattern = regexp.MustCompile(`(?is)`)
+ amazonAlbumTrackPath = regexp.MustCompile(`/albums/[A-Z0-9]{10}/(B[0-9A-Z]{9})`)
+ amazonTrackPath = regexp.MustCompile(`/tracks/(B[0-9A-Z]{9})`)
+)
+
type SongLinkClient struct {
- client *http.Client
- lastAPICallTime time.Time
- apiCallCount int
- apiCallResetTime time.Time
+ client *http.Client
}
type SongLinkURLs struct {
@@ -35,136 +47,44 @@ type TrackAvailability struct {
DeezerURL string `json:"deezer_url,omitempty"`
}
+type songLinkAPIResponse struct {
+ LinksByPlatform map[string]struct {
+ URL string `json:"url"`
+ } `json:"linksByPlatform"`
+}
+
+type resolvedTrackLinks struct {
+ TidalURL string
+ AmazonURL string
+ DeezerURL string
+ ISRC string
+}
+
func NewSongLinkClient() *SongLinkClient {
return &SongLinkClient{
client: &http.Client{
Timeout: 30 * time.Second,
},
- apiCallResetTime: time.Now(),
}
}
func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string, region string) (*SongLinkURLs, error) {
-
- now := time.Now()
- if now.Sub(s.apiCallResetTime) >= time.Minute {
- s.apiCallCount = 0
- s.apiCallResetTime = now
- }
-
- 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()
- }
- }
-
- 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)
- }
- }
-
- spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
-
- apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL))
-
- if region != "" {
- apiURL += fmt.Sprintf("&userCountry=%s", region)
- }
-
- 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...")
-
- 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)
- }
-
- 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"`
- }
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
- }
-
- if len(body) == 0 {
- return nil, fmt.Errorf("API returned empty response")
- }
-
- if err := json.Unmarshal(body, &songLinkResp); err != nil {
-
- bodyStr := string(body)
- if len(bodyStr) > 200 {
- bodyStr = bodyStr[:200] + "..."
- }
- return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
+ links, err := s.resolveSpotifyTrackLinks(spotifyTrackID, region)
+ if err != nil && (links == nil || (links.TidalURL == "" && links.AmazonURL == "")) {
+ return nil, err
}
urls := &SongLinkURLs{}
-
- if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
- urls.TidalURL = tidalLink.URL
- fmt.Printf("✓ Tidal URL found\n")
- }
-
- if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
- amazonURL := amazonLink.URL
-
- if len(amazonURL) > 0 {
- urls.AmazonURL = amazonURL
- fmt.Printf("✓ Amazon URL found\n")
- }
- }
-
- if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
- if isrc, err := getDeezerISRC(deezerLink.URL); err == nil && isrc != "" {
- urls.ISRC = isrc
- }
+ if links != nil {
+ urls.TidalURL = links.TidalURL
+ urls.AmazonURL = normalizeAmazonMusicURL(links.AmazonURL)
+ urls.ISRC = links.ISRC
}
if urls.TidalURL == "" && urls.AmazonURL == "" {
+ if err != nil {
+ return nil, err
+ }
return nil, fmt.Errorf("no streaming URLs found")
}
@@ -172,126 +92,53 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string, region str
}
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAvailability, error) {
-
- now := time.Now()
- if now.Sub(s.apiCallResetTime) >= time.Minute {
- s.apiCallCount = 0
- s.apiCallResetTime = now
- }
-
- 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()
- }
- }
-
- 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)
- }
- }
-
- spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
-
- apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL))
-
- req, err := http.NewRequest("GET", apiURL, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- fmt.Printf("Checking availability for track: %s\n", spotifyTrackID)
-
- maxRetries := 3
- var resp *http.Response
- for i := 0; i < maxRetries; i++ {
- resp, err = s.client.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to check availability: %w", err)
- }
-
- 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"`
- }
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
- }
-
- if len(body) == 0 {
- return nil, fmt.Errorf("API returned empty response")
- }
-
- if err := json.Unmarshal(body, &songLinkResp); err != nil {
-
- bodyStr := string(body)
- if len(bodyStr) > 200 {
- bodyStr = bodyStr[:200] + "..."
- }
- return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
- }
+ links, err := s.resolveSpotifyTrackLinks(spotifyTrackID, "")
availability := &TrackAvailability{
SpotifyID: spotifyTrackID,
}
- if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
- availability.Tidal = true
- availability.TidalURL = tidalLink.URL
+ if links != nil {
+ availability.TidalURL = links.TidalURL
+ availability.AmazonURL = normalizeAmazonMusicURL(links.AmazonURL)
+ availability.DeezerURL = normalizeDeezerTrackURL(links.DeezerURL)
+ availability.Tidal = availability.TidalURL != ""
+ availability.Amazon = availability.AmazonURL != ""
+ availability.Deezer = availability.DeezerURL != ""
}
- if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
- availability.Amazon = true
- availability.AmazonURL = amazonLink.URL
+ isrc := ""
+ if links != nil {
+ isrc = strings.TrimSpace(links.ISRC)
}
- if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
- deezerURL := deezerLink.URL
- availability.Deezer = true
- availability.DeezerURL = deezerURL
-
- deezerISRC, err := getDeezerISRC(deezerURL)
- if err == nil && deezerISRC != "" {
- qobuzAvailable := checkQobuzAvailability(deezerISRC)
- availability.Qobuz = qobuzAvailable
+ if isrc == "" && availability.DeezerURL != "" {
+ if deezerISRC, deezerErr := getDeezerISRC(availability.DeezerURL); deezerErr == nil {
+ isrc = deezerISRC
}
}
- return availability, nil
+ if isrc == "" {
+ if fallbackISRC, fallbackErr := s.lookupSpotifyISRC(spotifyTrackID); fallbackErr == nil {
+ isrc = fallbackISRC
+ } else if err == nil {
+ err = fallbackErr
+ }
+ }
+
+ if isrc != "" {
+ availability.Qobuz = checkQobuzAvailability(isrc)
+ }
+
+ if availability.Tidal || availability.Amazon || availability.Deezer || availability.Qobuz {
+ return availability, nil
+ }
+
+ if err != nil {
+ return availability, err
+ }
+
+ return availability, fmt.Errorf("no platforms found")
}
func checkQobuzAvailability(isrc string) bool {
@@ -323,107 +170,47 @@ func checkQobuzAvailability(isrc string) bool {
}
func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) {
-
- now := time.Now()
- if now.Sub(s.apiCallResetTime) >= time.Minute {
- s.apiCallCount = 0
- s.apiCallResetTime = now
+ links, err := s.resolveSpotifyTrackLinks(spotifyTrackID, "")
+ if links != nil && links.DeezerURL != "" {
+ deezerURL := normalizeDeezerTrackURL(links.DeezerURL)
+ fmt.Printf("Found Deezer URL: %s\n", deezerURL)
+ return deezerURL, nil
}
- 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()
+ isrc := ""
+ if links != nil {
+ isrc = strings.TrimSpace(links.ISRC)
+ }
+ if isrc == "" {
+ fallbackISRC, lookupErr := s.lookupSpotifyISRC(spotifyTrackID)
+ if lookupErr == nil {
+ isrc = fallbackISRC
+ } else if err == nil {
+ err = lookupErr
}
}
- 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)
+ if isrc != "" {
+ deezerURL, deezerErr := s.lookupDeezerTrackURLByISRC(isrc)
+ if deezerErr == nil {
+ fmt.Printf("Found Deezer URL: %s\n", deezerURL)
+ return deezerURL, nil
+ }
+ if err == nil {
+ err = deezerErr
}
}
- spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
-
- apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(spotifyURL))
-
- req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
- return "", fmt.Errorf("failed to create request: %w", err)
+ return "", err
}
-
- fmt.Println("Getting Deezer URL from song.link...")
-
- maxRetries := 3
- var resp *http.Response
- for i := 0; i < maxRetries; i++ {
- resp, err = s.client.Do(req)
- if err != nil {
- return "", fmt.Errorf("failed to get Deezer URL: %w", err)
- }
-
- 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 "", fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
- }
-
- if resp.StatusCode != 200 {
- resp.Body.Close()
- return "", 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 "", 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
+ return "", fmt.Errorf("deezer link not found")
}
func getDeezerISRC(deezerURL string) (string, error) {
-
- var trackID string
- if strings.Contains(deezerURL, "/track/") {
- parts := strings.Split(deezerURL, "/track/")
- if len(parts) > 1 {
- trackID = strings.Split(parts[1], "?")[0]
- trackID = strings.TrimSpace(trackID)
- }
- }
-
- if trackID == "" {
- return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", deezerURL)
+ trackID, err := extractDeezerTrackID(deezerURL)
+ if err != nil {
+ return "", err
}
apiURL := fmt.Sprintf("https://api.deezer.com/track/%s", trackID)
@@ -453,13 +240,686 @@ func getDeezerISRC(deezerURL string) (string, error) {
}
fmt.Printf("Found ISRC from Deezer: %s (track: %s)\n", deezerTrack.ISRC, deezerTrack.Title)
- return deezerTrack.ISRC, nil
+ return strings.ToUpper(strings.TrimSpace(deezerTrack.ISRC)), nil
}
func (s *SongLinkClient) GetISRC(spotifyID string) (string, error) {
- deezerURL, err := s.GetDeezerURLFromSpotify(spotifyID)
+ links, err := s.resolveSpotifyTrackLinks(spotifyID, "")
+ if links != nil && links.ISRC != "" {
+ return links.ISRC, nil
+ }
+
+ if links != nil && links.DeezerURL != "" {
+ if isrc, deezerErr := getDeezerISRC(links.DeezerURL); deezerErr == nil {
+ return isrc, nil
+ }
+ }
+
+ isrc, lookupErr := s.lookupSpotifyISRC(spotifyID)
+ if lookupErr == nil && isrc != "" {
+ return isrc, nil
+ }
+
+ if err != nil && lookupErr != nil {
+ return "", fmt.Errorf("%v | %v", err, lookupErr)
+ }
if err != nil {
return "", err
}
- return getDeezerISRC(deezerURL)
+ if lookupErr != nil {
+ return "", lookupErr
+ }
+
+ return "", fmt.Errorf("ISRC not found")
+}
+
+func (s *SongLinkClient) GetISRCDirect(spotifyID string) (string, error) {
+ return s.lookupSpotifyISRC(spotifyID)
+}
+
+func (s *SongLinkClient) resolveSpotifyTrackLinks(spotifyTrackID string, region string) (*resolvedTrackLinks, error) {
+ links := &resolvedTrackLinks{}
+ var attempts []string
+
+ spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
+
+ fmt.Println("Getting streaming URLs from song.link...")
+ resp, err := s.fetchSongLinkLinksByURL(spotifyURL, region)
+ if err == nil {
+ mergeSongLinkResponse(links, resp)
+ if links.DeezerURL != "" && links.ISRC == "" {
+ if isrc, deezerErr := getDeezerISRC(links.DeezerURL); deezerErr == nil {
+ links.ISRC = isrc
+ }
+ }
+ if hasAnySongLinkData(links) {
+ return links, nil
+ }
+ attempts = append(attempts, "song.link spotify: no links found")
+ } else {
+ if errors.Is(err, errSongLinkRateLimited) {
+ fmt.Println("song.link rate limited for Spotify URL, switching to fallback 1 (songstats)...")
+ } else {
+ fmt.Printf("song.link primary lookup failed: %v\n", err)
+ }
+ attempts = append(attempts, fmt.Sprintf("song.link spotify: %v", err))
+ }
+
+ isrc, lookupErr := s.lookupSpotifyISRC(spotifyTrackID)
+ if lookupErr != nil {
+ attempts = append(attempts, fmt.Sprintf("isrc lookup: %v", lookupErr))
+ } else {
+ links.ISRC = isrc
+ }
+
+ if links.ISRC != "" {
+ fmt.Printf("Fallback 1: fetching Songstats links for ISRC %s\n", links.ISRC)
+ if songstatsErr := s.populateLinksFromSongstats(links, links.ISRC); songstatsErr != nil {
+ attempts = append(attempts, fmt.Sprintf("songstats: %v", songstatsErr))
+ } else if links.TidalURL != "" && links.AmazonURL != "" {
+ return links, nil
+ }
+
+ fmt.Printf("Fallback 2: resolving Deezer track from ISRC %s\n", links.ISRC)
+ deezerURL, deezerErr := s.lookupDeezerTrackURLByISRC(links.ISRC)
+ if deezerErr != nil {
+ attempts = append(attempts, fmt.Sprintf("deezer isrc: %v", deezerErr))
+ } else {
+ if links.DeezerURL == "" {
+ links.DeezerURL = deezerURL
+ }
+ deezerResp, deezerSongLinkErr := s.fetchSongLinkLinksByURL(deezerURL, region)
+ if deezerSongLinkErr != nil {
+ attempts = append(attempts, fmt.Sprintf("song.link deezer: %v", deezerSongLinkErr))
+ } else {
+ mergeSongLinkResponse(links, deezerResp)
+ }
+ }
+ }
+
+ if hasAnySongLinkData(links) {
+ return links, nil
+ }
+
+ if len(attempts) == 0 {
+ attempts = append(attempts, "no streaming URLs found")
+ }
+
+ return links, errors.New(strings.Join(attempts, " | "))
+}
+
+func (s *SongLinkClient) fetchSongLinkLinksByURL(rawURL string, region string) (*songLinkAPIResponse, error) {
+ apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(rawURL))
+ if region != "" {
+ apiURL += fmt.Sprintf("&userCountry=%s", url.QueryEscape(region))
+ }
+
+ req, err := http.NewRequest("GET", apiURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("User-Agent", songLinkUserAgent)
+
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to call song.link: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == http.StatusTooManyRequests {
+ return nil, errSongLinkRateLimited
+ }
+ if resp.StatusCode != http.StatusOK {
+ bodyPreview, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
+ return nil, fmt.Errorf("song.link returned status %d (%s)", resp.StatusCode, strings.TrimSpace(string(bodyPreview)))
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read song.link response: %w", err)
+ }
+ if len(body) == 0 {
+ return nil, fmt.Errorf("song.link returned empty response")
+ }
+
+ var parsed songLinkAPIResponse
+ if err := json.Unmarshal(body, &parsed); err != nil {
+ bodyStr := string(body)
+ if len(bodyStr) > 200 {
+ bodyStr = bodyStr[:200] + "..."
+ }
+ return nil, fmt.Errorf("failed to decode song.link response: %w (response: %s)", err, bodyStr)
+ }
+
+ return &parsed, nil
+}
+
+func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error) {
+ spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
+
+ providers := []struct {
+ name string
+ fn func(string) (string, error)
+ }{
+ {name: "isrcfinder", fn: s.lookupISRCViaISRCFinder},
+ {name: "phpstack", fn: lookupISRCViaPHPStack},
+ {name: "findmyisrc", fn: lookupISRCViaFindMyISRC},
+ {name: "mixvibe", fn: lookupISRCViaMixvibe},
+ }
+
+ var errorsList []string
+ for _, provider := range providers {
+ fmt.Printf("Trying ISRC provider: %s\n", provider.name)
+ isrc, err := provider.fn(spotifyURL)
+ if err == nil && isrc != "" {
+ fmt.Printf("Found ISRC via %s: %s\n", provider.name, isrc)
+ return isrc, nil
+ }
+
+ if err != nil {
+ errorsList = append(errorsList, fmt.Sprintf("%s: %v", provider.name, err))
+ } else {
+ errorsList = append(errorsList, fmt.Sprintf("%s: no ISRC found", provider.name))
+ }
+ }
+
+ return "", errors.New(strings.Join(errorsList, " | "))
+}
+
+func (s *SongLinkClient) lookupISRCViaISRCFinder(spotifyURL string) (string, error) {
+ jar, err := cookiejar.New(nil)
+ if err != nil {
+ return "", fmt.Errorf("failed to create cookie jar: %w", err)
+ }
+
+ client := &http.Client{
+ Timeout: 20 * time.Second,
+ Jar: jar,
+ }
+
+ req, err := http.NewRequest("GET", "https://www.isrcfinder.com/", nil)
+ if err != nil {
+ return "", fmt.Errorf("failed to create GET request: %w", err)
+ }
+ req.Header.Set("User-Agent", songLinkUserAgent)
+ req.Header.Set("Referer", "https://www.isrcfinder.com/")
+ req.Header.Set("Origin", "https://www.isrcfinder.com")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("failed to load isrcfinder: %w", err)
+ }
+ body, err := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if err != nil {
+ return "", fmt.Errorf("failed to read isrcfinder response: %w", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("isrcfinder returned status %d", resp.StatusCode)
+ }
+
+ token := extractCSRFToken(string(body))
+ if token == "" {
+ if parsedURL, parseErr := url.Parse("https://www.isrcfinder.com/"); parseErr == nil {
+ for _, cookie := range jar.Cookies(parsedURL) {
+ if cookie.Name == "csrftoken" {
+ token = cookie.Value
+ break
+ }
+ }
+ }
+ }
+ if token == "" {
+ return "", fmt.Errorf("csrf token not found")
+ }
+
+ form := url.Values{}
+ form.Set("csrfmiddlewaretoken", token)
+ form.Set("URI", spotifyURL)
+
+ postReq, err := http.NewRequest("POST", "https://www.isrcfinder.com/", strings.NewReader(form.Encode()))
+ if err != nil {
+ return "", fmt.Errorf("failed to create POST request: %w", err)
+ }
+ postReq.Header.Set("User-Agent", songLinkUserAgent)
+ postReq.Header.Set("Referer", "https://www.isrcfinder.com/")
+ postReq.Header.Set("Origin", "https://www.isrcfinder.com")
+ postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ postResp, err := client.Do(postReq)
+ if err != nil {
+ return "", fmt.Errorf("failed to submit isrcfinder form: %w", err)
+ }
+ postBody, err := io.ReadAll(postResp.Body)
+ postResp.Body.Close()
+ if err != nil {
+ return "", fmt.Errorf("failed to read isrcfinder POST response: %w", err)
+ }
+ if postResp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("isrcfinder POST returned status %d", postResp.StatusCode)
+ }
+
+ isrc := firstISRCMatch(string(postBody))
+ if isrc == "" {
+ return "", fmt.Errorf("ISRC not found in isrcfinder response")
+ }
+
+ return isrc, nil
+}
+
+func lookupISRCViaPHPStack(spotifyURL string) (string, error) {
+ apiURL := fmt.Sprintf(
+ "https://phpstack-822472-6184058.cloudwaysapps.com/api/spotify.php?q=%s",
+ url.QueryEscape(spotifyURL),
+ )
+
+ req, err := http.NewRequest("GET", apiURL, nil)
+ if err != nil {
+ return "", fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("User-Agent", songLinkUserAgent)
+ req.Header.Set("Referer", "https://phpstack-822472-6184058.cloudwaysapps.com/?")
+
+ client := &http.Client{Timeout: 15 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("phpstack request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("phpstack returned status %d", resp.StatusCode)
+ }
+
+ var payload struct {
+ ISRC string `json:"isrc"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+ return "", fmt.Errorf("failed to decode phpstack response: %w", err)
+ }
+ if payload.ISRC == "" {
+ return "", fmt.Errorf("ISRC missing in phpstack response")
+ }
+
+ return strings.ToUpper(strings.TrimSpace(payload.ISRC)), nil
+}
+
+func lookupISRCViaFindMyISRC(spotifyURL string) (string, error) {
+ payloadBytes, err := json.Marshal(map[string][]string{
+ "uris": []string{spotifyURL},
+ })
+ if err != nil {
+ return "", fmt.Errorf("failed to encode payload: %w", err)
+ }
+
+ req, err := http.NewRequest(
+ "POST",
+ "https://lxtzsnh4l3.execute-api.ap-southeast-2.amazonaws.com/prod/find-my-isrc",
+ strings.NewReader(string(payloadBytes)),
+ )
+ if err != nil {
+ return "", fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("User-Agent", songLinkUserAgent)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Origin", "https://www.findmyisrc.com")
+ req.Header.Set("Referer", "https://www.findmyisrc.com/")
+
+ client := &http.Client{Timeout: 15 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("findmyisrc request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("findmyisrc returned status %d", resp.StatusCode)
+ }
+
+ var payload []struct {
+ Data struct {
+ ISRC string `json:"isrc"`
+ } `json:"data"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+ return "", fmt.Errorf("failed to decode findmyisrc response: %w", err)
+ }
+
+ for _, item := range payload {
+ if item.Data.ISRC != "" {
+ return strings.ToUpper(strings.TrimSpace(item.Data.ISRC)), nil
+ }
+ }
+
+ return "", fmt.Errorf("ISRC missing in findmyisrc response")
+}
+
+func lookupISRCViaMixvibe(spotifyURL string) (string, error) {
+ payloadBytes, err := json.Marshal(map[string]string{
+ "url": spotifyURL,
+ })
+ if err != nil {
+ return "", fmt.Errorf("failed to encode payload: %w", err)
+ }
+
+ req, err := http.NewRequest(
+ "POST",
+ "https://tools.mixviberecords.com/api/find-isrc",
+ strings.NewReader(string(payloadBytes)),
+ )
+ if err != nil {
+ return "", fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("User-Agent", songLinkUserAgent)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Origin", "https://tools.mixviberecords.com")
+ req.Header.Set("Referer", "https://tools.mixviberecords.com/isrc-finder")
+
+ client := &http.Client{Timeout: 15 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("mixvibe request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("failed to read mixvibe response: %w", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("mixvibe returned status %d", resp.StatusCode)
+ }
+
+ var payload interface{}
+ if err := json.Unmarshal(body, &payload); err == nil {
+ if isrc := findISRCInValue(payload); isrc != "" {
+ return isrc, nil
+ }
+ }
+
+ if isrc := firstISRCMatch(string(body)); isrc != "" {
+ return isrc, nil
+ }
+
+ return "", fmt.Errorf("ISRC missing in mixvibe response")
+}
+
+func (s *SongLinkClient) populateLinksFromSongstats(links *resolvedTrackLinks, isrc string) error {
+ pageURL := fmt.Sprintf("https://songstats.com/%s?ref=ISRCFinder", strings.ToUpper(strings.TrimSpace(isrc)))
+
+ req, err := http.NewRequest("GET", pageURL, nil)
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("User-Agent", songLinkUserAgent)
+
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to fetch Songstats page: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("Songstats returned status %d", resp.StatusCode)
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("failed to read Songstats response: %w", err)
+ }
+
+ matches := songstatsScriptPattern.FindAllStringSubmatch(string(body), -1)
+ if len(matches) == 0 {
+ return fmt.Errorf("Songstats JSON-LD not found")
+ }
+
+ found := false
+ for _, match := range matches {
+ if len(match) < 2 {
+ continue
+ }
+
+ scriptBody := strings.TrimSpace(html.UnescapeString(match[1]))
+ if scriptBody == "" {
+ continue
+ }
+
+ var payload interface{}
+ if err := json.Unmarshal([]byte(scriptBody), &payload); err != nil {
+ continue
+ }
+
+ before := *links
+ collectSongstatsLinks(payload, links)
+ if *links != before {
+ found = true
+ }
+ }
+
+ if !found && !hasAnySongLinkData(links) {
+ return fmt.Errorf("no platform links found in Songstats")
+ }
+
+ return nil
+}
+
+func (s *SongLinkClient) lookupDeezerTrackURLByISRC(isrc string) (string, error) {
+ apiURL := fmt.Sprintf("https://api.deezer.com/track/isrc:%s", strings.ToUpper(strings.TrimSpace(isrc)))
+
+ req, err := http.NewRequest("GET", apiURL, nil)
+ if err != nil {
+ return "", fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("User-Agent", songLinkUserAgent)
+
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("failed to call Deezer ISRC API: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("Deezer ISRC API returned status %d", resp.StatusCode)
+ }
+
+ var payload struct {
+ ID int64 `json:"id"`
+ ISRC string `json:"isrc"`
+ Link string `json:"link"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+ return "", fmt.Errorf("failed to decode Deezer ISRC response: %w", err)
+ }
+
+ if payload.Link != "" {
+ return normalizeDeezerTrackURL(payload.Link), nil
+ }
+ if payload.ID > 0 {
+ return normalizeDeezerTrackURL(fmt.Sprintf("https://www.deezer.com/track/%d", payload.ID)), nil
+ }
+
+ return "", fmt.Errorf("deezer track link not found for ISRC %s", isrc)
+}
+
+func mergeSongLinkResponse(links *resolvedTrackLinks, resp *songLinkAPIResponse) {
+ if resp == nil {
+ return
+ }
+
+ if link, ok := resp.LinksByPlatform["tidal"]; ok && link.URL != "" && links.TidalURL == "" {
+ links.TidalURL = strings.TrimSpace(link.URL)
+ fmt.Println("✓ Tidal URL found")
+ }
+
+ if link, ok := resp.LinksByPlatform["amazonMusic"]; ok && link.URL != "" && links.AmazonURL == "" {
+ links.AmazonURL = normalizeAmazonMusicURL(link.URL)
+ fmt.Println("✓ Amazon URL found")
+ }
+
+ if link, ok := resp.LinksByPlatform["deezer"]; ok && link.URL != "" && links.DeezerURL == "" {
+ links.DeezerURL = normalizeDeezerTrackURL(link.URL)
+ fmt.Println("✓ Deezer URL found")
+ }
+}
+
+func collectSongstatsLinks(value interface{}, links *resolvedTrackLinks) {
+ switch typed := value.(type) {
+ case map[string]interface{}:
+ if sameAs, ok := typed["sameAs"]; ok {
+ applySongstatsSameAs(sameAs, links)
+ }
+ for _, nested := range typed {
+ collectSongstatsLinks(nested, links)
+ }
+ case []interface{}:
+ for _, nested := range typed {
+ collectSongstatsLinks(nested, links)
+ }
+ }
+}
+
+func applySongstatsSameAs(value interface{}, links *resolvedTrackLinks) {
+ switch typed := value.(type) {
+ case string:
+ assignSongstatsLink(typed, links)
+ case []interface{}:
+ for _, item := range typed {
+ if link, ok := item.(string); ok {
+ assignSongstatsLink(link, links)
+ }
+ }
+ }
+}
+
+func assignSongstatsLink(rawLink string, links *resolvedTrackLinks) {
+ link := strings.TrimSpace(rawLink)
+ if link == "" {
+ return
+ }
+
+ switch {
+ case strings.Contains(link, "listen.tidal.com/track"):
+ if links.TidalURL == "" {
+ links.TidalURL = link
+ fmt.Println("✓ Tidal URL found via Songstats")
+ }
+ case strings.Contains(link, "music.amazon.com"):
+ if links.AmazonURL == "" {
+ if normalized := normalizeAmazonMusicURL(link); normalized != "" {
+ links.AmazonURL = normalized
+ fmt.Println("✓ Amazon URL found via Songstats")
+ }
+ }
+ case strings.Contains(link, "deezer.com"):
+ if links.DeezerURL == "" {
+ links.DeezerURL = normalizeDeezerTrackURL(link)
+ fmt.Println("✓ Deezer URL found via Songstats")
+ }
+ }
+}
+
+func normalizeAmazonMusicURL(rawURL string) string {
+ amazonURL := strings.TrimSpace(rawURL)
+ if amazonURL == "" {
+ return ""
+ }
+
+ if strings.Contains(amazonURL, "trackAsin=") {
+ parts := strings.Split(amazonURL, "trackAsin=")
+ if len(parts) > 1 {
+ trackAsin := strings.Split(parts[1], "&")[0]
+ if trackAsin != "" {
+ return fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", trackAsin)
+ }
+ }
+ }
+
+ if match := amazonAlbumTrackPath.FindStringSubmatch(amazonURL); len(match) > 1 {
+ return fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", match[1])
+ }
+
+ if match := amazonTrackPath.FindStringSubmatch(amazonURL); len(match) > 1 {
+ return fmt.Sprintf("https://music.amazon.com/tracks/%s?musicTerritory=US", match[1])
+ }
+
+ return ""
+}
+
+func normalizeDeezerTrackURL(rawURL string) string {
+ trackID, err := extractDeezerTrackID(rawURL)
+ if err != nil {
+ return strings.TrimSpace(rawURL)
+ }
+ return fmt.Sprintf("https://www.deezer.com/track/%s", trackID)
+}
+
+func extractDeezerTrackID(rawURL string) (string, error) {
+ cleanURL := strings.TrimSpace(rawURL)
+ if cleanURL == "" {
+ return "", fmt.Errorf("empty Deezer URL")
+ }
+
+ parts := strings.Split(cleanURL, "/track/")
+ if len(parts) < 2 {
+ return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", rawURL)
+ }
+
+ trackID := strings.Split(parts[1], "?")[0]
+ trackID = strings.Trim(trackID, "/ ")
+ if trackID == "" {
+ return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", rawURL)
+ }
+
+ return trackID, nil
+}
+
+func hasAnySongLinkData(links *resolvedTrackLinks) bool {
+ if links == nil {
+ return false
+ }
+ return links.TidalURL != "" || links.AmazonURL != "" || links.DeezerURL != ""
+}
+
+func extractCSRFToken(body string) string {
+ match := csrfTokenPattern.FindStringSubmatch(body)
+ if len(match) < 2 {
+ return ""
+ }
+ return strings.TrimSpace(match[1])
+}
+
+func firstISRCMatch(body string) string {
+ match := isrcPattern.FindStringSubmatch(strings.ToUpper(body))
+ if len(match) < 2 {
+ return ""
+ }
+ return strings.TrimSpace(match[1])
+}
+
+func findISRCInValue(value interface{}) string {
+ switch typed := value.(type) {
+ case map[string]interface{}:
+ for key, nested := range typed {
+ if strings.EqualFold(key, "isrc") {
+ if isrc, ok := nested.(string); ok {
+ if normalized := firstISRCMatch(isrc); normalized != "" {
+ return normalized
+ }
+ }
+ }
+ if isrc := findISRCInValue(nested); isrc != "" {
+ return isrc
+ }
+ }
+ case []interface{}:
+ for _, nested := range typed {
+ if isrc := findISRCInValue(nested); isrc != "" {
+ return isrc
+ }
+ }
+ case string:
+ return firstISRCMatch(typed)
+ }
+
+ return ""
}
diff --git a/backend/tidal.go b/backend/tidal.go
index cf0aae2..630ee7b 100644
--- a/backend/tidal.go
+++ b/backend/tidal.go
@@ -8,7 +8,6 @@ import (
"io"
"math/rand"
"net/http"
- "net/url"
"os"
"os/exec"
"path/filepath"
@@ -91,47 +90,17 @@ func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
}
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
-
- spotifyBase := "https://open.spotify.com/track/"
- spotifyURL := fmt.Sprintf("%s%s", spotifyBase, spotifyTrackID)
-
- apiBase := "https://api.song.link/v1-alpha.1/links?url="
- apiURL := fmt.Sprintf("%s%s", apiBase, url.QueryEscape(spotifyURL))
-
- req, err := http.NewRequest("GET", apiURL, nil)
- if err != nil {
- return "", fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
-
fmt.Println("Getting Tidal URL...")
-
- resp, err := t.client.Do(req)
+ client := NewSongLinkClient()
+ urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, "")
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 == "" {
+ tidalURL := urls.TidalURL
+ if tidalURL == "" {
return "", fmt.Errorf("tidal link not found")
}
-
- tidalURL := tidalLink.URL
fmt.Printf("Found Tidal URL: %s\n", tidalURL)
return tidalURL, nil
}
diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx
index c379705..c4303c4 100644
--- a/frontend/src/components/SettingsPage.tsx
+++ b/frontend/src/components/SettingsPage.tsx
@@ -161,7 +161,7 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin