.tidal gist url
This commit is contained in:
@@ -34,14 +34,6 @@ type CurrentIPInfo struct {
|
||||
}
|
||||
|
||||
const checkOperationTimeout = 10 * time.Second
|
||||
const unifiedStatusAPIURL = "https://status.spotbye.qzz.io/api/status/spotiflac/"
|
||||
const unifiedStatusCacheTTL = 5 * time.Second
|
||||
|
||||
var (
|
||||
unifiedStatusCacheMu sync.Mutex
|
||||
unifiedStatusCacheBody string
|
||||
unifiedStatusCacheExpiry time.Time
|
||||
)
|
||||
|
||||
func NewApp() *App {
|
||||
return &App{}
|
||||
@@ -152,60 +144,6 @@ func previewResponseBody(body []byte, maxLen int) string {
|
||||
return preview
|
||||
}
|
||||
|
||||
func fetchUnifiedStatusPayload(forceRefresh bool, endpoint string) (string, error) {
|
||||
unifiedStatusCacheMu.Lock()
|
||||
defer unifiedStatusCacheMu.Unlock()
|
||||
|
||||
if !forceRefresh && unifiedStatusCacheBody != "" && time.Now().Before(unifiedStatusCacheExpiry) {
|
||||
return unifiedStatusCacheBody, nil
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
maxRetries := 3
|
||||
var lastErr error
|
||||
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create unified status request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err == nil {
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if readErr != nil {
|
||||
lastErr = fmt.Errorf("attempt %d: failed reading response: %w", i+1, readErr)
|
||||
} else if resp.StatusCode != http.StatusOK {
|
||||
lastErr = fmt.Errorf("attempt %d: returned status %d (%s)", i+1, resp.StatusCode, previewResponseBody(body, 200))
|
||||
} else {
|
||||
payload := strings.TrimSpace(string(body))
|
||||
if payload == "" {
|
||||
lastErr = fmt.Errorf("attempt %d: empty response body", i+1)
|
||||
} else {
|
||||
unifiedStatusCacheBody = payload
|
||||
unifiedStatusCacheExpiry = time.Now().Add(unifiedStatusCacheTTL)
|
||||
return payload, nil
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lastErr = fmt.Errorf("attempt %d: connection failed: %w", i+1, err)
|
||||
}
|
||||
|
||||
if i < maxRetries-1 {
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("unknown error")
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("unified status API failed after %d retries: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
func fetchCurrentIPInfo() (CurrentIPInfo, error) {
|
||||
type ipwhoisResponse struct {
|
||||
Success bool `json:"success"`
|
||||
@@ -313,10 +251,6 @@ func (a *App) GetCurrentIPInfo() (string, error) {
|
||||
return string(payload), nil
|
||||
}
|
||||
|
||||
func (a *App) FetchUnifiedAPIStatus(forceRefresh bool) (string, error) {
|
||||
return fetchUnifiedStatusPayload(forceRefresh, unifiedStatusAPIURL)
|
||||
}
|
||||
|
||||
func (a *App) getFirstArtist(artistString string) string {
|
||||
if artistString == "" {
|
||||
return ""
|
||||
@@ -342,6 +276,11 @@ func (a *App) startup(ctx context.Context) {
|
||||
if err := backend.InitProviderPriorityDB(); err != nil {
|
||||
fmt.Printf("Failed to init provider priority DB: %v\n", err)
|
||||
}
|
||||
go func() {
|
||||
if err := backend.PrimeTidalAPIList(); err != nil {
|
||||
fmt.Printf("Failed to prime Tidal API list: %v\n", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *App) shutdown(ctx context.Context) {
|
||||
@@ -1042,70 +981,28 @@ func (a *App) ExportFailedDownloads() (string, error) {
|
||||
|
||||
func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
|
||||
isOnline, err := runWithTimeout(checkOperationTimeout, func() (bool, error) {
|
||||
var checkURL string
|
||||
if apiType == "tidal" {
|
||||
checkURL = fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)
|
||||
} else if apiType == "qobuz" {
|
||||
checkURL = fmt.Sprintf("%s/api/stream?trackId=360735657&quality=27", apiURL)
|
||||
} else if apiType == "qbz" {
|
||||
checkURL = fmt.Sprintf("%s/api/track/360735657?quality=27", apiURL)
|
||||
} else if apiType == "amazon" {
|
||||
checkURL = fmt.Sprintf("%s/status", apiURL)
|
||||
} else if apiType == "lrclib" {
|
||||
checkURL = fmt.Sprintf("%s/api/search?artist_name=Adele&track_name=Hello", strings.TrimRight(apiURL, "/"))
|
||||
} else if apiType == "musicbrainz" {
|
||||
checkURL = fmt.Sprintf("%s/ws/2/recording?query=%s&fmt=json&limit=1", strings.TrimRight(apiURL, "/"), url.QueryEscape(`recording:"Hello" AND artist:"Adele"`))
|
||||
} else {
|
||||
checkURL = apiURL
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
req, err := http.NewRequest("GET", checkURL, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
maxRetries := 3
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
resp, err := client.Do(req)
|
||||
if err == nil {
|
||||
statusCode := resp.StatusCode
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if readErr != nil {
|
||||
if i < maxRetries-1 {
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if apiType == "amazon" && statusCode == 200 && strings.Contains(string(body), `"amazonMusic":"up"`) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if (apiType == "qobuz" || apiType == "qbz") && statusCode == 200 && containsStreamingURL(body) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if apiType == "lrclib" && statusCode == 200 && containsLRCLIBResults(body) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if apiType == "musicbrainz" && statusCode == 200 && containsMusicBrainzResults(body) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if apiType != "amazon" && apiType != "qobuz" && apiType != "qbz" && apiType != "lrclib" && apiType != "musicbrainz" && statusCode == 200 {
|
||||
switch apiType {
|
||||
case "tidal":
|
||||
if checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs(apiURL)) {
|
||||
return true, nil
|
||||
}
|
||||
if strings.TrimSpace(apiURL) == "" {
|
||||
if _, refreshErr := backend.RefreshTidalAPIList(true); refreshErr == nil && checkGroupedAPIStatus("tidal", buildTidalStatusCheckURLs("")) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if i < maxRetries-1 {
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
return false, nil
|
||||
case "qobuz", "qbz":
|
||||
return checkGroupedAPIStatus("qobuz", buildQobuzStatusCheckURLs(apiURL)), nil
|
||||
case "amazon":
|
||||
return checkGroupedAPIStatus("amazon", buildAmazonStatusCheckURLs(apiURL)), nil
|
||||
case "lrclib":
|
||||
return checkGroupedAPIStatus("lrclib", buildLRCLIBStatusCheckURLs(apiURL)), nil
|
||||
case "musicbrainz":
|
||||
return checkGroupedAPIStatus("musicbrainz", buildMusicBrainzStatusCheckURLs(apiURL)), nil
|
||||
default:
|
||||
return checkGroupedAPIStatus(apiType, []string{strings.TrimSpace(apiURL)}), nil
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if err != nil {
|
||||
if apiType == "musicbrainz" {
|
||||
@@ -1122,6 +1019,146 @@ func (a *App) CheckAPIStatus(apiType string, apiURL string) bool {
|
||||
return isOnline
|
||||
}
|
||||
|
||||
func buildTidalStatusCheckURLs(apiURL string) []string {
|
||||
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||
if apiURL != "" {
|
||||
return []string{fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", apiURL)}
|
||||
}
|
||||
|
||||
apis, err := backend.GetRotatedTidalAPIList()
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to load rotated Tidal API list for status check: %v\n", err)
|
||||
}
|
||||
|
||||
urls := make([]string, 0, len(apis))
|
||||
for _, baseURL := range apis {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" {
|
||||
continue
|
||||
}
|
||||
urls = append(urls, fmt.Sprintf("%s/track/?id=441821360&quality=HI_RES_LOSSLESS", baseURL))
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
func buildQobuzStatusCheckURLs(apiURL string) []string {
|
||||
if trimmed := strings.TrimSpace(apiURL); trimmed != "" {
|
||||
return []string{buildQobuzStatusCheckURL(trimmed)}
|
||||
}
|
||||
|
||||
bases := backend.GetQobuzStreamAPIBaseURLs()
|
||||
urls := make([]string, 0, len(bases))
|
||||
for _, baseURL := range bases {
|
||||
urls = append(urls, buildQobuzStatusCheckURL(baseURL))
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
func buildQobuzStatusCheckURL(apiBase string) string {
|
||||
apiBase = strings.TrimSpace(apiBase)
|
||||
if strings.Contains(apiBase, "qobuz.spotbye.qzz.io") {
|
||||
return fmt.Sprintf("%s360735657?quality=27", apiBase)
|
||||
}
|
||||
return fmt.Sprintf("%s360735657&quality=27", apiBase)
|
||||
}
|
||||
|
||||
func buildAmazonStatusCheckURLs(apiURL string) []string {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = backend.GetAmazonMusicAPIBaseURL()
|
||||
}
|
||||
return []string{fmt.Sprintf("%s/status", baseURL)}
|
||||
}
|
||||
|
||||
func buildLRCLIBStatusCheckURLs(apiURL string) []string {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://lrclib.net"
|
||||
}
|
||||
return []string{fmt.Sprintf("%s/api/search?artist_name=Adele&track_name=Hello", baseURL)}
|
||||
}
|
||||
|
||||
func buildMusicBrainzStatusCheckURLs(apiURL string) []string {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://musicbrainz.org"
|
||||
}
|
||||
return []string{fmt.Sprintf("%s/ws/2/recording?query=%s&fmt=json&limit=1", baseURL, url.QueryEscape(`recording:"Hello" AND artist:"Adele"`))}
|
||||
}
|
||||
|
||||
func checkGroupedAPIStatus(apiType string, checkURLs []string) bool {
|
||||
filtered := make([]string, 0, len(checkURLs))
|
||||
for _, rawURL := range checkURLs {
|
||||
url := strings.TrimSpace(rawURL)
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, url)
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
results := make(chan bool, len(filtered))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, checkURL := range filtered {
|
||||
wg.Add(1)
|
||||
go func(target string) {
|
||||
defer wg.Done()
|
||||
results <- checkSingleAPIStatus(apiType, target)
|
||||
}(checkURL)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(results)
|
||||
}()
|
||||
|
||||
for online := range results {
|
||||
if online {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func checkSingleAPIStatus(apiType string, checkURL string) bool {
|
||||
client := &http.Client{Timeout: 4 * time.Second}
|
||||
req, err := backend.NewRequestWithDefaultHeaders(http.MethodGet, checkURL, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
statusCode := resp.StatusCode
|
||||
switch apiType {
|
||||
case "amazon":
|
||||
return statusCode == http.StatusOK && strings.Contains(string(body), `"amazonMusic":"up"`)
|
||||
case "qobuz", "qbz":
|
||||
return statusCode == http.StatusOK && containsStreamingURL(body)
|
||||
case "lrclib":
|
||||
return statusCode == http.StatusOK && containsLRCLIBResults(body)
|
||||
case "musicbrainz":
|
||||
return statusCode == http.StatusOK && containsMusicBrainzResults(body)
|
||||
default:
|
||||
return statusCode == http.StatusOK
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) Quit() {
|
||||
|
||||
panic("quit")
|
||||
|
||||
+6
-5
@@ -56,12 +56,11 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
|
||||
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://amazon.spotbye.qzz.io/api/track/%s", asin)
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
apiURL := fmt.Sprintf("%s/api/track/%s", amazonMusicAPIBaseURL, asin)
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
|
||||
fmt.Printf("Fetching from Amazon API (ASIN: %s)...\n", asin)
|
||||
resp, err := a.client.Do(req)
|
||||
@@ -98,8 +97,10 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
dlReq, _ := http.NewRequest("GET", downloadURL, nil)
|
||||
dlReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
dlReq, err := NewRequestWithDefaultHeaders(http.MethodGet, downloadURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dlResp, err := a.client.Do(dlReq)
|
||||
if err != nil {
|
||||
|
||||
@@ -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) {
|
||||
apiURL := buildQobuzAPIURL(apiBase, trackID, quality)
|
||||
resp, err := q.client.Get(apiURL)
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := q.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -191,11 +196,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
|
||||
|
||||
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
|
||||
|
||||
standardAPIs := prioritizeProviders("qobuz", []string{
|
||||
"https://dab.yeet.su/api/stream?trackId=",
|
||||
"https://dabmusic.xyz/api/stream?trackId=",
|
||||
"https://qobuz.spotbye.qzz.io/api/track/",
|
||||
})
|
||||
standardAPIs := prioritizeProviders("qobuz", GetQobuzStreamAPIBaseURLs())
|
||||
|
||||
downloadFunc := func(qual string) (string, error) {
|
||||
type Provider struct {
|
||||
@@ -272,7 +273,12 @@ func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
|
||||
Timeout: 5 * time.Minute,
|
||||
}
|
||||
|
||||
resp, err := downloadClient.Get(url)
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create download request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := downloadClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
@@ -306,7 +312,12 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
|
||||
return fmt.Errorf("no cover URL provided")
|
||||
}
|
||||
|
||||
resp, err := q.client.Get(coverURL)
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, coverURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create cover request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := q.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download cover: %w", err)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const (
|
||||
qobuzAPIBaseURL = "https://www.qobuz.com/api.json/0.2"
|
||||
qobuzDefaultAPIAppID = "712109809"
|
||||
qobuzDefaultAPIAppSecret = "589be88e4538daea11f509d29e4a23b1"
|
||||
qobuzDefaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
|
||||
qobuzDefaultUA = DefaultDownloaderUserAgent
|
||||
qobuzCredentialsCacheFile = "qobuz-api-credentials.json"
|
||||
qobuzCredentialsCacheTTL = 24 * time.Hour
|
||||
qobuzCredentialsProbeTrackISRC = "USUM71703861"
|
||||
|
||||
+96
-103
@@ -49,17 +49,9 @@ type TidalBTSManifest struct {
|
||||
}
|
||||
|
||||
func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
||||
if apiURL == "" {
|
||||
downloader := &TidalDownloader{
|
||||
client: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
},
|
||||
timeout: 5 * time.Second,
|
||||
maxRetries: 3,
|
||||
apiURL: "",
|
||||
}
|
||||
|
||||
apis, err := downloader.GetAvailableAPIs()
|
||||
apis, err := GetRotatedTidalAPIList()
|
||||
if err == nil && len(apis) > 0 {
|
||||
apiURL = apis[0]
|
||||
}
|
||||
@@ -76,16 +68,12 @@ func NewTidalDownloader(apiURL string) *TidalDownloader {
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
||||
apis := []string{
|
||||
"https://hifi-one.spotisaver.net",
|
||||
"https://hifi-two.spotisaver.net",
|
||||
"https://eu-central.monochrome.tf",
|
||||
"https://us-west.monochrome.tf",
|
||||
"https://api.monochrome.tf",
|
||||
"https://monochrome-api.samidy.com",
|
||||
"https://tidal.kinoplus.online",
|
||||
apis, err := GetRotatedTidalAPIList()
|
||||
if err == nil && len(apis) > 0 {
|
||||
return apis, nil
|
||||
}
|
||||
return prioritizeProviders("tidal", apis), nil
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
@@ -129,14 +117,12 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
|
||||
fmt.Printf("Tidal API URL: %s\n", url)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("✗ failed to create request: %v\n", err)
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("✗ Tidal API request failed: %v\n", err)
|
||||
@@ -194,13 +180,11 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
||||
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
@@ -241,11 +225,10 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
|
||||
}
|
||||
|
||||
doRequest := func(url string) (*http.Response, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
@@ -460,7 +443,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
||||
|
||||
downloadURL, err := t.GetDownloadURL(trackID, quality)
|
||||
if err != nil {
|
||||
if quality == "HI_RES" && allowFallback {
|
||||
if isTidalHiResQuality(quality) && allowFallback {
|
||||
fmt.Println("⚠ HI_RES unavailable/failed, falling back to LOSSLESS...")
|
||||
downloadURL, err = t.GetDownloadURL(trackID, "LOSSLESS")
|
||||
if err != nil {
|
||||
@@ -515,6 +498,11 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
||||
if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if t.apiURL != "" {
|
||||
if err := RememberTidalAPIUsage(t.apiURL); err != nil {
|
||||
fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
isrc := strings.TrimSpace(isrcOverride)
|
||||
var mbMeta Metadata
|
||||
@@ -580,11 +568,6 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
|
||||
apis, err := t.GetAvailableAPIs()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no APIs available for fallback: %w", err)
|
||||
}
|
||||
|
||||
if outputDir != "." {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("directory error: %w", err)
|
||||
@@ -626,19 +609,6 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
||||
return "EXISTS:" + outputFilename, nil
|
||||
}
|
||||
|
||||
successAPI, downloadURL, err := getDownloadURLRotated(apis, trackID, quality)
|
||||
if err != nil {
|
||||
if quality == "HI_RES" && allowFallback {
|
||||
fmt.Println("⚠ HI_RES unavailable/failed on all APIs, falling back to LOSSLESS...")
|
||||
successAPI, downloadURL, err = getDownloadURLRotated(apis, trackID, "LOSSLESS")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err)
|
||||
}
|
||||
} else {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
type mbResultFallback struct {
|
||||
ISRC string
|
||||
Metadata Metadata
|
||||
@@ -680,10 +650,11 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
|
||||
}
|
||||
|
||||
fmt.Printf("Downloading to: %s\n", outputFilename)
|
||||
downloader := NewTidalDownloader(successAPI)
|
||||
if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil {
|
||||
successAPI, err := t.downloadWithRotatingAPIs(trackID, outputFilename, quality, allowFallback)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Printf("✓ Downloaded using API: %s\n", successAPI)
|
||||
|
||||
isrc := strings.TrimSpace(isrcOverride)
|
||||
var mbMeta Metadata
|
||||
@@ -920,79 +891,101 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
return "", initURL, mediaURLs, "", nil
|
||||
}
|
||||
|
||||
func getDownloadURLRotated(apis []string, trackID int64, quality string) (string, string, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", "", fmt.Errorf("no APIs available")
|
||||
func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename string, quality string, allowFallback bool) (string, error) {
|
||||
qualities := []string{quality}
|
||||
if isTidalHiResQuality(quality) && allowFallback {
|
||||
qualities = append(qualities, "LOSSLESS")
|
||||
}
|
||||
|
||||
orderedAPIs := prioritizeProviders("tidal", apis)
|
||||
fmt.Printf("Trying %d prioritized APIs...\n", len(orderedAPIs))
|
||||
|
||||
var lastError error
|
||||
var errors []string
|
||||
|
||||
for _, apiURL := range orderedAPIs {
|
||||
fmt.Printf("Trying API: %s\n", apiURL)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
var lastErr error
|
||||
for idx, candidateQuality := range qualities {
|
||||
if idx > 0 {
|
||||
fmt.Printf("⚠ %s unavailable/failed on all APIs, falling back to %s...\n", quality, candidateQuality)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", apiURL, trackID, quality)
|
||||
resp, err := client.Get(url)
|
||||
apiURL, err := t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, candidateQuality, false)
|
||||
if err == nil {
|
||||
return apiURL, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no tidal api succeeded")
|
||||
}
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilename string, quality string, refreshed bool) (string, error) {
|
||||
apis, err := GetRotatedTidalAPIList()
|
||||
if err != nil && len(apis) == 0 {
|
||||
return "", fmt.Errorf("failed to load tidal api list: %w", err)
|
||||
}
|
||||
if len(apis) == 0 {
|
||||
return "", fmt.Errorf("no tidal apis available")
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
errors := make([]string, 0, len(apis))
|
||||
|
||||
for _, apiURL := range apis {
|
||||
fmt.Printf("Trying Tidal API: %s\n", apiURL)
|
||||
|
||||
downloader := NewTidalDownloader(apiURL)
|
||||
downloadURL, err := downloader.GetDownloadURL(trackID, quality)
|
||||
if err != nil {
|
||||
lastError = err
|
||||
recordProviderFailure("tidal", apiURL)
|
||||
lastErr = err
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
lastError = fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
recordProviderFailure("tidal", apiURL)
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, lastError))
|
||||
if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil {
|
||||
lastErr = err
|
||||
cleanupTidalDownloadArtifacts(outputFilename)
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
|
||||
continue
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
lastError = err
|
||||
recordProviderFailure("tidal", apiURL)
|
||||
errors = append(errors, fmt.Sprintf("%s: read body failed", apiURL))
|
||||
continue
|
||||
if err := RememberTidalAPIUsage(apiURL); err != nil {
|
||||
fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
|
||||
}
|
||||
|
||||
var v2Response TidalAPIResponseV2
|
||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||
fmt.Printf("✓ Success with: %s\n", apiURL)
|
||||
recordProviderSuccess("tidal", apiURL)
|
||||
return apiURL, "MANIFEST:" + v2Response.Data.Manifest, nil
|
||||
}
|
||||
|
||||
var v1Responses []TidalAPIResponse
|
||||
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
||||
for _, item := range v1Responses {
|
||||
if item.OriginalTrackURL != "" {
|
||||
fmt.Printf("✓ Success with: %s\n", apiURL)
|
||||
recordProviderSuccess("tidal", apiURL)
|
||||
return apiURL, item.OriginalTrackURL, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastError = fmt.Errorf("no download URL or manifest in response")
|
||||
recordProviderFailure("tidal", apiURL)
|
||||
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, lastError))
|
||||
return apiURL, nil
|
||||
}
|
||||
|
||||
fmt.Println("All APIs failed:")
|
||||
for _, e := range errors {
|
||||
fmt.Printf(" ✗ %s\n", e)
|
||||
if !refreshed {
|
||||
if _, refreshErr := RefreshTidalAPIList(true); refreshErr != nil {
|
||||
errors = append(errors, fmt.Sprintf("gist refresh failed: %v", refreshErr))
|
||||
} else {
|
||||
fmt.Println("All cached Tidal APIs failed, refreshed gist list and retrying...")
|
||||
return t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, quality, true)
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("all %d APIs failed. Last error: %v", len(apis), lastError)
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("all tidal apis failed")
|
||||
}
|
||||
|
||||
fmt.Println("All Tidal APIs failed:")
|
||||
for _, item := range errors {
|
||||
fmt.Printf(" ✗ %s\n", item)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("all tidal apis failed for quality %s: %w", quality, lastErr)
|
||||
}
|
||||
|
||||
func cleanupTidalDownloadArtifacts(outputPath string) {
|
||||
if outputPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
_ = os.Remove(outputPath)
|
||||
_ = os.Remove(outputPath + ".m4a.tmp")
|
||||
}
|
||||
|
||||
func isTidalHiResQuality(quality string) bool {
|
||||
normalized := strings.TrimSpace(strings.ToUpper(quality))
|
||||
return normalized == "HI_RES" || normalized == "HI_RES_LOSSLESS"
|
||||
}
|
||||
|
||||
func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool, extra ...string) string {
|
||||
|
||||
@@ -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 { SearchCheck, CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon, LrclibIcon, MusicBrainzIcon } from "./PlatformIcons";
|
||||
import { TidalIcon, QobuzIcon, AmazonIcon, MusicBrainzIcon } from "./PlatformIcons";
|
||||
import { useApiStatus } from "@/hooks/useApiStatus";
|
||||
export function ApiStatusTab() {
|
||||
const { sources, statuses, isCheckingAll, checkAll } = useApiStatus();
|
||||
@@ -17,7 +17,7 @@ export function ApiStatusTab() {
|
||||
const status = statuses[source.id] || "idle";
|
||||
return (<div key={source.id} className="flex items-center justify-between p-4 border rounded-lg bg-card text-card-foreground shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
{source.type === "tidal" ? <TidalIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "amazon" ? <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "lrclib" ? <LrclibIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "musicbrainz" ? <MusicBrainzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>}
|
||||
{source.type === "tidal" ? <TidalIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "amazon" ? <AmazonIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : source.type === "musicbrainz" ? <MusicBrainzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/> : <QobuzIcon className="w-5 h-5 shrink-0 text-muted-foreground"/>}
|
||||
<p className="font-medium leading-none">{source.name}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,100 +1,72 @@
|
||||
import { CheckAPIStatus, FetchUnifiedAPIStatus } from "../../wailsjs/go/main/App";
|
||||
import { CheckAPIStatus } from "../../wailsjs/go/main/App";
|
||||
import { CHECK_TIMEOUT_MS, withTimeout } from "@/lib/async-timeout";
|
||||
|
||||
export type ApiCheckStatus = "checking" | "online" | "offline" | "idle";
|
||||
|
||||
export interface ApiSource {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const API_SOURCES: ApiSource[] = [
|
||||
{ id: "tidal1", type: "tidal", name: "Tidal A", url: "https://hifi-one.spotisaver.net" },
|
||||
{ id: "tidal2", type: "tidal", name: "Tidal B", url: "https://hifi-two.spotisaver.net" },
|
||||
{ id: "tidal3", type: "tidal", name: "Tidal C", url: "https://eu-central.monochrome.tf" },
|
||||
{ id: "tidal4", type: "tidal", name: "Tidal D", url: "https://us-west.monochrome.tf" },
|
||||
{ id: "tidal5", type: "tidal", name: "Tidal E", url: "https://api.monochrome.tf" },
|
||||
{ id: "tidal6", type: "tidal", name: "Tidal F", url: "https://monochrome-api.samidy.com" },
|
||||
{ id: "tidal7", type: "tidal", name: "Tidal G", url: "https://tidal.kinoplus.online" },
|
||||
{ id: "qobuz1", type: "qobuz", name: "Qobuz A", url: "https://dab.yeet.su" },
|
||||
{ id: "qobuz2", type: "qobuz", name: "Qobuz B", url: "https://dabmusic.xyz" },
|
||||
{ id: "qobuz3", type: "qbz", name: "Qobuz C", url: "https://qobuz.spotbye.qzz.io" },
|
||||
{ id: "amazon1", type: "amazon", name: "Amazon Music", url: "https://amazon.spotbye.qzz.io" },
|
||||
{ id: "lrclib", type: "lrclib", name: "LRCLIB", url: "https://lrclib.net" },
|
||||
{ id: "tidal", type: "tidal", name: "Tidal", url: "" },
|
||||
{ id: "qobuz", type: "qobuz", name: "Qobuz", url: "" },
|
||||
{ id: "amazon", type: "amazon", name: "Amazon Music", url: "" },
|
||||
{ id: "musicbrainz", type: "musicbrainz", name: "MusicBrainz", url: "https://musicbrainz.org" },
|
||||
];
|
||||
|
||||
type ApiStatusState = {
|
||||
isCheckingAll: boolean;
|
||||
statuses: Record<string, ApiCheckStatus>;
|
||||
};
|
||||
|
||||
let apiStatusState: ApiStatusState = {
|
||||
isCheckingAll: false,
|
||||
statuses: {},
|
||||
};
|
||||
|
||||
let activeCheckAll: Promise<void> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
type SpotiFLACUnifiedStatusResponse = {
|
||||
tidal?: string;
|
||||
qobuz_a?: string;
|
||||
qobuz_b?: string;
|
||||
qobuz_c?: string;
|
||||
amazon?: string;
|
||||
lrclib?: string;
|
||||
};
|
||||
|
||||
function emitApiStatusChange() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function setApiStatusState(updater: (current: ApiStatusState) => ApiStatusState) {
|
||||
apiStatusState = updater(apiStatusState);
|
||||
emitApiStatusChange();
|
||||
}
|
||||
function statusFromUnifiedValue(value: string | undefined): ApiCheckStatus {
|
||||
return value === "up" ? "online" : "offline";
|
||||
}
|
||||
async function fetchUnifiedStatuses(forceRefresh: boolean): Promise<Pick<ApiStatusState, "statuses">> {
|
||||
const response = await FetchUnifiedAPIStatus(forceRefresh);
|
||||
const payload = JSON.parse(response) as SpotiFLACUnifiedStatusResponse;
|
||||
const tidalStatus = statusFromUnifiedValue(payload.tidal);
|
||||
return {
|
||||
statuses: {
|
||||
tidal1: tidalStatus,
|
||||
tidal2: tidalStatus,
|
||||
tidal3: tidalStatus,
|
||||
tidal4: tidalStatus,
|
||||
tidal5: tidalStatus,
|
||||
tidal6: tidalStatus,
|
||||
tidal7: tidalStatus,
|
||||
qobuz1: statusFromUnifiedValue(payload.qobuz_a),
|
||||
qobuz2: statusFromUnifiedValue(payload.qobuz_b),
|
||||
qobuz3: statusFromUnifiedValue(payload.qobuz_c),
|
||||
amazon1: statusFromUnifiedValue(payload.amazon),
|
||||
lrclib: statusFromUnifiedValue(payload.lrclib),
|
||||
},
|
||||
};
|
||||
}
|
||||
async function checkMusicBrainzStatus(): Promise<ApiCheckStatus> {
|
||||
|
||||
async function checkSourceStatus(source: ApiSource): Promise<ApiCheckStatus> {
|
||||
try {
|
||||
const isOnline = await withTimeout(CheckAPIStatus("musicbrainz", "https://musicbrainz.org"), CHECK_TIMEOUT_MS, "API status check timed out after 10 seconds for MusicBrainz");
|
||||
const isOnline = await withTimeout(CheckAPIStatus(source.type, source.url), CHECK_TIMEOUT_MS, `API status check timed out after 10 seconds for ${source.name}`);
|
||||
return isOnline ? "online" : "offline";
|
||||
}
|
||||
catch {
|
||||
return "offline";
|
||||
}
|
||||
}
|
||||
|
||||
export function getApiStatusState(): ApiStatusState {
|
||||
return apiStatusState;
|
||||
}
|
||||
|
||||
export function subscribeApiStatus(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
export async function checkAllApiStatuses(forceRefresh: boolean = false): Promise<void> {
|
||||
|
||||
export async function checkAllApiStatuses(_forceRefresh: boolean = false): Promise<void> {
|
||||
if (activeCheckAll) {
|
||||
return activeCheckAll;
|
||||
}
|
||||
|
||||
activeCheckAll = (async () => {
|
||||
const checkingStatuses = Object.fromEntries(API_SOURCES.map((source) => [source.id, "checking" as ApiCheckStatus]));
|
||||
setApiStatusState((current) => ({
|
||||
@@ -105,37 +77,20 @@ export async function checkAllApiStatuses(forceRefresh: boolean = false): Promis
|
||||
...checkingStatuses,
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const [unifiedResult, musicBrainzStatus] = await Promise.allSettled([
|
||||
withTimeout(fetchUnifiedStatuses(forceRefresh), CHECK_TIMEOUT_MS, "Unified SpotiFLAC status check timed out after 10 seconds"),
|
||||
checkMusicBrainzStatus(),
|
||||
]);
|
||||
setApiStatusState((current) => {
|
||||
const nextStatuses = { ...current.statuses };
|
||||
if (unifiedResult.status === "fulfilled") {
|
||||
Object.assign(nextStatuses, unifiedResult.value.statuses);
|
||||
}
|
||||
else {
|
||||
nextStatuses.tidal1 = "offline";
|
||||
nextStatuses.tidal2 = "offline";
|
||||
nextStatuses.tidal3 = "offline";
|
||||
nextStatuses.tidal4 = "offline";
|
||||
nextStatuses.tidal5 = "offline";
|
||||
nextStatuses.tidal6 = "offline";
|
||||
nextStatuses.tidal7 = "offline";
|
||||
nextStatuses.qobuz1 = "offline";
|
||||
nextStatuses.qobuz2 = "offline";
|
||||
nextStatuses.qobuz3 = "offline";
|
||||
nextStatuses.amazon1 = "offline";
|
||||
nextStatuses.lrclib = "offline";
|
||||
}
|
||||
nextStatuses.musicbrainz =
|
||||
musicBrainzStatus.status === "fulfilled" ? musicBrainzStatus.value : "offline";
|
||||
return {
|
||||
...current,
|
||||
statuses: nextStatuses,
|
||||
};
|
||||
});
|
||||
const results = await Promise.all(API_SOURCES.map(async (source) => ({
|
||||
id: source.id,
|
||||
status: await checkSourceStatus(source),
|
||||
})));
|
||||
|
||||
setApiStatusState((current) => ({
|
||||
...current,
|
||||
statuses: results.reduce<Record<string, ApiCheckStatus>>((acc, result) => {
|
||||
acc[result.id] = result.status;
|
||||
return acc;
|
||||
}, { ...current.statuses }),
|
||||
}));
|
||||
}
|
||||
finally {
|
||||
setApiStatusState((current) => ({
|
||||
@@ -145,5 +100,6 @@ export async function checkAllApiStatuses(forceRefresh: boolean = false): Promis
|
||||
activeCheckAll = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return activeCheckAll;
|
||||
}
|
||||
|
||||
Vendored
-2
@@ -53,8 +53,6 @@ export function DownloadTrack(arg1:main.DownloadRequest):Promise<main.DownloadRe
|
||||
|
||||
export function ExportFailedDownloads():Promise<string>;
|
||||
|
||||
export function FetchUnifiedAPIStatus(arg1:boolean):Promise<string>;
|
||||
|
||||
export function GetBrewPath():Promise<string>;
|
||||
|
||||
export function GetConfigPath():Promise<string>;
|
||||
|
||||
@@ -102,10 +102,6 @@ export function ExportFailedDownloads() {
|
||||
return window['go']['main']['App']['ExportFailedDownloads']();
|
||||
}
|
||||
|
||||
export function FetchUnifiedAPIStatus(arg1) {
|
||||
return window['go']['main']['App']['FetchUnifiedAPIStatus'](arg1);
|
||||
}
|
||||
|
||||
export function GetBrewPath() {
|
||||
return window['go']['main']['App']['GetBrewPath']();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user