diff --git a/app.go b/app.go index d1ef995..bbe73cd 100644 --- a/app.go +++ b/app.go @@ -34,14 +34,6 @@ type CurrentIPInfo struct { } const checkOperationTimeout = 10 * time.Second -const unifiedStatusAPIURL = "https://status.spotbye.qzz.io/api/status/spotiflac/" -const unifiedStatusCacheTTL = 5 * time.Second - -var ( - unifiedStatusCacheMu sync.Mutex - unifiedStatusCacheBody string - unifiedStatusCacheExpiry time.Time -) func NewApp() *App { return &App{} @@ -152,60 +144,6 @@ func previewResponseBody(body []byte, maxLen int) string { return preview } -func fetchUnifiedStatusPayload(forceRefresh bool, endpoint string) (string, error) { - unifiedStatusCacheMu.Lock() - defer unifiedStatusCacheMu.Unlock() - - if !forceRefresh && unifiedStatusCacheBody != "" && time.Now().Before(unifiedStatusCacheExpiry) { - return unifiedStatusCacheBody, nil - } - - client := &http.Client{Timeout: 10 * time.Second} - maxRetries := 3 - var lastErr error - - for i := 0; i < maxRetries; i++ { - req, err := http.NewRequest(http.MethodGet, endpoint, nil) - if err != nil { - return "", fmt.Errorf("failed to create unified status request: %w", err) - } - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") - req.Header.Set("Accept", "application/json") - - resp, err := client.Do(req) - if err == nil { - body, readErr := io.ReadAll(resp.Body) - resp.Body.Close() - if readErr != nil { - lastErr = fmt.Errorf("attempt %d: failed reading response: %w", i+1, readErr) - } else if resp.StatusCode != http.StatusOK { - lastErr = fmt.Errorf("attempt %d: returned status %d (%s)", i+1, resp.StatusCode, previewResponseBody(body, 200)) - } else { - payload := strings.TrimSpace(string(body)) - if payload == "" { - lastErr = fmt.Errorf("attempt %d: empty response body", i+1) - } else { - unifiedStatusCacheBody = payload - unifiedStatusCacheExpiry = time.Now().Add(unifiedStatusCacheTTL) - return payload, nil - } - } - } else { - lastErr = fmt.Errorf("attempt %d: connection failed: %w", i+1, err) - } - - if i < maxRetries-1 { - time.Sleep(1 * time.Second) - } - } - - if lastErr == nil { - lastErr = fmt.Errorf("unknown error") - } - - return "", fmt.Errorf("unified status API failed after %d retries: %w", maxRetries, lastErr) -} - func fetchCurrentIPInfo() (CurrentIPInfo, error) { type ipwhoisResponse struct { Success bool `json:"success"` @@ -313,10 +251,6 @@ func (a *App) GetCurrentIPInfo() (string, error) { return string(payload), nil } -func (a *App) FetchUnifiedAPIStatus(forceRefresh bool) (string, error) { - return fetchUnifiedStatusPayload(forceRefresh, unifiedStatusAPIURL) -} - func (a *App) getFirstArtist(artistString string) string { if artistString == "" { return "" @@ -342,6 +276,11 @@ func (a *App) startup(ctx context.Context) { if err := backend.InitProviderPriorityDB(); err != nil { fmt.Printf("Failed to init provider priority DB: %v\n", err) } + go func() { + if err := backend.PrimeTidalAPIList(); err != nil { + fmt.Printf("Failed to prime Tidal API list: %v\n", err) + } + }() } func (a *App) shutdown(ctx context.Context) { @@ -1042,70 +981,28 @@ func (a *App) ExportFailedDownloads() (string, error) { func (a *App) CheckAPIStatus(apiType string, apiURL string) bool { isOnline, err := runWithTimeout(checkOperationTimeout, func() (bool, error) { - var checkURL string - if apiType == "tidal" { - checkURL = fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL) - } else if apiType == "qobuz" { - checkURL = fmt.Sprintf("%s/api/stream?trackId=360735657&quality=27", apiURL) - } else if apiType == "qbz" { - checkURL = fmt.Sprintf("%s/api/track/360735657?quality=27", apiURL) - } else if apiType == "amazon" { - checkURL = fmt.Sprintf("%s/status", apiURL) - } else if apiType == "lrclib" { - checkURL = fmt.Sprintf("%s/api/search?artist_name=Adele&track_name=Hello", strings.TrimRight(apiURL, "/")) - } else if apiType == "musicbrainz" { - checkURL = fmt.Sprintf("%s/ws/2/recording?query=%s&fmt=json&limit=1", strings.TrimRight(apiURL, "/"), url.QueryEscape(`recording:"Hello" AND artist:"Adele"`)) - } else { - checkURL = apiURL - } - - client := &http.Client{Timeout: 10 * time.Second} - req, err := http.NewRequest("GET", checkURL, nil) - if err != nil { - return false, 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") - req.Header.Set("Accept", "application/json") - - maxRetries := 3 - for i := 0; i < maxRetries; i++ { - resp, err := client.Do(req) - if err == nil { - statusCode := resp.StatusCode - body, readErr := io.ReadAll(resp.Body) - resp.Body.Close() - if readErr != nil { - if i < maxRetries-1 { - time.Sleep(1 * time.Second) - } - continue - } - - if apiType == "amazon" && statusCode == 200 && strings.Contains(string(body), `"amazonMusic":"up"`) { - return true, nil - } - - if (apiType == "qobuz" || apiType == "qbz") && statusCode == 200 && containsStreamingURL(body) { - return true, nil - } - - if apiType == "lrclib" && statusCode == 200 && containsLRCLIBResults(body) { - return true, nil - } - - if apiType == "musicbrainz" && statusCode == 200 && containsMusicBrainzResults(body) { - return true, nil - } - - if apiType != "amazon" && apiType != "qobuz" && apiType != "qbz" && apiType != "lrclib" && apiType != "musicbrainz" && statusCode == 200 { + switch apiType { + case "tidal": + if checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs(apiURL)) { + return true, nil + } + if strings.TrimSpace(apiURL) == "" { + if _, refreshErr := backend.RefreshTidalAPIList(true); refreshErr == nil && checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs("")) { return true, nil } } - if i < maxRetries-1 { - time.Sleep(1 * time.Second) - } + return false, nil + case "qobuz", "qbz": + return checkGroupedAPIStatus("qobuz", buildQobuzStatusCheckURLs(apiURL)), nil + case "amazon": + return checkGroupedAPIStatus("amazon", buildAmazonStatusCheckURLs(apiURL)), nil + case "lrclib": + return checkGroupedAPIStatus("lrclib", buildLRCLIBStatusCheckURLs(apiURL)), nil + case "musicbrainz": + return checkGroupedAPIStatus("musicbrainz", buildMusicBrainzStatusCheckURLs(apiURL)), nil + default: + return checkGroupedAPIStatus(apiType, []string{strings.TrimSpace(apiURL)}), nil } - return false, nil }) if err != nil { if apiType == "musicbrainz" { @@ -1122,6 +1019,146 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool { return isOnline } +func buildTidalStatusCheckURLs(apiURL string) []string { + apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/") + if apiURL != "" { + return []string{fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)} + } + + apis, err := backend.GetRotatedTidalAPIList() + if err != nil { + fmt.Printf("Warning: failed to load rotated Tidal API list for status check: %v\n", err) + } + + urls := make([]string, 0, len(apis)) + for _, baseURL := range apis { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if baseURL == "" { + continue + } + urls = append(urls, fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", baseURL)) + } + + return urls +} + +func buildQobuzStatusCheckURLs(apiURL string) []string { + if trimmed := strings.TrimSpace(apiURL); trimmed != "" { + return []string{buildQobuzStatusCheckURL(trimmed)} + } + + bases := backend.GetQobuzStreamAPIBaseURLs() + urls := make([]string, 0, len(bases)) + for _, baseURL := range bases { + urls = append(urls, buildQobuzStatusCheckURL(baseURL)) + } + return urls +} + +func buildQobuzStatusCheckURL(apiBase string) string { + apiBase = strings.TrimSpace(apiBase) + if strings.Contains(apiBase, "qobuz.spotbye.qzz.io") { + return fmt.Sprintf("%s360735657?quality=27", apiBase) + } + return fmt.Sprintf("%s360735657&quality=27", apiBase) +} + +func buildAmazonStatusCheckURLs(apiURL string) []string { + baseURL := strings.TrimRight(strings.TrimSpace(apiURL), "/") + if baseURL == "" { + baseURL = backend.GetAmazonMusicAPIBaseURL() + } + return []string{fmt.Sprintf("%s/status", baseURL)} +} + +func buildLRCLIBStatusCheckURLs(apiURL string) []string { + baseURL := strings.TrimRight(strings.TrimSpace(apiURL), "/") + if baseURL == "" { + baseURL = "https://lrclib.net" + } + return []string{fmt.Sprintf("%s/api/search?artist_name=Adele&track_name=Hello", baseURL)} +} + +func buildMusicBrainzStatusCheckURLs(apiURL string) []string { + baseURL := strings.TrimRight(strings.TrimSpace(apiURL), "/") + if baseURL == "" { + baseURL = "https://musicbrainz.org" + } + return []string{fmt.Sprintf("%s/ws/2/recording?query=%s&fmt=json&limit=1", baseURL, url.QueryEscape(`recording:"Hello" AND artist:"Adele"`))} +} + +func checkGroupedAPIStatus(apiType string, checkURLs []string) bool { + filtered := make([]string, 0, len(checkURLs)) + for _, rawURL := range checkURLs { + url := strings.TrimSpace(rawURL) + if url == "" { + continue + } + filtered = append(filtered, url) + } + + if len(filtered) == 0 { + return false + } + + results := make(chan bool, len(filtered)) + var wg sync.WaitGroup + + for _, checkURL := range filtered { + wg.Add(1) + go func(target string) { + defer wg.Done() + results <- checkSingleAPIStatus(apiType, target) + }(checkURL) + } + + go func() { + wg.Wait() + close(results) + }() + + for online := range results { + if online { + return true + } + } + + return false +} + +func checkSingleAPIStatus(apiType string, checkURL string) bool { + client := &http.Client{Timeout: 4 * time.Second} + req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil) + if err != nil { + return false + } + + resp, err := client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return false + } + + statusCode := resp.StatusCode + switch apiType { + case "amazon": + return statusCode == http.StatusOK && strings.Contains(string(body), `"amazonMusic":"up"`) + case "qobuz", "qbz": + return statusCode == http.StatusOK && containsStreamingURL(body) + case "lrclib": + return statusCode == http.StatusOK && containsLRCLIBResults(body) + case "musicbrainz": + return statusCode == http.StatusOK && containsMusicBrainzResults(body) + default: + return statusCode == http.StatusOK + } +} + func (a *App) Quit() { panic("quit") diff --git a/backend/amazon.go b/backend/amazon.go index 54a03d2..20f5e0a 100644 --- a/backend/amazon.go +++ b/backend/amazon.go @@ -56,12 +56,11 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL) } - apiURL := fmt.Sprintf("https://amazon.spotbye.qzz.io/api/track/%s", asin) - req, err := http.NewRequest("GET", apiURL, nil) + apiURL := fmt.Sprintf("%s/api/track/%s", amazonMusicAPIBaseURL, asin) + req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil) if err != nil { return "", 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.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin) resp, err := a.client.Do(req) @@ -98,8 +97,10 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st } defer out.Close() - dlReq, _ := http.NewRequest("GET", downloadURL, nil) - dlReq.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") + dlReq, err := NewRequestWithDefaultHeaders(http.MethodGet, downloadURL, nil) + if err != nil { + return "", err + } dlResp, err := a.client.Do(dlReq) if err != nil { diff --git a/backend/http_headers.go b/backend/http_headers.go new file mode 100644 index 0000000..94f95f3 --- /dev/null +++ b/backend/http_headers.go @@ -0,0 +1,21 @@ +package backend + +import ( + "io" + "net/http" +) + +const DefaultDownloaderUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" + +func NewRequestWithDefaultHeaders(method string, rawURL string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequest(method, rawURL, body) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", DefaultDownloaderUserAgent) + req.Header.Set("Accept", "application/json, text/plain, */*") + + return req, nil +} + diff --git a/backend/provider_endpoints.go b/backend/provider_endpoints.go new file mode 100644 index 0000000..bc04208 --- /dev/null +++ b/backend/provider_endpoints.go @@ -0,0 +1,17 @@ +package backend + +const amazonMusicAPIBaseURL = "https://amazon.spotbye.qzz.io" + +var defaultQobuzStreamAPIBaseURLs = []string{ + "https://dab.yeet.su/api/stream?trackId=", + "https://dabmusic.xyz/api/stream?trackId=", + "https://qobuz.spotbye.qzz.io/api/track/", +} + +func GetQobuzStreamAPIBaseURLs() []string { + return append([]string(nil), defaultQobuzStreamAPIBaseURLs...) +} + +func GetAmazonMusicAPIBaseURL() string { + return amazonMusicAPIBaseURL +} diff --git a/backend/qobuz.go b/backend/qobuz.go index 3283680..989e6f7 100644 --- a/backend/qobuz.go +++ b/backend/qobuz.go @@ -147,7 +147,12 @@ func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string { func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) { apiURL := buildQobuzAPIURL(apiBase, trackID, quality) - resp, err := q.client.Get(apiURL) + req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil) + if err != nil { + return "", err + } + + resp, err := q.client.Do(req) if err != nil { return "", err } @@ -191,11 +196,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode) - standardAPIs := prioritizeProviders("qobuz", []string{ - "https://dab.yeet.su/api/stream?trackId=", - "https://dabmusic.xyz/api/stream?trackId=", - "https://qobuz.spotbye.qzz.io/api/track/", - }) + standardAPIs := prioritizeProviders("qobuz", GetQobuzStreamAPIBaseURLs()) downloadFunc := func(qual string) (string, error) { type Provider struct { @@ -272,7 +273,12 @@ func (q *QobuzDownloader) DownloadFile(url, filepath string) error { Timeout: 5 * time.Minute, } - resp, err := downloadClient.Get(url) + req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("failed to create download request: %w", err) + } + + resp, err := downloadClient.Do(req) if err != nil { return fmt.Errorf("failed to download file: %w", err) } @@ -306,7 +312,12 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error { return fmt.Errorf("no cover URL provided") } - resp, err := q.client.Get(coverURL) + req, err := NewRequestWithDefaultHeaders(http.MethodGet, coverURL, nil) + if err != nil { + return fmt.Errorf("failed to create cover request: %w", err) + } + + resp, err := q.client.Do(req) if err != nil { return fmt.Errorf("failed to download cover: %w", err) } diff --git a/backend/qobuz_api.go b/backend/qobuz_api.go index de5bfb9..7815b8a 100644 --- a/backend/qobuz_api.go +++ b/backend/qobuz_api.go @@ -21,7 +21,7 @@ const ( qobuzAPIBaseURL = "https://www.qobuz.com/api.json/0.2" qobuzDefaultAPIAppID = "712109809" qobuzDefaultAPIAppSecret = "589be88e4538daea11f509d29e4a23b1" - qobuzDefaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" + qobuzDefaultUA = DefaultDownloaderUserAgent qobuzCredentialsCacheFile = "qobuz-api-credentials.json" qobuzCredentialsCacheTTL = 24 * time.Hour qobuzCredentialsProbeTrackISRC = "USUM71703861" diff --git a/backend/tidal.go b/backend/tidal.go index ee16973..33643de 100644 --- a/backend/tidal.go +++ b/backend/tidal.go @@ -49,17 +49,9 @@ type TidalBTSManifest struct { } func NewTidalDownloader(apiURL string) *TidalDownloader { + apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/") if apiURL == "" { - downloader := &TidalDownloader{ - client: &http.Client{ - Timeout: 5 * time.Second, - }, - timeout: 5 * time.Second, - maxRetries: 3, - apiURL: "", - } - - apis, err := downloader.GetAvailableAPIs() + apis, err := GetRotatedTidalAPIList() if err == nil && len(apis) > 0 { apiURL = apis[0] } @@ -76,16 +68,12 @@ func NewTidalDownloader(apiURL string) *TidalDownloader { } func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) { - apis := []string{ - "https://hifi-one.spotisaver.net", - "https://hifi-two.spotisaver.net", - "https://eu-central.monochrome.tf", - "https://us-west.monochrome.tf", - "https://api.monochrome.tf", - "https://monochrome-api.samidy.com", - "https://tidal.kinoplus.online", + apis, err := GetRotatedTidalAPIList() + if err == nil && len(apis) > 0 { + return apis, nil } - return prioritizeProviders("tidal", apis), nil + + return nil, err } func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) { @@ -129,14 +117,12 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality) fmt.Printf("Tidal API URL: %s\n", url) - req, err := http.NewRequest("GET", url, nil) + req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil) if err != nil { fmt.Printf("✗ failed to create request: %v\n", err) 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") - resp, err := t.client.Do(req) if err != nil { fmt.Printf("✗ Tidal API request failed: %v\n", err) @@ -194,13 +180,11 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error { return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath) } - req, err := http.NewRequest("GET", url, nil) + req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, 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") - resp, err := t.client.Do(req) if err != nil { @@ -241,11 +225,10 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e } doRequest := func(url string) (*http.Response, error) { - req, err := http.NewRequest("GET", url, nil) + req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil) if err != nil { return nil, 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") return client.Do(req) } @@ -460,7 +443,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo downloadURL, err := t.GetDownloadURL(trackID, quality) if err != nil { - if quality == "HI_RES" && allowFallback { + if isTidalHiResQuality(quality) && allowFallback { fmt.Println("⚠ HI_RES unavailable/failed, falling back to LOSSLESS...") downloadURL, err = t.GetDownloadURL(trackID, "LOSSLESS") if err != nil { @@ -515,6 +498,11 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo if err := t.DownloadFile(downloadURL, outputFilename); err != nil { return "", err } + if t.apiURL != "" { + if err := RememberTidalAPIUsage(t.apiURL); err != nil { + fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err) + } + } isrc := strings.TrimSpace(isrcOverride) var mbMeta Metadata @@ -580,11 +568,6 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo } func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { - apis, err := t.GetAvailableAPIs() - if err != nil { - return "", fmt.Errorf("no APIs available for fallback: %w", err) - } - if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { return "", fmt.Errorf("directory error: %w", err) @@ -626,19 +609,6 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality return "EXISTS:" + outputFilename, nil } - successAPI, downloadURL, err := getDownloadURLRotated(apis, trackID, quality) - if err != nil { - if quality == "HI_RES" && allowFallback { - fmt.Println("⚠ HI_RES unavailable/failed on all APIs, falling back to LOSSLESS...") - successAPI, downloadURL, err = getDownloadURLRotated(apis, trackID, "LOSSLESS") - if err != nil { - return "", fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err) - } - } else { - return "", err - } - } - type mbResultFallback struct { ISRC string Metadata Metadata @@ -680,10 +650,11 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality } fmt.Printf("Downloading to: %s\n", outputFilename) - downloader := NewTidalDownloader(successAPI) - if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil { + successAPI, err := t.downloadWithRotatingAPIs(trackID, outputFilename, quality, allowFallback) + if err != nil { return "", err } + fmt.Printf("✓ Downloaded using API: %s\n", successAPI) isrc := strings.TrimSpace(isrcOverride) var mbMeta Metadata @@ -920,79 +891,101 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU return "", initURL, mediaURLs, "", nil } -func getDownloadURLRotated(apis []string, trackID int64, quality string) (string, string, error) { - if len(apis) == 0 { - return "", "", fmt.Errorf("no APIs available") +func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename string, quality string, allowFallback bool) (string, error) { + qualities := []string{quality} + if isTidalHiResQuality(quality) && allowFallback { + qualities = append(qualities, "LOSSLESS") } - orderedAPIs := prioritizeProviders("tidal", apis) - fmt.Printf("Trying %d prioritized APIs...\n", len(orderedAPIs)) - - var lastError error - var errors []string - - for _, apiURL := range orderedAPIs { - fmt.Printf("Trying API: %s\n", apiURL) - - client := &http.Client{ - Timeout: 15 * time.Second, + var lastErr error + for idx, candidateQuality := range qualities { + if idx > 0 { + fmt.Printf("⚠ %s unavailable/failed on all APIs, falling back to %s...\n", quality, candidateQuality) } - url := fmt.Sprintf("%s/track/?id=%d&quality=%s", apiURL, trackID, quality) - resp, err := client.Get(url) + apiURL, err := t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, candidateQuality, false) + if err == nil { + return apiURL, nil + } + lastErr = err + } + + if lastErr == nil { + lastErr = fmt.Errorf("no tidal api succeeded") + } + return "", lastErr +} + +func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilename string, quality string, refreshed bool) (string, error) { + apis, err := GetRotatedTidalAPIList() + if err != nil && len(apis) == 0 { + return "", fmt.Errorf("failed to load tidal api list: %w", err) + } + if len(apis) == 0 { + return "", fmt.Errorf("no tidal apis available") + } + + var lastErr error + errors := make([]string, 0, len(apis)) + + for _, apiURL := range apis { + fmt.Printf("Trying Tidal API: %s\n", apiURL) + + downloader := NewTidalDownloader(apiURL) + downloadURL, err := downloader.GetDownloadURL(trackID, quality) if err != nil { - lastError = err - recordProviderFailure("tidal", apiURL) + lastErr = err errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err)) continue } - if resp.StatusCode != 200 { - resp.Body.Close() - lastError = fmt.Errorf("HTTP %d", resp.StatusCode) - recordProviderFailure("tidal", apiURL) - errors = append(errors, fmt.Sprintf("%s: %v", apiURL, lastError)) + if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil { + lastErr = err + cleanupTidalDownloadArtifacts(outputFilename) + errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err)) continue } - body, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - lastError = err - recordProviderFailure("tidal", apiURL) - errors = append(errors, fmt.Sprintf("%s: read body failed", apiURL)) - continue + if err := RememberTidalAPIUsage(apiURL); err != nil { + fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err) } - var v2Response TidalAPIResponseV2 - if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { - fmt.Printf("✓ Success with: %s\n", apiURL) - recordProviderSuccess("tidal", apiURL) - return apiURL, "MANIFEST:" + v2Response.Data.Manifest, nil - } - - var v1Responses []TidalAPIResponse - if err := json.Unmarshal(body, &v1Responses); err == nil { - for _, item := range v1Responses { - if item.OriginalTrackURL != "" { - fmt.Printf("✓ Success with: %s\n", apiURL) - recordProviderSuccess("tidal", apiURL) - return apiURL, item.OriginalTrackURL, nil - } - } - } - - lastError = fmt.Errorf("no download URL or manifest in response") - recordProviderFailure("tidal", apiURL) - errors = append(errors, fmt.Sprintf("%s: %v", apiURL, lastError)) + return apiURL, nil } - fmt.Println("All APIs failed:") - for _, e := range errors { - fmt.Printf(" ✗ %s\n", e) + if !refreshed { + if _, refreshErr := RefreshTidalAPIList(true); refreshErr != nil { + errors = append(errors, fmt.Sprintf("gist refresh failed: %v", refreshErr)) + } else { + fmt.Println("All cached Tidal APIs failed, refreshed gist list and retrying...") + return t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, quality, true) + } } - return "", "", fmt.Errorf("all %d APIs failed. Last error: %v", len(apis), lastError) + if lastErr == nil { + lastErr = fmt.Errorf("all tidal apis failed") + } + + fmt.Println("All Tidal APIs failed:") + for _, item := range errors { + fmt.Printf(" ✗ %s\n", item) + } + + return "", fmt.Errorf("all tidal apis failed for quality %s: %w", quality, lastErr) +} + +func cleanupTidalDownloadArtifacts(outputPath string) { + if outputPath == "" { + return + } + + _ = os.Remove(outputPath) + _ = os.Remove(outputPath + ".m4a.tmp") +} + +func isTidalHiResQuality(quality string) bool { + normalized := strings.TrimSpace(strings.ToUpper(quality)) + return normalized == "HI_RES" || normalized == "HI_RES_LOSSLESS" } func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool, extra ...string) string { diff --git a/backend/tidal_api_list.go b/backend/tidal_api_list.go new file mode 100644 index 0000000..fd40804 --- /dev/null +++ b/backend/tidal_api_list.go @@ -0,0 +1,296 @@ +package backend + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + tidalAPIListGistURL = "https://gist.githubusercontent.com/afkarxyz/2ce772b943321b9448b454f39403ce25/raw" + tidalAPIListCacheFile = "tidal-api-urls.json" +) + +type tidalAPIListCache struct { + URLs []string `json:"urls"` + LastUsedURL string `json:"last_used_url,omitempty"` + UpdatedAt int64 `json:"updated_at_unix"` + Source string `json:"source,omitempty"` +} + +var ( + tidalAPIListMu sync.Mutex + tidalAPIListState *tidalAPIListCache +) + +func loadTidalAPIListStateLocked() (*tidalAPIListCache, error) { + if tidalAPIListState != nil { + return cloneTidalAPIListState(tidalAPIListState), nil + } + + appDir, err := EnsureAppDir() + if err != nil { + return nil, err + } + + cachePath := filepath.Join(appDir, tidalAPIListCacheFile) + data, err := os.ReadFile(cachePath) + if err != nil { + if os.IsNotExist(err) { + state := &tidalAPIListCache{} + tidalAPIListState = cloneTidalAPIListState(state) + return cloneTidalAPIListState(state), nil + } + return nil, fmt.Errorf("failed to read tidal api cache: %w", err) + } + + var state tidalAPIListCache + if err := json.Unmarshal(data, &state); err != nil { + return nil, fmt.Errorf("failed to parse tidal api cache: %w", err) + } + + state.URLs = normalizeTidalAPIURLs(state.URLs) + + tidalAPIListState = cloneTidalAPIListState(&state) + return cloneTidalAPIListState(&state), nil +} + +func saveTidalAPIListStateLocked(state *tidalAPIListCache) error { + appDir, err := EnsureAppDir() + if err != nil { + return err + } + + cachePath := filepath.Join(appDir, tidalAPIListCacheFile) + payload, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("failed to encode tidal api cache: %w", err) + } + + if err := os.WriteFile(cachePath, payload, 0o644); err != nil { + return fmt.Errorf("failed to write tidal api cache: %w", err) + } + + tidalAPIListState = cloneTidalAPIListState(state) + return nil +} + +func cloneTidalAPIListState(state *tidalAPIListCache) *tidalAPIListCache { + if state == nil { + return nil + } + + return &tidalAPIListCache{ + URLs: append([]string(nil), state.URLs...), + LastUsedURL: state.LastUsedURL, + UpdatedAt: state.UpdatedAt, + Source: state.Source, + } +} + +func normalizeTidalAPIURLs(urls []string) []string { + seen := make(map[string]struct{}, len(urls)) + normalized := make([]string, 0, len(urls)) + + for _, rawURL := range urls { + url := strings.TrimRight(strings.TrimSpace(rawURL), "/") + if url == "" { + continue + } + if _, exists := seen[url]; exists { + continue + } + seen[url] = struct{}{} + normalized = append(normalized, url) + } + + return normalized +} + +func fetchTidalAPIURLsFromGist() ([]string, error) { + client := &http.Client{Timeout: 12 * time.Second} + req, err := NewRequestWithDefaultHeaders(http.MethodGet, tidalAPIListGistURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create tidal api gist request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch tidal api gist: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + preview, _ := io.ReadAll(io.LimitReader(resp.Body, 200)) + return nil, fmt.Errorf("tidal api gist returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(preview))) + } + + var urls []string + if err := json.NewDecoder(resp.Body).Decode(&urls); err != nil { + return nil, fmt.Errorf("failed to decode tidal api gist: %w", err) + } + + urls = normalizeTidalAPIURLs(urls) + if len(urls) == 0 { + return nil, fmt.Errorf("tidal api gist returned no valid urls") + } + + return urls, nil +} + +func PrimeTidalAPIList() error { + _, err := RefreshTidalAPIList(true) + if err != nil { + fmt.Printf("Warning: failed to refresh Tidal API list from gist: %v\n", err) + } + + tidalAPIListMu.Lock() + defer tidalAPIListMu.Unlock() + + state, loadErr := loadTidalAPIListStateLocked() + if loadErr != nil { + return loadErr + } + + if len(state.URLs) == 0 { + return fmt.Errorf("tidal api cache is empty") + } + + if state.UpdatedAt == 0 { + state.UpdatedAt = time.Now().Unix() + return saveTidalAPIListStateLocked(state) + } + + return nil +} + +func RefreshTidalAPIList(force bool) ([]string, error) { + tidalAPIListMu.Lock() + defer tidalAPIListMu.Unlock() + + state, err := loadTidalAPIListStateLocked() + if err != nil { + state = &tidalAPIListCache{} + } + + if !force && len(state.URLs) > 0 { + return append([]string(nil), state.URLs...), nil + } + + urls, fetchErr := fetchTidalAPIURLsFromGist() + if fetchErr != nil { + if len(state.URLs) > 0 { + return append([]string(nil), state.URLs...), fetchErr + } + return nil, fetchErr + } + + state.URLs = urls + state.UpdatedAt = time.Now().Unix() + state.Source = "gist" + + if !containsString(state.URLs, state.LastUsedURL) { + state.LastUsedURL = "" + } + + if err := saveTidalAPIListStateLocked(state); err != nil { + return append([]string(nil), state.URLs...), err + } + + return append([]string(nil), state.URLs...), nil +} + +func GetTidalAPIList() ([]string, error) { + tidalAPIListMu.Lock() + defer tidalAPIListMu.Unlock() + + state, err := loadTidalAPIListStateLocked() + if err != nil { + return nil, err + } + + if len(state.URLs) == 0 { + return nil, fmt.Errorf("no cached tidal api urls") + } + + return append([]string(nil), state.URLs...), nil +} + +func GetRotatedTidalAPIList() ([]string, error) { + tidalAPIListMu.Lock() + defer tidalAPIListMu.Unlock() + + state, err := loadTidalAPIListStateLocked() + if err != nil { + return nil, err + } + + urls := state.URLs + if len(urls) == 0 { + return nil, fmt.Errorf("no cached tidal api urls") + } + + return rotateTidalAPIURLs(urls, state.LastUsedURL), nil +} + +func RememberTidalAPIUsage(apiURL string) error { + tidalAPIListMu.Lock() + defer tidalAPIListMu.Unlock() + + state, err := loadTidalAPIListStateLocked() + if err != nil { + return err + } + + state.LastUsedURL = strings.TrimRight(strings.TrimSpace(apiURL), "/") + if state.UpdatedAt == 0 { + state.UpdatedAt = time.Now().Unix() + } + + return saveTidalAPIListStateLocked(state) +} + +func rotateTidalAPIURLs(urls []string, lastUsedURL string) []string { + normalized := normalizeTidalAPIURLs(urls) + if len(normalized) < 2 { + return normalized + } + + lastUsedURL = strings.TrimRight(strings.TrimSpace(lastUsedURL), "/") + if lastUsedURL == "" { + return normalized + } + + lastIndex := -1 + for idx, candidate := range normalized { + if candidate == lastUsedURL { + lastIndex = idx + break + } + } + + if lastIndex == -1 { + return normalized + } + + rotated := make([]string, 0, len(normalized)) + rotated = append(rotated, normalized[lastIndex+1:]...) + rotated = append(rotated, normalized[:lastIndex+1]...) + return rotated +} + +func containsString(values []string, target string) bool { + target = strings.TrimRight(strings.TrimSpace(target), "/") + for _, value := range values { + if strings.TrimRight(strings.TrimSpace(value), "/") == target { + return true + } + } + return false +} diff --git a/frontend/src/components/ApiStatusTab.tsx b/frontend/src/components/ApiStatusTab.tsx index 3ee097d..0617a15 100644 --- a/frontend/src/components/ApiStatusTab.tsx +++ b/frontend/src/components/ApiStatusTab.tsx @@ -1,6 +1,6 @@ import { Button } from "@/components/ui/button"; import { SearchCheck, CheckCircle2, XCircle, Loader2 } from "lucide-react"; -import { TidalIcon, QobuzIcon, AmazonIcon, LrclibIcon, MusicBrainzIcon } from "./PlatformIcons"; +import { TidalIcon, QobuzIcon, AmazonIcon, MusicBrainzIcon } from "./PlatformIcons"; import { useApiStatus } from "@/hooks/useApiStatus"; export function ApiStatusTab() { const { sources, statuses, isCheckingAll, checkAll } = useApiStatus(); @@ -17,7 +17,7 @@ export function ApiStatusTab() { const status = statuses[source.id] || "idle"; return (
{source.name}