This commit is contained in:
afkarxyz
2026-04-19 23:16:15 +07:00
parent 7346730be9
commit 30cbcf8ab1
28 changed files with 1364 additions and 834 deletions
+17 -5
View File
@@ -56,12 +56,11 @@ func (a *AmazonDownloader) DownloadFromAfkarXYZ(amazonURL, outputDir, quality st
return "", fmt.Errorf("failed to extract ASIN from URL: %s", amazonURL)
}
apiURL := fmt.Sprintf("https://amzn.afkarxyz.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 {
@@ -287,6 +288,16 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
mbMeta = result.Metadata
}
upc := ""
if spotifyURL != "" {
if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" {
if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" {
isrc = strings.TrimSpace(identifiers.ISRC)
}
upc = strings.TrimSpace(identifiers.UPC)
}
}
originalFileDir := filepath.Dir(filePath)
originalFileBase := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
@@ -406,6 +417,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
Separator: metadataSeparator,
Description: "https://github.com/spotbye/SpotiFLAC",
ISRC: isrc,
UPC: upc,
Genre: mbMeta.Genre,
}
+20
View File
@@ -0,0 +1,20 @@
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
}
+1 -1
View File
@@ -180,7 +180,7 @@ func queryMusicBrainzRecordings(client *http.Client, query string) (*MusicBrainz
return nil, err
}
req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( hi@afkarxyz.qzz.io )", AppVersion))
req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( support@spotbye.qzz.io )", AppVersion))
req.Header.Set("Accept", "application/json")
var lastErr error
+17
View File
@@ -0,0 +1,17 @@
package backend
const amazonMusicAPIBaseURL = "https://amazon.spotbye.qzz.io"
var defaultQobuzStreamAPIBaseURLs = []string{
"https://dab.yeet.su/api/stream?trackId=",
"https://dabmusic.xyz/api/stream?trackId=",
"https://qobuz.spotbye.qzz.io/api/track/",
}
func GetQobuzStreamAPIBaseURLs() []string {
return append([]string(nil), defaultQobuzStreamAPIBaseURLs...)
}
func GetAmazonMusicAPIBaseURL() string {
return amazonMusicAPIBaseURL
}
+20 -9
View File
@@ -139,7 +139,7 @@ func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
}
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
if strings.Contains(apiBase, "qbz.afkarxyz.qzz.io") {
if strings.Contains(apiBase, "qobuz.spotbye.qzz.io") {
return fmt.Sprintf("%s%d?quality=%s", apiBase, trackID, quality)
}
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
@@ -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://qbz.afkarxyz.qzz.io/api/track/",
})
standardAPIs := prioritizeProviders("qobuz", GetQobuzStreamAPIBaseURLs())
downloadFunc := func(qual string) (string, error) {
type Provider struct {
@@ -272,7 +273,12 @@ func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
Timeout: 5 * time.Minute,
}
resp, err := downloadClient.Get(url)
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("failed to create download request: %w", err)
}
resp, err := downloadClient.Do(req)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
@@ -306,7 +312,12 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error {
return fmt.Errorf("no cover URL provided")
}
resp, err := q.client.Get(coverURL)
req, err := NewRequestWithDefaultHeaders(http.MethodGet, coverURL, nil)
if err != nil {
return fmt.Errorf("failed to create cover request: %w", err)
}
resp, err := q.client.Do(req)
if err != nil {
return fmt.Errorf("failed to download cover: %w", err)
}
+1 -1
View File
@@ -21,7 +21,7 @@ const (
qobuzAPIBaseURL = "https://www.qobuz.com/api.json/0.2"
qobuzDefaultAPIAppID = "712109809"
qobuzDefaultAPIAppSecret = "589be88e4538daea11f509d29e4a23b1"
qobuzDefaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
qobuzDefaultUA = DefaultDownloaderUserAgent
qobuzCredentialsCacheFile = "qobuz-api-credentials.json"
qobuzCredentialsCacheTTL = 24 * time.Hour
qobuzCredentialsProbeTrackISRC = "USUM71703861"
+109 -349
View File
@@ -9,7 +9,6 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
@@ -49,17 +48,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 +67,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 +116,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 +179,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 +224,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)
}
@@ -417,12 +399,6 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e
}
func (t *TidalDownloader) DownloadByURL(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) {
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("directory error: %w", err)
}
}
fmt.Printf("Using Tidal URL: %s\n", tidalURL)
trackID, err := t.GetTrackIDFromURL(tidalURL)
@@ -434,25 +410,10 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
return "", fmt.Errorf("no track ID found")
}
artistName := spotifyArtistName
trackTitle := spotifyTrackName
albumTitle := spotifyAlbumName
artistNameForFile := sanitizeFilename(artistName)
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
if useFirstArtistOnly {
artistNameForFile = sanitizeFilename(GetFirstArtist(artistName))
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
outputFilename, alreadyExists, err := buildTidalOutputPath(outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyTrackNumber, spotifyDiscNumber, isrcOverride, useFirstArtistOnly)
if err != nil {
return "", err
}
trackTitleForFile := sanitizeFilename(trackTitle)
albumTitleForFile := sanitizeFilename(albumTitle)
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride)
outputFilename := filepath.Join(outputDir, filename)
outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting())
if alreadyExists {
fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024))
return "EXISTS:" + outputFilename, nil
@@ -460,119 +421,29 @@ 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 {
return "", fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err)
return outputFilename, fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err)
}
} else {
return "", err
return outputFilename, err
}
}
type mbResult struct {
ISRC string
Metadata Metadata
}
metaChan := make(chan mbResult, 1)
if embedGenre && spotifyURL != "" {
go func() {
res := mbResult{}
var isrc string
parts := strings.Split(spotifyURL, "/")
if len(parts) > 0 {
sID := strings.Split(parts[len(parts)-1], "?")[0]
if sID != "" {
client := NewSongLinkClient()
if val, err := client.GetISRC(sID); err == nil {
isrc = val
}
}
}
res.ISRC = isrc
if isrc != "" {
if ShouldSkipMusicBrainzMetadataFetch() {
fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.")
} else {
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
res.Metadata = fetchedMeta
fmt.Println("✓ MusicBrainz metadata fetched")
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
}
}
}
metaChan <- res
}()
} else {
close(metaChan)
}
fmt.Printf("Downloading to: %s\n", outputFilename)
if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
return "", err
cleanupTidalDownloadArtifacts(outputFilename)
return outputFilename, err
}
isrc := strings.TrimSpace(isrcOverride)
var mbMeta Metadata
if spotifyURL != "" {
result := <-metaChan
if isrc == "" {
isrc = result.ISRC
}
mbMeta = result.Metadata
}
fmt.Println("Adding metadata...")
coverPath := ""
if spotifyCoverURL != "" {
coverPath = outputFilename + ".cover.jpg"
coverClient := NewCoverClient()
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
coverPath = ""
} else {
defer os.Remove(coverPath)
fmt.Println("Spotify cover downloaded")
if t.apiURL != "" {
if err := RememberTidalAPIUsage(t.apiURL); err != nil {
fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
}
}
trackNumberToEmbed := spotifyTrackNumber
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1
}
metadata := Metadata{
Title: trackTitle,
Artist: artistName,
Album: albumTitle,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Comment: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Composer: spotifyComposer,
Separator: metadataSeparator,
Description: "https://github.com/spotbye/SpotiFLAC",
ISRC: isrc,
Genre: mbMeta.Genre,
}
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
fmt.Printf("Tagging failed: %v\n", err)
} else {
fmt.Println("Metadata saved")
}
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
fmt.Println("Done")
fmt.Println("✓ Downloaded successfully from Tidal")
@@ -580,17 +451,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)
}
}
fmt.Printf("Using Tidal URL: %s\n", tidalURL)
trackID, err := t.GetTrackIDFromURL(tidalURL)
@@ -602,146 +462,24 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
return "", fmt.Errorf("no track ID found")
}
artistName := spotifyArtistName
trackTitle := spotifyTrackName
albumTitle := spotifyAlbumName
artistNameForFile := sanitizeFilename(artistName)
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
if useFirstArtistOnly {
artistNameForFile = sanitizeFilename(GetFirstArtist(artistName))
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
outputFilename, alreadyExists, err := buildTidalOutputPath(outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyTrackNumber, spotifyDiscNumber, isrcOverride, useFirstArtistOnly)
if err != nil {
return "", err
}
trackTitleForFile := sanitizeFilename(trackTitle)
albumTitleForFile := sanitizeFilename(albumTitle)
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride)
outputFilename := filepath.Join(outputDir, filename)
outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting())
if alreadyExists {
fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024))
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
}
metaChan := make(chan mbResultFallback, 1)
if embedGenre && spotifyURL != "" {
go func() {
res := mbResultFallback{}
var isrc string
parts := strings.Split(spotifyURL, "/")
if len(parts) > 0 {
sID := strings.Split(parts[len(parts)-1], "?")[0]
if sID != "" {
client := NewSongLinkClient()
if val, err := client.GetISRC(sID); err == nil {
isrc = val
}
}
}
res.ISRC = isrc
if isrc != "" {
if ShouldSkipMusicBrainzMetadataFetch() {
fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.")
} else {
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
res.Metadata = fetchedMeta
fmt.Println("✓ MusicBrainz metadata fetched")
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
}
}
}
metaChan <- res
}()
} else {
close(metaChan)
}
fmt.Printf("Downloading to: %s\n", outputFilename)
downloader := NewTidalDownloader(successAPI)
if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil {
return "", err
successAPI, err := t.downloadWithRotatingAPIs(trackID, outputFilename, quality, allowFallback)
if err != nil {
cleanupTidalDownloadArtifacts(outputFilename)
return outputFilename, err
}
fmt.Printf("✓ Downloaded using API: %s\n", successAPI)
isrc := strings.TrimSpace(isrcOverride)
var mbMeta Metadata
if spotifyURL != "" {
result := <-metaChan
if isrc == "" {
isrc = result.ISRC
}
mbMeta = result.Metadata
}
fmt.Println("Adding metadata...")
coverPath := ""
if spotifyCoverURL != "" {
coverPath = outputFilename + ".cover.jpg"
coverClient := NewCoverClient()
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
coverPath = ""
} else {
defer os.Remove(coverPath)
fmt.Println("Spotify cover downloaded")
}
}
trackNumberToEmbed := spotifyTrackNumber
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1
}
metadata := Metadata{
Title: trackTitle,
Artist: artistName,
Album: albumTitle,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Comment: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Composer: spotifyComposer,
Separator: metadataSeparator,
Description: "https://github.com/spotbye/SpotiFLAC",
ISRC: isrc,
Genre: mbMeta.Genre,
}
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
fmt.Printf("Tagging failed: %v\n", err)
} else {
fmt.Println("Metadata saved")
}
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
fmt.Println("Done")
fmt.Println("✓ Downloaded successfully from Tidal")
@@ -752,7 +490,7 @@ func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameF
tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID)
if err != nil {
return "", fmt.Errorf("songlink couldn't find Tidal URL: %w", err)
return "", fmt.Errorf("songlink/songstats couldn't find Tidal URL: %w", err)
}
return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
@@ -920,79 +658,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 {
+238
View File
@@ -0,0 +1,238 @@
package backend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
const tidalAltDownloadAPIBaseURL = "https://tidal.spotbye.qzz.io/get"
type TidalAltAPIResponse struct {
Title string `json:"title"`
Link string `json:"link"`
}
func buildTidalOutputPath(outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyTrackNumber, spotifyDiscNumber int, isrcOverride string, useFirstArtistOnly bool) (string, bool, error) {
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", false, fmt.Errorf("directory error: %w", err)
}
}
artistNameForFile := sanitizeFilename(spotifyArtistName)
albumArtistForFile := sanitizeFilename(spotifyAlbumArtist)
if useFirstArtistOnly {
artistNameForFile = sanitizeFilename(GetFirstArtist(spotifyArtistName))
albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist))
}
trackTitleForFile := sanitizeFilename(spotifyTrackName)
albumTitleForFile := sanitizeFilename(spotifyAlbumName)
filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride)
outputFilename := filepath.Join(outputDir, filename)
outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting())
return outputFilename, alreadyExists, nil
}
func finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useSingleGenre bool, embedGenre bool) {
trackTitle := spotifyTrackName
artistName := spotifyArtistName
albumTitle := spotifyAlbumName
type mbResult struct {
ISRC string
Metadata Metadata
}
metaChan := make(chan mbResult, 1)
if embedGenre && spotifyURL != "" {
go func() {
res := mbResult{}
var isrc string
parts := strings.Split(spotifyURL, "/")
if len(parts) > 0 {
sID := strings.Split(parts[len(parts)-1], "?")[0]
if sID != "" {
client := NewSongLinkClient()
if val, err := client.GetISRC(sID); err == nil {
isrc = val
}
}
}
res.ISRC = isrc
if isrc != "" {
if ShouldSkipMusicBrainzMetadataFetch() {
fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.")
} else {
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil {
res.Metadata = fetchedMeta
fmt.Println("✓ MusicBrainz metadata fetched")
} else {
fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err)
}
}
}
metaChan <- res
}()
} else {
close(metaChan)
}
isrc := strings.TrimSpace(isrcOverride)
var mbMeta Metadata
if spotifyURL != "" {
result := <-metaChan
if isrc == "" {
isrc = result.ISRC
}
mbMeta = result.Metadata
}
upc := ""
if spotifyURL != "" {
if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" {
if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" {
isrc = strings.TrimSpace(identifiers.ISRC)
}
upc = strings.TrimSpace(identifiers.UPC)
}
}
fmt.Println("Adding metadata...")
coverPath := ""
if spotifyCoverURL != "" {
coverPath = outputFilename + ".cover.jpg"
coverClient := NewCoverClient()
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
coverPath = ""
} else {
defer os.Remove(coverPath)
fmt.Println("Spotify cover downloaded")
}
}
trackNumberToEmbed := spotifyTrackNumber
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1
}
metadata := Metadata{
Title: trackTitle,
Artist: artistName,
Album: albumTitle,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Comment: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Composer: spotifyComposer,
Separator: metadataSeparator,
Description: "https://github.com/spotbye/SpotiFLAC",
ISRC: isrc,
UPC: upc,
Genre: mbMeta.Genre,
}
if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil {
fmt.Printf("Tagging failed: %v\n", err)
} else {
fmt.Println("Metadata saved")
}
}
func (t *TidalDownloader) GetAltDownloadURLFromSpotify(spotifyTrackID string) (string, error) {
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
if spotifyTrackID == "" {
return "", fmt.Errorf("spotify track ID is required")
}
apiURL := fmt.Sprintf("%s/%s", tidalAltDownloadAPIBaseURL, spotifyTrackID)
fmt.Printf("Tidal Alt. API URL: %s\n", apiURL)
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create Tidal Alt. request: %w", err)
}
resp, err := t.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get Tidal Alt. download URL: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read Tidal Alt. response: %w", err)
}
if resp.StatusCode != http.StatusOK {
preview := strings.TrimSpace(string(body))
if len(preview) > 200 {
preview = preview[:200] + "..."
}
return "", fmt.Errorf("Tidal Alt. returned status %d: %s", resp.StatusCode, preview)
}
var payload TidalAltAPIResponse
if err := json.Unmarshal(body, &payload); err != nil {
return "", fmt.Errorf("failed to decode Tidal Alt. response: %w", err)
}
downloadURL := strings.TrimSpace(payload.Link)
if downloadURL == "" {
return "", fmt.Errorf("Tidal Alt. response did not include a download link")
}
fmt.Println("✓ Tidal Alt. download URL found")
return downloadURL, nil
}
func (t *TidalDownloader) DownloadAlt(spotifyTrackID, outputDir, 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, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
if spotifyTrackID == "" {
return "", fmt.Errorf("spotify track ID is required for Tidal Alt.")
}
outputFilename, alreadyExists, err := buildTidalOutputPath(outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyTrackNumber, spotifyDiscNumber, isrcOverride, useFirstArtistOnly)
if err != nil {
return "", err
}
if alreadyExists {
fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024))
return "EXISTS:" + outputFilename, nil
}
fmt.Printf("Using Tidal Alt. for Spotify track: %s\n", spotifyTrackID)
downloadURL, err := t.GetAltDownloadURLFromSpotify(spotifyTrackID)
if err != nil {
return outputFilename, err
}
fmt.Printf("Downloading to: %s\n", outputFilename)
if err := t.DownloadFile(downloadURL, outputFilename); err != nil {
cleanupTidalDownloadArtifacts(outputFilename)
return outputFilename, err
}
finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre)
fmt.Println("Done")
fmt.Println("✓ Downloaded successfully from Tidal Alt.")
return outputFilename, nil
}
+296
View File
@@ -0,0 +1,296 @@
package backend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const (
tidalAPIListGistURL = "https://gist.githubusercontent.com/afkarxyz/2ce772b943321b9448b454f39403ce25/raw"
tidalAPIListCacheFile = "tidal-api-urls.json"
)
type tidalAPIListCache struct {
URLs []string `json:"urls"`
LastUsedURL string `json:"last_used_url,omitempty"`
UpdatedAt int64 `json:"updated_at_unix"`
Source string `json:"source,omitempty"`
}
var (
tidalAPIListMu sync.Mutex
tidalAPIListState *tidalAPIListCache
)
func loadTidalAPIListStateLocked() (*tidalAPIListCache, error) {
if tidalAPIListState != nil {
return cloneTidalAPIListState(tidalAPIListState), nil
}
appDir, err := EnsureAppDir()
if err != nil {
return nil, err
}
cachePath := filepath.Join(appDir, tidalAPIListCacheFile)
data, err := os.ReadFile(cachePath)
if err != nil {
if os.IsNotExist(err) {
state := &tidalAPIListCache{}
tidalAPIListState = cloneTidalAPIListState(state)
return cloneTidalAPIListState(state), nil
}
return nil, fmt.Errorf("failed to read tidal api cache: %w", err)
}
var state tidalAPIListCache
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("failed to parse tidal api cache: %w", err)
}
state.URLs = normalizeTidalAPIURLs(state.URLs)
tidalAPIListState = cloneTidalAPIListState(&state)
return cloneTidalAPIListState(&state), nil
}
func saveTidalAPIListStateLocked(state *tidalAPIListCache) error {
appDir, err := EnsureAppDir()
if err != nil {
return err
}
cachePath := filepath.Join(appDir, tidalAPIListCacheFile)
payload, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("failed to encode tidal api cache: %w", err)
}
if err := os.WriteFile(cachePath, payload, 0o644); err != nil {
return fmt.Errorf("failed to write tidal api cache: %w", err)
}
tidalAPIListState = cloneTidalAPIListState(state)
return nil
}
func cloneTidalAPIListState(state *tidalAPIListCache) *tidalAPIListCache {
if state == nil {
return nil
}
return &tidalAPIListCache{
URLs: append([]string(nil), state.URLs...),
LastUsedURL: state.LastUsedURL,
UpdatedAt: state.UpdatedAt,
Source: state.Source,
}
}
func normalizeTidalAPIURLs(urls []string) []string {
seen := make(map[string]struct{}, len(urls))
normalized := make([]string, 0, len(urls))
for _, rawURL := range urls {
url := strings.TrimRight(strings.TrimSpace(rawURL), "/")
if url == "" {
continue
}
if _, exists := seen[url]; exists {
continue
}
seen[url] = struct{}{}
normalized = append(normalized, url)
}
return normalized
}
func fetchTidalAPIURLsFromGist() ([]string, error) {
client := &http.Client{Timeout: 12 * time.Second}
req, err := NewRequestWithDefaultHeaders(http.MethodGet, tidalAPIListGistURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create tidal api gist request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch tidal api gist: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
preview, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
return nil, fmt.Errorf("tidal api gist returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(preview)))
}
var urls []string
if err := json.NewDecoder(resp.Body).Decode(&urls); err != nil {
return nil, fmt.Errorf("failed to decode tidal api gist: %w", err)
}
urls = normalizeTidalAPIURLs(urls)
if len(urls) == 0 {
return nil, fmt.Errorf("tidal api gist returned no valid urls")
}
return urls, nil
}
func PrimeTidalAPIList() error {
_, err := RefreshTidalAPIList(true)
if err != nil {
fmt.Printf("Warning: failed to refresh Tidal API list from gist: %v\n", err)
}
tidalAPIListMu.Lock()
defer tidalAPIListMu.Unlock()
state, loadErr := loadTidalAPIListStateLocked()
if loadErr != nil {
return loadErr
}
if len(state.URLs) == 0 {
return fmt.Errorf("tidal api cache is empty")
}
if state.UpdatedAt == 0 {
state.UpdatedAt = time.Now().Unix()
return saveTidalAPIListStateLocked(state)
}
return nil
}
func RefreshTidalAPIList(force bool) ([]string, error) {
tidalAPIListMu.Lock()
defer tidalAPIListMu.Unlock()
state, err := loadTidalAPIListStateLocked()
if err != nil {
state = &tidalAPIListCache{}
}
if !force && len(state.URLs) > 0 {
return append([]string(nil), state.URLs...), nil
}
urls, fetchErr := fetchTidalAPIURLsFromGist()
if fetchErr != nil {
if len(state.URLs) > 0 {
return append([]string(nil), state.URLs...), fetchErr
}
return nil, fetchErr
}
state.URLs = urls
state.UpdatedAt = time.Now().Unix()
state.Source = "gist"
if !containsString(state.URLs, state.LastUsedURL) {
state.LastUsedURL = ""
}
if err := saveTidalAPIListStateLocked(state); err != nil {
return append([]string(nil), state.URLs...), err
}
return append([]string(nil), state.URLs...), nil
}
func GetTidalAPIList() ([]string, error) {
tidalAPIListMu.Lock()
defer tidalAPIListMu.Unlock()
state, err := loadTidalAPIListStateLocked()
if err != nil {
return nil, err
}
if len(state.URLs) == 0 {
return nil, fmt.Errorf("no cached tidal api urls")
}
return append([]string(nil), state.URLs...), nil
}
func GetRotatedTidalAPIList() ([]string, error) {
tidalAPIListMu.Lock()
defer tidalAPIListMu.Unlock()
state, err := loadTidalAPIListStateLocked()
if err != nil {
return nil, err
}
urls := state.URLs
if len(urls) == 0 {
return nil, fmt.Errorf("no cached tidal api urls")
}
return rotateTidalAPIURLs(urls, state.LastUsedURL), nil
}
func RememberTidalAPIUsage(apiURL string) error {
tidalAPIListMu.Lock()
defer tidalAPIListMu.Unlock()
state, err := loadTidalAPIListStateLocked()
if err != nil {
return err
}
state.LastUsedURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
if state.UpdatedAt == 0 {
state.UpdatedAt = time.Now().Unix()
}
return saveTidalAPIListStateLocked(state)
}
func rotateTidalAPIURLs(urls []string, lastUsedURL string) []string {
normalized := normalizeTidalAPIURLs(urls)
if len(normalized) < 2 {
return normalized
}
lastUsedURL = strings.TrimRight(strings.TrimSpace(lastUsedURL), "/")
if lastUsedURL == "" {
return normalized
}
lastIndex := -1
for idx, candidate := range normalized {
if candidate == lastUsedURL {
lastIndex = idx
break
}
}
if lastIndex == -1 {
return normalized
}
rotated := make([]string, 0, len(normalized))
rotated = append(rotated, normalized[lastIndex+1:]...)
rotated = append(rotated, normalized[:lastIndex+1]...)
return rotated
}
func containsString(values []string, target string) bool {
target = strings.TrimRight(strings.TrimSpace(target), "/")
for _, value := range values {
if strings.TrimRight(strings.TrimSpace(value), "/") == target {
return true
}
}
return false
}