.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
+161 -124
View File
@@ -34,14 +34,6 @@ type CurrentIPInfo struct {
} }
const checkOperationTimeout = 10 * time.Second 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 { func NewApp() *App {
return &App{} return &App{}
@@ -152,60 +144,6 @@ func previewResponseBody(body []byte, maxLen int) string {
return preview 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) { func fetchCurrentIPInfo() (CurrentIPInfo, error) {
type ipwhoisResponse struct { type ipwhoisResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
@@ -313,10 +251,6 @@ func (a *App) GetCurrentIPInfo() (string, error) {
return string(payload), nil return string(payload), nil
} }
func (a *App) FetchUnifiedAPIStatus(forceRefresh bool) (string, error) {
return fetchUnifiedStatusPayload(forceRefresh, unifiedStatusAPIURL)
}
func (a *App) getFirstArtist(artistString string) string { func (a *App) getFirstArtist(artistString string) string {
if artistString == "" { if artistString == "" {
return "" return ""
@@ -342,6 +276,11 @@ func (a *App) startup(ctx context.Context) {
if err := backend.InitProviderPriorityDB(); err != nil { if err := backend.InitProviderPriorityDB(); err != nil {
fmt.Printf("Failed to init provider priority DB: %v\n", err) 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) { 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 { func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
isOnline, err := runWithTimeout(checkOperationTimeout, func() (bool, error) { isOnline, err := runWithTimeout(checkOperationTimeout, func() (bool, error) {
var checkURL string switch apiType {
if apiType == "tidal" { case "tidal":
checkURL = fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL) if checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs(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 return true, nil
} }
if strings.TrimSpace(apiURL) == "" {
if (apiType == "qobuz" || apiType == "qbz") && statusCode == 200 && containsStreamingURL(body) { if _, refreshErr := backend.RefreshTidalAPIList(true); refreshErr == nil && checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs("")) {
return true, nil 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 {
return true, nil
}
}
if i < maxRetries-1 {
time.Sleep(1 * time.Second)
}
} }
return false, nil 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
}
}) })
if err != nil { if err != nil {
if apiType == "musicbrainz" { if apiType == "musicbrainz" {
@@ -1122,6 +1019,146 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
return isOnline 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() { func (a *App) Quit() {
panic("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) return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
} }
apiURL := fmt.Sprintf("https://amazon.spotbye.qzz.io/api/track/%s", asin) apiURL := fmt.Sprintf("%s/api/track/%s", amazonMusicAPIBaseURL, asin)
req, err := http.NewRequest("GET", apiURL, nil) req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
if err != nil { if err != nil {
return "", err 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) fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
resp, err := a.client.Do(req) resp, err := a.client.Do(req)
@@ -98,8 +97,10 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
} }
defer out.Close() defer out.Close()
dlReq, _ := http.NewRequest("GET", downloadURL, nil) dlReq, err := NewRequestWithDefaultHeaders(http.MethodGet, 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") if err != nil {
return "", err
}
dlResp, err := a.client.Do(dlReq) dlResp, err := a.client.Do(dlReq)
if err != nil { 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) { func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) {
apiURL := buildQobuzAPIURL(apiBase, trackID, quality) 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 { if err != nil {
return "", err 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) fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
standardAPIs := prioritizeProviders("qobuz", []string{ standardAPIs := prioritizeProviders("qobuz", GetQobuzStreamAPIBaseURLs())
"https://dab.yeet.su/api/stream?trackId=",
"https://dabmusic.xyz/api/stream?trackId=",
"https://qobuz.spotbye.qzz.io/api/track/",
})
downloadFunc := func(qual string) (string, error) { downloadFunc := func(qual string) (string, error) {
type Provider struct { type Provider struct {
@@ -272,7 +273,12 @@ func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
Timeout: 5 * time.Minute, 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 { if err != nil {
return fmt.Errorf("failed to download file: %w", err) 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") 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 { if err != nil {
return fmt.Errorf("failed to download cover: %w", err) 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" qobuzAPIBaseURL = "https://www.qobuz.com/api.json/0.2"
qobuzDefaultAPIAppID = "712109809" qobuzDefaultAPIAppID = "712109809"
qobuzDefaultAPIAppSecret = "589be88e4538daea11f509d29e4a23b1" 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" qobuzCredentialsCacheFile = "qobuz-api-credentials.json"
qobuzCredentialsCacheTTL = 24 * time.Hour qobuzCredentialsCacheTTL = 24 * time.Hour
qobuzCredentialsProbeTrackISRC = "USUM71703861" qobuzCredentialsProbeTrackISRC = "USUM71703861"
+90 -97
View File
@@ -49,17 +49,9 @@ type TidalBTSManifest struct {
} }
func NewTidalDownloader(apiURL string) *TidalDownloader { func NewTidalDownloader(apiURL string) *TidalDownloader {
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if apiURL == "" { if apiURL == "" {
downloader := &TidalDownloader{ apis, err := GetRotatedTidalAPIList()
client: &http.Client{
Timeout: 5 * time.Second,
},
timeout: 5 * time.Second,
maxRetries: 3,
apiURL: "",
}
apis, err := downloader.GetAvailableAPIs()
if err == nil && len(apis) > 0 { if err == nil && len(apis) > 0 {
apiURL = apis[0] apiURL = apis[0]
} }
@@ -76,16 +68,12 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
} }
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) { func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
apis := []string{ apis, err := GetRotatedTidalAPIList()
"https://hifi-one.spotisaver.net", if err == nil && len(apis) > 0 {
"https://hifi-two.spotisaver.net", return apis, nil
"https://eu-central.monochrome.tf",
"https://us-west.monochrome.tf",
"https://api.monochrome.tf",
"https://monochrome-api.samidy.com",
"https://tidal.kinoplus.online",
} }
return prioritizeProviders("tidal", apis), nil
return nil, err
} }
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) { 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) url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
fmt.Printf("Tidal API URL: %s\n", url) 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 { if err != nil {
fmt.Printf("✗ failed to create request: %v\n", err) fmt.Printf("✗ failed to create request: %v\n", err)
return "", fmt.Errorf("failed to create request: %w", 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) resp, err := t.client.Do(req)
if err != nil { if err != nil {
fmt.Printf("✗ Tidal API request failed: %v\n", err) 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) 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 { if err != nil {
return fmt.Errorf("failed to create request: %w", 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) resp, err := t.client.Do(req)
if err != nil { if err != nil {
@@ -241,11 +225,10 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
} }
doRequest := func(url string) (*http.Response, error) { doRequest := func(url string) (*http.Response, error) {
req, err := http.NewRequest("GET", url, nil) req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
if err != nil { if err != nil {
return nil, err 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) return client.Do(req)
} }
@@ -460,7 +443,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
downloadURL, err := t.GetDownloadURL(trackID, quality) downloadURL, err := t.GetDownloadURL(trackID, quality)
if err != nil { if err != nil {
if quality == "HI_RES" && allowFallback { if isTidalHiResQuality(quality) && allowFallback {
fmt.Println("⚠ HI_RES unavailable/failed, falling back to LOSSLESS...") fmt.Println("⚠ HI_RES unavailable/failed, falling back to LOSSLESS...")
downloadURL, err = t.GetDownloadURL(trackID, "LOSSLESS") downloadURL, err = t.GetDownloadURL(trackID, "LOSSLESS")
if err != nil { if err != nil {
@@ -515,6 +498,11 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
if err := t.DownloadFile(downloadURL, outputFilename); err != nil { if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
return "", err 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) isrc := strings.TrimSpace(isrcOverride)
var mbMeta Metadata 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) { 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 outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil { if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("directory error: %w", err) return "", fmt.Errorf("directory error: %w", err)
@@ -626,19 +609,6 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
return "EXISTS:" + outputFilename, nil 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 { type mbResultFallback struct {
ISRC string ISRC string
Metadata Metadata Metadata Metadata
@@ -680,10 +650,11 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
} }
fmt.Printf("Downloading to: %s\n", outputFilename) fmt.Printf("Downloading to: %s\n", outputFilename)
downloader := NewTidalDownloader(successAPI) successAPI, err := t.downloadWithRotatingAPIs(trackID, outputFilename, quality, allowFallback)
if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil { if err != nil {
return "", err return "", err
} }
fmt.Printf("✓ Downloaded using API: %s\n", successAPI)
isrc := strings.TrimSpace(isrcOverride) isrc := strings.TrimSpace(isrcOverride)
var mbMeta Metadata var mbMeta Metadata
@@ -920,79 +891,101 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
return "", initURL, mediaURLs, "", nil return "", initURL, mediaURLs, "", nil
} }
func getDownloadURLRotated(apis []string, trackID int64, quality string) (string, string, error) { 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")
}
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)
}
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 { if len(apis) == 0 {
return "", "", fmt.Errorf("no APIs available") return "", fmt.Errorf("no tidal apis available")
} }
orderedAPIs := prioritizeProviders("tidal", apis) var lastErr error
fmt.Printf("Trying %d prioritized APIs...\n", len(orderedAPIs)) errors := make([]string, 0, len(apis))
var lastError error for _, apiURL := range apis {
var errors []string fmt.Printf("Trying Tidal API: %s\n", apiURL)
for _, apiURL := range orderedAPIs { downloader := NewTidalDownloader(apiURL)
fmt.Printf("Trying API: %s\n", apiURL) downloadURL, err := downloader.GetDownloadURL(trackID, quality)
client := &http.Client{
Timeout: 15 * time.Second,
}
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", apiURL, trackID, quality)
resp, err := client.Get(url)
if err != nil { if err != nil {
lastError = err lastErr = err
recordProviderFailure("tidal", apiURL)
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err)) errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
continue continue
} }
if resp.StatusCode != 200 { if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil {
resp.Body.Close() lastErr = err
lastError = fmt.Errorf("HTTP %d", resp.StatusCode) cleanupTidalDownloadArtifacts(outputFilename)
recordProviderFailure("tidal", apiURL) errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, lastError))
continue continue
} }
body, err := io.ReadAll(resp.Body) if err := RememberTidalAPIUsage(apiURL); err != nil {
resp.Body.Close() fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
if err != nil {
lastError = err
recordProviderFailure("tidal", apiURL)
errors = append(errors, fmt.Sprintf("%s: read body failed", apiURL))
continue
} }
var v2Response TidalAPIResponseV2 return apiURL, nil
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 !refreshed {
if err := json.Unmarshal(body, &v1Responses); err == nil { if _, refreshErr := RefreshTidalAPIList(true); refreshErr != nil {
for _, item := range v1Responses { errors = append(errors, fmt.Sprintf("gist refresh failed: %v", refreshErr))
if item.OriginalTrackURL != "" { } else {
fmt.Printf("✓ Success with: %s\n", apiURL) fmt.Println("All cached Tidal APIs failed, refreshed gist list and retrying...")
recordProviderSuccess("tidal", apiURL) return t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, quality, true)
return apiURL, item.OriginalTrackURL, nil
}
} }
} }
lastError = fmt.Errorf("no download URL or manifest in response") if lastErr == nil {
recordProviderFailure("tidal", apiURL) lastErr = fmt.Errorf("all tidal apis failed")
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, lastError))
} }
fmt.Println("All APIs failed:") fmt.Println("All Tidal APIs failed:")
for _, e := range errors { for _, item := range errors {
fmt.Printf(" ✗ %s\n", e) fmt.Printf(" ✗ %s\n", item)
} }
return "", "", fmt.Errorf("all %d APIs failed. Last error: %v", len(apis), lastError) 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 { 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 { Button } from "@/components/ui/button";
import { SearchCheck, CheckCircle2, XCircle, Loader2 } from "lucide-react"; 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"; import { useApiStatus } from "@/hooks/useApiStatus";
export function ApiStatusTab() { export function ApiStatusTab() {
const { sources, statuses, isCheckingAll, checkAll } = useApiStatus(); const { sources, statuses, isCheckingAll, checkAll } = useApiStatus();
@@ -17,7 +17,7 @@ export function ApiStatusTab() {
const status = statuses[source.id] || "idle"; 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"> 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"> <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> <p className="font-medium leading-none">{source.name}</p>
</div> </div>
+33 -77
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"; import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle"; export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
export interface ApiSource { export interface ApiSource {
id: string; id: string;
type: string; type: string;
name: string; name: string;
url: string; url: string;
} }
export const API_SOURCES: ApiSource[] = [ export const API_SOURCES: ApiSource[] = [
{ id: "tidal1", type: "tidal", name: "Tidal A", url: "https://hifi-one.spotisaver.net" }, { id: "tidal", type: "tidal", name: "Tidal", url: "" },
{ id: "tidal2", type: "tidal", name: "Tidal B", url: "https://hifi-two.spotisaver.net" }, { id: "qobuz", type: "qobuz", name: "Qobuz", url: "" },
{ id: "tidal3", type: "tidal", name: "Tidal C", url: "https://eu-central.monochrome.tf" }, { id: "amazon", type: "amazon", name: "Amazon Music", url: "" },
{ 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: "musicbrainz", type: "musicbrainz", name: "MusicBrainz", url: "https://musicbrainz.org" }, { id: "musicbrainz", type: "musicbrainz", name: "MusicBrainz", url: "https://musicbrainz.org" },
]; ];
type ApiStatusState = { type ApiStatusState = {
isCheckingAll: boolean; isCheckingAll: boolean;
statuses: Record<string, ApiCheckStatus>; statuses: Record<string, ApiCheckStatus>;
}; };
let apiStatusState: ApiStatusState = { let apiStatusState: ApiStatusState = {
isCheckingAll: false, isCheckingAll: false,
statuses: {}, statuses: {},
}; };
let activeCheckAll: Promise<void> | null = null; let activeCheckAll: Promise<void> | null = null;
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
type SpotiFLACUnifiedStatusResponse = {
tidal?: string;
qobuz_a?: string;
qobuz_b?: string;
qobuz_c?: string;
amazon?: string;
lrclib?: string;
};
function emitApiStatusChange() { function emitApiStatusChange() {
for (const listener of listeners) { for (const listener of listeners) {
listener(); listener();
} }
} }
function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) { function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) {
apiStatusState = updater(apiStatusState); apiStatusState = updater(apiStatusState);
emitApiStatusChange(); emitApiStatusChange();
} }
function statusFromUnifiedValue(value: string | undefined): ApiCheckStatus {
return value === "up" ? "online" : "offline"; async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
}
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> {
try { 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"; return isOnline ? "online" : "offline";
} }
catch { catch {
return "offline"; return "offline";
} }
} }
export function getApiStatusState(): ApiStatusState { export function getApiStatusState(): ApiStatusState {
return apiStatusState; return apiStatusState;
} }
export function subscribeApiStatus(listener: () => void): () => void { export function subscribeApiStatus(listener: () => void): () => void {
listeners.add(listener); listeners.add(listener);
return () => { return () => {
listeners.delete(listener); listeners.delete(listener);
}; };
} }
export async function checkAllApiStatuses(forceRefresh: boolean = false): Promise<void> {
export async function checkAllApiStatuses(_forceRefresh: boolean = false): Promise<void> {
if (activeCheckAll) { if (activeCheckAll) {
return activeCheckAll; return activeCheckAll;
} }
activeCheckAll = (async () => { activeCheckAll = (async () => {
const checkingStatuses = Object.fromEntries(API_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus])); const checkingStatuses = Object.fromEntries(API_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus]));
setApiStatusState((current) => ({ setApiStatusState((current) => ({
@@ -105,37 +77,20 @@ export async function checkAllApiStatuses(forceRefresh: boolean = false): Promis
...checkingStatuses, ...checkingStatuses,
}, },
})); }));
try { try {
const [unifiedResult, musicBrainzStatus] = await Promise.allSettled([ const results = await Promise.all(API_SOURCES.map(async (source) => ({
withTimeout(fetchUnifiedStatuses(forceRefresh), CHECK_TIMEOUT_MS, "Unified SpotiFLAC status check timed out after 10 seconds"), id: source.id,
checkMusicBrainzStatus(), status: await checkSourceStatus(source),
]); })));
setApiStatusState((current) => {
const nextStatuses = { ...current.statuses }; setApiStatusState((current) => ({
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, ...current,
statuses: nextStatuses, statuses: results.reduce<Record<string, ApiCheckStatus>>((acc, result) => {
}; acc[result.id] = result.status;
}); return acc;
}, { ...current.statuses }),
}));
} }
finally { finally {
setApiStatusState((current) => ({ setApiStatusState((current) => ({
@@ -145,5 +100,6 @@ export async function checkAllApiStatuses(forceRefresh: boolean = false): Promis
activeCheckAll = null; activeCheckAll = null;
} }
})(); })();
return activeCheckAll; 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 ExportFailedDownloads():Promise<string>;
export function FetchUnifiedAPIStatus(arg1:boolean):Promise<string>;
export function GetBrewPath():Promise<string>; export function GetBrewPath():Promise<string>;
export function GetConfigPath():Promise<string>; export function GetConfigPath():Promise<string>;
-4
View File
@@ -102,10 +102,6 @@ export function ExportFailedDownloads() {
return window['go']['main']['App']['ExportFailedDownloads'](); return window['go']['main']['App']['ExportFailedDownloads']();
} }
export function FetchUnifiedAPIStatus(arg1) {
return window['go']['main']['App']['FetchUnifiedAPIStatus'](arg1);
}
export function GetBrewPath() { export function GetBrewPath() {
return window['go']['main']['App']['GetBrewPath'](); return window['go']['main']['App']['GetBrewPath']();
} }