.tidal gist url

This commit is contained in:
afkarxyz
2026-04-19 22:15:49 +07:00
parent a3e780587b
commit 3af9327a3d
12 changed files with 655 additions and 329 deletions
+163 -126
View File
@@ -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")
+6 -5
View File
@@ -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 {
+21
View File
@@ -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
}
+17
View File
@@ -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
}
+19 -8
View File
@@ -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)
}
+1 -1
View File
@@ -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"
+96 -103
View File
@@ -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 {
+296
View File
@@ -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
}
+2 -2
View File
@@ -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 (<div key={source.id} className="flex items-center justify-between p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
<div className="flex items-center gap-3">
{source.type === "tidal" ? <TidalIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "amazon" ? <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "lrclib" ? <LrclibIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "musicbrainz" ? <MusicBrainzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>}
{source.type === "tidal" ? <TidalIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "amazon" ? <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "musicbrainz" ? <MusicBrainzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>}
<p className="font-medium leading-none">{source.name}</p>
</div>
+34 -78
View File
@@ -1,100 +1,72 @@
import { CheckAPIStatus, FetchUnifiedAPIStatus } from "../../wailsjs/go/main/App";
import { CheckAPIStatus } from "../../wailsjs/go/main/App";
import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
export interface ApiSource {
id: string;
type: string;
name: string;
url: string;
}
export const API_SOURCES: ApiSource[] = [
{ id: "tidal1", type: "tidal", name: "Tidal A", url: "https://hifi-one.spotisaver.net" },
{ id: "tidal2", type: "tidal", name: "Tidal B", url: "https://hifi-two.spotisaver.net" },
{ id: "tidal3", type: "tidal", name: "Tidal C", url: "https://eu-central.monochrome.tf" },
{ id: "tidal4", type: "tidal", name: "Tidal D", url: "https://us-west.monochrome.tf" },
{ id: "tidal5", type: "tidal", name: "Tidal E", url: "https://api.monochrome.tf" },
{ id: "tidal6", type: "tidal", name: "Tidal F", url: "https://monochrome-api.samidy.com" },
{ id: "tidal7", type: "tidal", name: "Tidal G", url: "https://tidal.kinoplus.online" },
{ id: "qobuz1", type: "qobuz", name: "Qobuz A", url: "https://dab.yeet.su" },
{ id: "qobuz2", type: "qobuz", name: "Qobuz B", url: "https://dabmusic.xyz" },
{ id: "qobuz3", type: "qbz", name: "Qobuz C", url: "https://qobuz.spotbye.qzz.io" },
{ id: "amazon1", type: "amazon", name: "Amazon Music", url: "https://amazon.spotbye.qzz.io" },
{ id: "lrclib", type: "lrclib", name: "LRCLIB", url: "https://lrclib.net" },
{ id: "tidal", type: "tidal", name: "Tidal", url: "" },
{ id: "qobuz", type: "qobuz", name: "Qobuz", url: "" },
{ id: "amazon", type: "amazon", name: "Amazon Music", url: "" },
{ id: "musicbrainz", type: "musicbrainz", name: "MusicBrainz", url: "https://musicbrainz.org" },
];
type ApiStatusState = {
isCheckingAll: boolean;
statuses: Record<string, ApiCheckStatus>;
};
let apiStatusState: ApiStatusState = {
isCheckingAll: false,
statuses: {},
};
let activeCheckAll: Promise<void> | null = null;
const listeners = new Set<() => void>();
type SpotiFLACUnifiedStatusResponse = {
tidal?: string;
qobuz_a?: string;
qobuz_b?: string;
qobuz_c?: string;
amazon?: string;
lrclib?: string;
};
function emitApiStatusChange() {
for (const listener of listeners) {
listener();
}
}
function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) {
apiStatusState = updater(apiStatusState);
emitApiStatusChange();
}
function statusFromUnifiedValue(value: string | undefined): ApiCheckStatus {
return value === "up" ? "online" : "offline";
}
async function fetchUnifiedStatuses(forceRefresh: boolean): Promise<Pick<ApiStatusState, "statuses">> {
const response = await FetchUnifiedAPIStatus(forceRefresh);
const payload = JSON.parse(response) as SpotiFLACUnifiedStatusResponse;
const tidalStatus = statusFromUnifiedValue(payload.tidal);
return {
statuses: {
tidal1: tidalStatus,
tidal2: tidalStatus,
tidal3: tidalStatus,
tidal4: tidalStatus,
tidal5: tidalStatus,
tidal6: tidalStatus,
tidal7: tidalStatus,
qobuz1: statusFromUnifiedValue(payload.qobuz_a),
qobuz2: statusFromUnifiedValue(payload.qobuz_b),
qobuz3: statusFromUnifiedValue(payload.qobuz_c),
amazon1: statusFromUnifiedValue(payload.amazon),
lrclib: statusFromUnifiedValue(payload.lrclib),
},
};
}
async function checkMusicBrainzStatus(): Promise<ApiCheckStatus> {
async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
try {
const isOnline = await withTimeout(CheckAPIStatus("musicbrainz", "https://musicbrainz.org"), CHECK_TIMEOUT_MS, "API status check timed out after 10 seconds for MusicBrainz");
const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`);
return isOnline ? "online" : "offline";
}
catch {
return "offline";
}
}
export function getApiStatusState(): ApiStatusState {
return apiStatusState;
}
export function subscribeApiStatus(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export async function checkAllApiStatuses(forceRefresh: boolean = false): Promise<void> {
export async function checkAllApiStatuses(_forceRefresh: boolean = false): Promise<void> {
if (activeCheckAll) {
return activeCheckAll;
}
activeCheckAll = (async () => {
const checkingStatuses = Object.fromEntries(API_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus]));
setApiStatusState((current) => ({
@@ -105,37 +77,20 @@ export async function checkAllApiStatuses(forceRefresh: boolean = false): Promis
...checkingStatuses,
},
}));
try {
const [unifiedResult, musicBrainzStatus] = await Promise.allSettled([
withTimeout(fetchUnifiedStatuses(forceRefresh), CHECK_TIMEOUT_MS, "Unified SpotiFLAC status check timed out after 10 seconds"),
checkMusicBrainzStatus(),
]);
setApiStatusState((current) => {
const nextStatuses = { ...current.statuses };
if (unifiedResult.status === "fulfilled") {
Object.assign(nextStatuses, unifiedResult.value.statuses);
}
else {
nextStatuses.tidal1 = "offline";
nextStatuses.tidal2 = "offline";
nextStatuses.tidal3 = "offline";
nextStatuses.tidal4 = "offline";
nextStatuses.tidal5 = "offline";
nextStatuses.tidal6 = "offline";
nextStatuses.tidal7 = "offline";
nextStatuses.qobuz1 = "offline";
nextStatuses.qobuz2 = "offline";
nextStatuses.qobuz3 = "offline";
nextStatuses.amazon1 = "offline";
nextStatuses.lrclib = "offline";
}
nextStatuses.musicbrainz =
musicBrainzStatus.status === "fulfilled" ? musicBrainzStatus.value : "offline";
return {
...current,
statuses: nextStatuses,
};
});
const results = await Promise.all(API_SOURCES.map(async (source) => ({
id: source.id,
status: await checkSourceStatus(source),
})));
setApiStatusState((current) => ({
...current,
statuses: results.reduce<Record<string, ApiCheckStatus>>((acc, result) => {
acc[result.id] = result.status;
return acc;
}, { ...current.statuses }),
}));
}
finally {
setApiStatusState((current) => ({
@@ -145,5 +100,6 @@ export async function checkAllApiStatuses(forceRefresh: boolean = false): Promis
activeCheckAll = null;
}
})();
return activeCheckAll;
}
-2
View File
@@ -53,8 +53,6 @@ export function DownloadTrack(arg1:main.DownloadRequest):Promise<main.DownloadRe
export function ExportFailedDownloads():Promise<string>;
export function FetchUnifiedAPIStatus(arg1:boolean):Promise<string>;
export function GetBrewPath():Promise<string>;
export function GetConfigPath():Promise<string>;
-4
View File
@@ -102,10 +102,6 @@ export function ExportFailedDownloads() {
return window['go']['main']['App']['ExportFailedDownloads']();
}
export function FetchUnifiedAPIStatus(arg1) {
return window['go']['main']['App']['FetchUnifiedAPIStatus'](arg1);
}
export function GetBrewPath() {
return window['go']['main']['App']['GetBrewPath']();
}