.tidal gist url
This commit is contained in:
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
-2
@@ -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>;
|
||||||
|
|||||||
@@ -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']();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user