This commit is contained in:
afkarxyz
2026-04-02 18:42:35 +07:00
parent f13359df7f
commit 2bc2c0bf03
48 changed files with 3409 additions and 1038 deletions
+1
View File
@@ -387,6 +387,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Comment: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC",
+99
View File
@@ -1,6 +1,8 @@
package backend
import (
"bytes"
"encoding/base64"
"fmt"
"os"
"os/exec"
@@ -24,6 +26,16 @@ type AnalysisResult struct {
RMSLevel float64 `json:"rms_level"`
}
type AnalysisDecodeResponse struct {
PCMBase64 string `json:"pcm_base64"`
SampleRate uint32 `json:"sample_rate"`
Channels uint8 `json:"channels"`
BitsPerSample uint8 `json:"bits_per_sample"`
Duration float64 `json:"duration"`
BitrateKbps int `json:"bitrate_kbps,omitempty"`
BitDepth string `json:"bit_depth,omitempty"`
}
func GetTrackMetadata(filepath string) (*AnalysisResult, error) {
if !fileExists(filepath) {
return nil, fmt.Errorf("file does not exist: %s", filepath)
@@ -113,3 +125,90 @@ func GetMetadataWithFFprobe(filePath string) (*AnalysisResult, error) {
return res, nil
}
func DecodeAudioForAnalysis(filePath string) (*AnalysisDecodeResponse, error) {
metadata, err := GetTrackMetadata(filePath)
if err != nil {
return nil, err
}
pcmBase64, err := extractAnalysisPCMBase64(filePath)
if err != nil {
return nil, err
}
resp := &AnalysisDecodeResponse{
PCMBase64: pcmBase64,
SampleRate: metadata.SampleRate,
Channels: metadata.Channels,
BitsPerSample: metadata.BitsPerSample,
Duration: metadata.Duration,
BitDepth: metadata.BitDepth,
}
if metadata.Bitrate > 0 {
resp.BitrateKbps = metadata.Bitrate / 1000
}
return resp, nil
}
func extractAnalysisPCMBase64(filePath string) (string, error) {
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return "", err
}
argSets := [][]string{
{
"-v", "error",
"-i", filePath,
"-vn",
"-map", "0:a:0",
"-af", "pan=mono|c0=c0",
"-f", "s16le",
"-acodec", "pcm_s16le",
"pipe:1",
},
{
"-v", "error",
"-i", filePath,
"-vn",
"-map", "0:a:0",
"-ac", "1",
"-f", "s16le",
"-acodec", "pcm_s16le",
"pipe:1",
},
}
var lastErr error
for _, args := range argSets {
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd := exec.Command(ffmpegPath, args...)
setHideWindow(cmd)
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
lastErr = fmt.Errorf("ffmpeg analysis decode failed: %w - %s", err, strings.TrimSpace(stderr.String()))
continue
}
if stdout.Len() == 0 {
lastErr = fmt.Errorf("ffmpeg analysis decode returned empty PCM output")
continue
}
return base64.StdEncoding.EncodeToString(stdout.Bytes()), nil
}
if lastErr != nil {
return "", lastErr
}
return "", fmt.Errorf("ffmpeg analysis decode failed")
}
+86
View File
@@ -1,8 +1,10 @@
package backend
import (
"encoding/json"
"os"
"path/filepath"
"strings"
)
func GetDefaultMusicPath() string {
@@ -15,3 +17,87 @@ func GetDefaultMusicPath() string {
return filepath.Join(homeDir, "Music")
}
func GetConfigPath() (string, error) {
dir, err := EnsureAppDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "config.json"), nil
}
func LoadConfigSettings() (map[string]interface{}, error) {
configPath, err := GetConfigPath()
if err != nil {
return nil, err
}
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return nil, nil
}
data, err := os.ReadFile(configPath)
if err != nil {
return nil, err
}
var settings map[string]interface{}
if err := json.Unmarshal(data, &settings); err != nil {
return nil, err
}
return settings, nil
}
func GetSpotFetchAPISettings() (bool, string) {
settings, err := LoadConfigSettings()
if err != nil || settings == nil {
return false, ""
}
useAPI, _ := settings["useSpotFetchAPI"].(bool)
if !useAPI {
return false, ""
}
apiURL, _ := settings["spotFetchAPIUrl"].(string)
if apiURL == "" {
apiURL = "https://sp.afkarxyz.qzz.io/api"
}
return true, apiURL
}
func GetLinkResolverSetting() string {
settings, err := LoadConfigSettings()
if err != nil || settings == nil {
return linkResolverProviderDeezerSongLink
}
resolver, _ := settings["linkResolver"].(string)
switch strings.TrimSpace(strings.ToLower(resolver)) {
case "songlink", linkResolverProviderDeezerSongLink:
return linkResolverProviderDeezerSongLink
case "songstats":
return linkResolverProviderSongstats
case "":
return linkResolverProviderDeezerSongLink
default:
return linkResolverProviderDeezerSongLink
}
}
func GetLinkResolverAllowFallback() bool {
settings, err := LoadConfigSettings()
if err != nil || settings == nil {
return true
}
allowFallback, ok := settings["allowResolverFallback"].(bool)
if !ok {
return true
}
return allowFallback
}
+18 -1
View File
@@ -58,7 +58,7 @@ func ValidateExecutable(path string) error {
return nil
}
func GetFFmpegDir() (string, error) {
func GetAppDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
@@ -66,6 +66,23 @@ func GetFFmpegDir() (string, error) {
return filepath.Join(homeDir, ".spotiflac"), nil
}
func EnsureAppDir() (string, error) {
appDir, err := GetAppDir()
if err != nil {
return "", err
}
if err := os.MkdirAll(appDir, 0o755); err != nil {
return "", fmt.Errorf("failed to create app directory: %w", err)
}
return appDir, nil
}
func GetFFmpegDir() (string, error) {
return EnsureAppDir()
}
func GetFFmpegPath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
+6 -2
View File
@@ -11,8 +11,8 @@ func SelectMultipleFiles(ctx context.Context) ([]string, error) {
Title: "Select Audio Files",
Filters: []runtime.FileFilter{
{
DisplayName: "Audio Files (*.mp3, *.m4a, *.flac)",
Pattern: "*.mp3;*.m4a;*.flac",
DisplayName: "Audio Files (*.mp3, *.m4a, *.flac, *.aac)",
Pattern: "*.mp3;*.m4a;*.flac;*.aac",
},
{
DisplayName: "MP3 Files (*.mp3)",
@@ -26,6 +26,10 @@ func SelectMultipleFiles(ctx context.Context) ([]string, error) {
DisplayName: "FLAC Files (*.flac)",
Pattern: "*.flac",
},
{
DisplayName: "AAC Files (*.aac)",
Pattern: "*.aac",
},
{
DisplayName: "All Files (*.*)",
Pattern: "*.*",
+1 -1
View File
@@ -94,7 +94,7 @@ func ListAudioFiles(dirPath string) ([]FileInfo, error) {
}
ext := strings.ToLower(filepath.Ext(path))
if ext == ".flac" || ext == ".mp3" || ext == ".m4a" {
if ext == ".flac" || ext == ".mp3" || ext == ".m4a" || ext == ".aac" {
result = append(result, FileInfo{
Name: info.Name(),
Path: path,
+8 -18
View File
@@ -1,9 +1,7 @@
package backend
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
@@ -139,25 +137,17 @@ func NormalizePath(folderPath string) string {
}
func GetSeparator() string {
dir, err := GetFFmpegDir()
if err != nil {
return "; "
}
configPath := filepath.Join(dir, "config.json")
data, err := os.ReadFile(configPath)
if err != nil {
settings, err := LoadConfigSettings()
if err != nil || settings == nil {
return "; "
}
var settings map[string]interface{}
if err := json.Unmarshal(data, &settings); err == nil {
if sep, ok := settings["separator"].(string); ok {
if sep == "comma" {
return ", "
}
if sep == "semicolon" {
return "; "
}
if sep, ok := settings["separator"].(string); ok {
if sep == "comma" {
return ", "
}
if sep == "semicolon" {
return "; "
}
}
return "; "
+1 -5
View File
@@ -3,7 +3,6 @@ package backend
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"time"
@@ -35,13 +34,10 @@ const (
func InitHistoryDB(appName string) error {
appDir, err := GetFFmpegDir()
appDir, err := EnsureAppDir()
if err != nil {
return err
}
if _, err := os.Stat(appDir); os.IsNotExist(err) {
os.MkdirAll(appDir, 0755)
}
dbPath := filepath.Join(appDir, "history.db")
db, err := bolt.Open(dbPath, 0600, &bolt.Options{Timeout: 1 * time.Second})
+137
View File
@@ -0,0 +1,137 @@
package backend
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"sync"
"time"
bolt "go.etcd.io/bbolt"
)
const (
isrcCacheDBFile = "isrc_cache.db"
isrcCacheBucket = "SpotifyTrackISRC"
)
type isrcCacheEntry struct {
TrackID string `json:"track_id"`
ISRC string `json:"isrc"`
UpdatedAt int64 `json:"updated_at"`
}
var (
isrcCacheDB *bolt.DB
isrcCacheDBMu sync.Mutex
)
func InitISRCCacheDB() error {
isrcCacheDBMu.Lock()
defer isrcCacheDBMu.Unlock()
if isrcCacheDB != nil {
return nil
}
appDir, err := EnsureAppDir()
if err != nil {
return err
}
dbPath := filepath.Join(appDir, isrcCacheDBFile)
db, err := bolt.Open(dbPath, 0o600, &bolt.Options{Timeout: 1 * time.Second})
if err != nil {
return err
}
if err := db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists([]byte(isrcCacheBucket))
return err
}); err != nil {
db.Close()
return err
}
isrcCacheDB = db
return nil
}
func CloseISRCCacheDB() {
isrcCacheDBMu.Lock()
defer isrcCacheDBMu.Unlock()
if isrcCacheDB != nil {
_ = isrcCacheDB.Close()
isrcCacheDB = nil
}
}
func GetCachedISRC(trackID string) (string, error) {
normalizedTrackID := strings.TrimSpace(trackID)
if normalizedTrackID == "" {
return "", nil
}
if err := InitISRCCacheDB(); err != nil {
return "", err
}
var cachedISRC string
err := isrcCacheDB.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(isrcCacheBucket))
if bucket == nil {
return nil
}
value := bucket.Get([]byte(normalizedTrackID))
if len(value) == 0 {
return nil
}
var entry isrcCacheEntry
if err := json.Unmarshal(value, &entry); err != nil {
return err
}
cachedISRC = strings.ToUpper(strings.TrimSpace(entry.ISRC))
return nil
})
if err != nil {
return "", err
}
return cachedISRC, nil
}
func PutCachedISRC(trackID string, isrc string) error {
normalizedTrackID := strings.TrimSpace(trackID)
normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
if normalizedTrackID == "" || normalizedISRC == "" {
return nil
}
if err := InitISRCCacheDB(); err != nil {
return err
}
entry := isrcCacheEntry{
TrackID: normalizedTrackID,
ISRC: normalizedISRC,
UpdatedAt: time.Now().Unix(),
}
payload, err := json.Marshal(entry)
if err != nil {
return fmt.Errorf("failed to encode ISRC cache entry: %w", err)
}
return isrcCacheDB.Update(func(tx *bolt.Tx) error {
bucket, err := tx.CreateBucketIfNotExists([]byte(isrcCacheBucket))
if err != nil {
return err
}
return bucket.Put([]byte(normalizedTrackID), payload)
})
}
+572
View File
@@ -0,0 +1,572 @@
package backend
import (
"crypto/hmac"
"crypto/sha1"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
const (
spotifyServerTimeURL = "https://open.spotify.com/api/server-time"
spotifySessionTokenURL = "https://open.spotify.com/api/token"
spotifyTOTPSecretsURL = "https://git.gay/thereallo/totp-secrets/raw/branch/main/secrets/secretDict.json"
spotifyGIDMetadataURL = "https://spclient.wg.spotify.com/metadata/4/%s/%s?market=from_token"
spotifyTOTPPeriod = 30
spotifyTOTPDigits = 6
spotifyBase62Alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
spotifyTokenCacheFile = ".isrc-finder-token.json"
spotifySecretsCacheFile = "spotify-secret-dict-cache.json"
spotifySecretsCacheTTL = 24 * time.Hour
)
var spotifyAnonymousTokenMu sync.Mutex
type spotifyAnonymousToken struct {
AccessToken string `json:"accessToken"`
AccessTokenExpirationTimestampMs int64 `json:"accessTokenExpirationTimestampMs"`
}
type spotifyServerTimeResponse struct {
ServerTime int64 `json:"serverTime"`
}
type spotifySecretsCache struct {
FetchedAtUnix int64 `json:"fetched_at_unix"`
Secrets map[string][]int `json:"secrets"`
}
type spotifyTrackRawData struct {
ExternalID []struct {
Type string `json:"type"`
ID string `json:"id"`
} `json:"external_id"`
}
type spotFetchISRCResponse struct {
Input string `json:"input"`
TrackID string `json:"track_id"`
GID string `json:"gid"`
CanonicalURI string `json:"canonical_uri"`
Name string `json:"name"`
Artists []string `json:"artists"`
AlbumName string `json:"album_name"`
ReleaseDate string `json:"release_date"`
Label string `json:"label"`
ISRC string `json:"isrc"`
}
func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error) {
normalizedTrackID, err := extractSpotifyTrackID(spotifyTrackID)
if err != nil {
return "", err
}
cachedISRC, err := GetCachedISRC(normalizedTrackID)
if err != nil {
fmt.Printf("Warning: failed to read ISRC cache: %v\n", err)
} else if cachedISRC != "" {
fmt.Printf("Found ISRC in cache: %s\n", cachedISRC)
return cachedISRC, nil
}
useSpotFetchAPI, spotFetchAPIURL := GetSpotFetchAPISettings()
if useSpotFetchAPI {
isrc, resolvedTrackID, err := s.lookupSpotifyISRCViaSpotFetchAPI(normalizedTrackID, spotFetchAPIURL)
if err == nil && isrc != "" {
fmt.Printf("Found ISRC via SpotFetch API: %s\n", isrc)
cacheResolvedSpotifyTrackISRC(normalizedTrackID, resolvedTrackID, isrc)
return isrc, nil
}
if err != nil {
fmt.Printf("Warning: SpotFetch ISRC lookup failed, falling back to Spotify metadata: %v\n", err)
}
}
payload, metadataErr := fetchSpotifyTrackRawData(s.client, normalizedTrackID)
if metadataErr == nil {
isrc, extractErr := extractSpotifyTrackISRC(payload)
if extractErr == nil {
fmt.Printf("Found ISRC via Spotify metadata: %s\n", isrc)
cacheResolvedSpotifyTrackISRC(normalizedTrackID, "", isrc)
return isrc, nil
}
metadataErr = extractErr
}
if metadataErr != nil {
fmt.Printf("Warning: Spotify metadata ISRC lookup failed, falling back to Soundplate: %v\n", metadataErr)
}
isrc, resolvedTrackID, soundplateErr := s.lookupSpotifyISRCViaSoundplate(normalizedTrackID)
if soundplateErr == nil && isrc != "" {
fmt.Printf("Found ISRC via Soundplate: %s\n", isrc)
cacheResolvedSpotifyTrackISRC(normalizedTrackID, resolvedTrackID, isrc)
return isrc, nil
}
if metadataErr != nil && soundplateErr != nil {
return "", fmt.Errorf("spotify metadata lookup failed: %v | soundplate lookup failed: %w", metadataErr, soundplateErr)
}
if soundplateErr != nil {
return "", soundplateErr
}
return "", metadataErr
}
func cacheResolvedSpotifyTrackISRC(trackID string, resolvedTrackID string, isrc string) {
if err := PutCachedISRC(trackID, isrc); err != nil {
fmt.Printf("Warning: failed to write ISRC cache: %v\n", err)
}
if resolvedTrackID != "" && resolvedTrackID != trackID {
if err := PutCachedISRC(resolvedTrackID, isrc); err != nil {
fmt.Printf("Warning: failed to write ISRC cache for resolved track ID: %v\n", err)
}
}
}
func (s *SongLinkClient) lookupSpotifyISRCViaSpotFetchAPI(spotifyTrackID string, apiBaseURL string) (string, string, error) {
normalizedTrackID := strings.TrimSpace(spotifyTrackID)
baseURL := strings.TrimRight(strings.TrimSpace(apiBaseURL), "/")
if normalizedTrackID == "" {
return "", "", fmt.Errorf("spotify track ID is required")
}
if baseURL == "" {
return "", "", fmt.Errorf("spotfetch api url is required")
}
requestURL := fmt.Sprintf("%s/isrc/%s", baseURL, url.PathEscape(normalizedTrackID))
req, err := http.NewRequest(http.MethodGet, requestURL, nil)
if err != nil {
return "", "", fmt.Errorf("failed to create SpotFetch ISRC request: %w", err)
}
req.Header.Set("User-Agent", songLinkUserAgent)
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", "", fmt.Errorf("SpotFetch ISRC request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyPreview, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return "", "", fmt.Errorf("SpotFetch ISRC returned status %d (%s)", resp.StatusCode, strings.TrimSpace(string(bodyPreview)))
}
var payload spotFetchISRCResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return "", "", fmt.Errorf("failed to decode SpotFetch ISRC response: %w", err)
}
isrc := firstISRCMatch(payload.ISRC)
if isrc == "" {
return "", "", fmt.Errorf("ISRC missing in SpotFetch response")
}
return isrc, strings.TrimSpace(payload.TrackID), nil
}
func requestSpotifyBytes(client *http.Client, targetURL string, headers map[string]string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, targetURL, nil)
if err != nil {
return nil, err
}
for key, value := range headers {
req.Header.Set(key, value)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
details := strings.TrimSpace(string(body))
if details == "" {
details = resp.Status
}
return nil, fmt.Errorf("request failed: %s", details)
}
return body, nil
}
func requestSpotifyJSON(client *http.Client, targetURL string, headers map[string]string, target interface{}) error {
body, err := requestSpotifyBytes(client, targetURL, headers)
if err != nil {
return err
}
if err := json.Unmarshal(body, target); err != nil {
return fmt.Errorf("failed to parse JSON response: %w", err)
}
return nil
}
func loadSpotifyCachedToken() (*spotifyAnonymousToken, error) {
cachePath, err := spotifyTokenCachePath()
if err != nil {
return nil, err
}
body, err := os.ReadFile(cachePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("failed to read token cache: %w", err)
}
var token spotifyAnonymousToken
if err := json.Unmarshal(body, &token); err != nil {
return nil, fmt.Errorf("failed to read token cache: %w", err)
}
return &token, nil
}
func saveSpotifyCachedToken(token *spotifyAnonymousToken) error {
cachePath, err := spotifyTokenCachePath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil {
return fmt.Errorf("failed to create token cache directory: %w", err)
}
body, err := json.MarshalIndent(token, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(cachePath, body, 0o644); err != nil {
return fmt.Errorf("failed to write token cache: %w", err)
}
return nil
}
func loadSpotifyCachedSecrets() (*spotifySecretsCache, error) {
cachePath, err := spotifySecretsCachePath()
if err != nil {
return nil, err
}
body, err := os.ReadFile(cachePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("failed to read secrets cache: %w", err)
}
var cache spotifySecretsCache
if err := json.Unmarshal(body, &cache); err != nil {
return nil, fmt.Errorf("failed to parse secrets cache: %w", err)
}
return &cache, nil
}
func saveSpotifyCachedSecrets(cache *spotifySecretsCache) error {
cachePath, err := spotifySecretsCachePath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil {
return fmt.Errorf("failed to create secrets cache directory: %w", err)
}
body, err := json.MarshalIndent(cache, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(cachePath, body, 0o644); err != nil {
return fmt.Errorf("failed to write secrets cache: %w", err)
}
return nil
}
func spotifyTokenCachePath() (string, error) {
appDir, err := EnsureAppDir()
if err != nil {
return "", err
}
return filepath.Join(appDir, spotifyTokenCacheFile), nil
}
func spotifySecretsCachePath() (string, error) {
appDir, err := EnsureAppDir()
if err != nil {
return "", err
}
return filepath.Join(appDir, spotifySecretsCacheFile), nil
}
func spotifyTokenIsValid(token *spotifyAnonymousToken) bool {
if token == nil || token.AccessToken == "" || token.AccessTokenExpirationTimestampMs == 0 {
return false
}
return time.Now().UnixMilli() < token.AccessTokenExpirationTimestampMs-30_000
}
func spotifySecretsCacheIsValid(cache *spotifySecretsCache) bool {
if cache == nil || cache.FetchedAtUnix == 0 || len(cache.Secrets) == 0 {
return false
}
return time.Since(time.Unix(cache.FetchedAtUnix, 0)) < spotifySecretsCacheTTL
}
func deriveSpotifyTOTPSecret(ciphertext []int) []byte {
var builder strings.Builder
for index, value := range ciphertext {
builder.WriteString(strconv.Itoa(value ^ ((index % 33) + 9)))
}
return []byte(builder.String())
}
func generateSpotifyTOTP(secret []byte, timestampMs int64) string {
counter := timestampMs / 1000 / spotifyTOTPPeriod
counterBytes := make([]byte, 8)
binary.BigEndian.PutUint64(counterBytes, uint64(counter))
mac := hmac.New(sha1.New, secret)
mac.Write(counterBytes)
digest := mac.Sum(nil)
offset := digest[len(digest)-1] & 0x0f
binaryCode := (int(digest[offset])&0x7f)<<24 |
(int(digest[offset+1])&0xff)<<16 |
(int(digest[offset+2])&0xff)<<8 |
(int(digest[offset+3]) & 0xff)
modulo := 1
for i := 0; i < spotifyTOTPDigits; i++ {
modulo *= 10
}
return fmt.Sprintf("%0*d", spotifyTOTPDigits, binaryCode%modulo)
}
func requestSpotifyAnonymousAccessToken(client *http.Client) (string, error) {
spotifyAnonymousTokenMu.Lock()
defer spotifyAnonymousTokenMu.Unlock()
cachedToken, err := loadSpotifyCachedToken()
if err != nil {
return "", err
}
if spotifyTokenIsValid(cachedToken) {
return cachedToken.AccessToken, nil
}
var serverTime spotifyServerTimeResponse
if err := requestSpotifyJSON(client, spotifyServerTimeURL, nil, &serverTime); err != nil {
return "", err
}
var secrets map[string][]int
cachedSecrets, err := loadSpotifyCachedSecrets()
if err != nil {
fmt.Printf("Warning: failed to read Spotify secrets cache: %v\n", err)
}
if spotifySecretsCacheIsValid(cachedSecrets) {
secrets = cachedSecrets.Secrets
} else {
if err := requestSpotifyJSON(client, spotifyTOTPSecretsURL, nil, &secrets); err != nil {
if cachedSecrets != nil && len(cachedSecrets.Secrets) > 0 {
fmt.Printf("Warning: failed to refresh Spotify secrets cache, using stale cache: %v\n", err)
secrets = cachedSecrets.Secrets
} else {
return "", err
}
} else {
cache := &spotifySecretsCache{
FetchedAtUnix: time.Now().Unix(),
Secrets: secrets,
}
if err := saveSpotifyCachedSecrets(cache); err != nil {
fmt.Printf("Warning: failed to write Spotify secrets cache: %v\n", err)
}
}
}
version, err := latestSpotifySecretVersion(secrets)
if err != nil {
return "", err
}
secret := deriveSpotifyTOTPSecret(secrets[version])
generatedTOTP := generateSpotifyTOTP(secret, serverTime.ServerTime*1000)
query := url.Values{
"reason": {"init"},
"productType": {"web-player"},
"totp": {generatedTOTP},
"totpServer": {generatedTOTP},
"totpVer": {version},
}
var token spotifyAnonymousToken
if err := requestSpotifyJSON(client, spotifySessionTokenURL+"?"+query.Encode(), nil, &token); err != nil {
return "", err
}
if err := saveSpotifyCachedToken(&token); err != nil {
return "", err
}
return token.AccessToken, nil
}
func latestSpotifySecretVersion(secrets map[string][]int) (string, error) {
var (
bestVersion string
bestNumber int
)
for version := range secrets {
number, err := strconv.Atoi(version)
if err != nil {
return "", fmt.Errorf("invalid secret version %q: %w", version, err)
}
if bestVersion == "" || number > bestNumber {
bestVersion = version
bestNumber = number
}
}
if bestVersion == "" {
return "", errors.New("no TOTP secret versions available")
}
return bestVersion, nil
}
func extractSpotifyTrackID(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", errors.New("track input is required")
}
if strings.HasPrefix(value, "spotify:track:") {
return value[strings.LastIndex(value, ":")+1:], nil
}
parsed, err := url.Parse(value)
if err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") {
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
if len(parts) >= 2 && parts[0] == "track" {
return parts[1], nil
}
return "", errors.New("expected URL like https://open.spotify.com/track/<id>")
}
if len(value) == 22 {
return value, nil
}
return "", errors.New("track must be a Spotify track ID, URL, or URI")
}
func spotifyTrackIDToGID(trackID string) (string, error) {
if trackID == "" {
return "", errors.New("track ID is empty")
}
value := big.NewInt(0)
base := big.NewInt(62)
for _, char := range trackID {
index := strings.IndexRune(spotifyBase62Alphabet, char)
if index < 0 {
return "", fmt.Errorf("invalid base62 character: %q", string(char))
}
value.Mul(value, base)
value.Add(value, big.NewInt(int64(index)))
}
hexValue := value.Text(16)
if len(hexValue) < 32 {
hexValue = strings.Repeat("0", 32-len(hexValue)) + hexValue
}
return hexValue, nil
}
func fetchSpotifyTrackRawData(client *http.Client, trackID string) ([]byte, error) {
accessToken, err := requestSpotifyAnonymousAccessToken(client)
if err != nil {
return nil, err
}
gid, err := spotifyTrackIDToGID(trackID)
if err != nil {
return nil, err
}
return requestSpotifyBytes(
client,
fmt.Sprintf(spotifyGIDMetadataURL, "track", gid),
map[string]string{
"authorization": "Bearer " + accessToken,
"accept": "application/json",
},
)
}
func extractSpotifyTrackISRC(payload []byte) (string, error) {
var track spotifyTrackRawData
if err := json.Unmarshal(payload, &track); err != nil {
return "", fmt.Errorf("failed to decode Spotify track metadata: %w", err)
}
for _, externalID := range track.ExternalID {
if strings.EqualFold(strings.TrimSpace(externalID.Type), "isrc") {
if isrc := firstISRCMatch(externalID.ID); isrc != "" {
return isrc, nil
}
}
}
if fallbackISRC := firstISRCMatch(string(payload)); fallbackISRC != "" {
return fallbackISRC, nil
}
return "", fmt.Errorf("ISRC not found in Spotify track metadata")
}
+154
View File
@@ -0,0 +1,154 @@
package backend
import (
"errors"
"fmt"
"strings"
)
type resolvedTrackLinks struct {
TidalURL string
AmazonURL string
DeezerURL string
ISRC string
}
const (
linkResolverProviderSongstats = "songstats"
linkResolverProviderDeezerSongLink = "deezer-songlink"
)
func (s *SongLinkClient) resolveSpotifyTrackLinks(spotifyTrackID string, region string) (*resolvedTrackLinks, error) {
links := &resolvedTrackLinks{}
var attempts []string
isrc, err := s.lookupSpotifyISRC(spotifyTrackID)
if err != nil {
attempts = append(attempts, fmt.Sprintf("spotify isrc: %v", err))
} else {
links.ISRC = isrc
}
if links.ISRC != "" {
resolvers := orderedLinkResolvers()
for _, resolver := range resolvers {
switch resolver {
case linkResolverProviderSongstats:
addedData, songstatsErr := s.resolveLinksViaSongstats(links)
if songstatsErr != nil {
attempts = append(attempts, fmt.Sprintf("songstats: %v", songstatsErr))
} else if addedData {
fmt.Println("Using Songstats as configured link resolver")
}
case linkResolverProviderDeezerSongLink:
addedData, deezerSongLinkErr := s.resolveLinksViaDeezerSongLink(links, region)
if deezerSongLinkErr != nil {
attempts = append(attempts, fmt.Sprintf("deezer-songlink: %v", deezerSongLinkErr))
} else if addedData {
fmt.Println("Using Songlink as configured link resolver")
}
}
if links.TidalURL != "" && links.AmazonURL != "" {
return links, nil
}
}
}
if hasAnySongLinkData(links) {
return links, nil
}
if len(attempts) == 0 {
attempts = append(attempts, "no streaming URLs found")
}
return links, errors.New(strings.Join(attempts, " | "))
}
func orderedLinkResolvers() []string {
preferred := GetLinkResolverSetting()
if !GetLinkResolverAllowFallback() {
if preferred == linkResolverProviderDeezerSongLink {
return []string{linkResolverProviderDeezerSongLink}
}
return []string{linkResolverProviderSongstats}
}
if preferred == linkResolverProviderDeezerSongLink {
return []string{
linkResolverProviderDeezerSongLink,
linkResolverProviderSongstats,
}
}
return []string{
linkResolverProviderSongstats,
linkResolverProviderDeezerSongLink,
}
}
func (s *SongLinkClient) resolveLinksViaSongstats(links *resolvedTrackLinks) (bool, error) {
if links == nil || links.ISRC == "" {
return false, fmt.Errorf("ISRC is required for Songstats resolver")
}
before := *links
fmt.Printf("Fetching Songstats links for ISRC %s\n", links.ISRC)
if err := s.populateLinksFromSongstats(links, links.ISRC); err != nil {
return false, err
}
return *links != before, nil
}
func (s *SongLinkClient) resolveLinksViaDeezerSongLink(links *resolvedTrackLinks, region string) (bool, error) {
if links == nil || links.ISRC == "" {
return false, fmt.Errorf("ISRC is required for Deezer song.link resolver")
}
before := *links
var attempts []string
if links.DeezerURL == "" {
fmt.Printf("Resolving Deezer track from ISRC %s\n", links.ISRC)
deezerURL, err := s.lookupDeezerTrackURLByISRC(links.ISRC)
if err != nil {
attempts = append(attempts, fmt.Sprintf("deezer isrc: %v", err))
} else {
links.DeezerURL = deezerURL
fmt.Printf("Found Deezer URL: %s\n", links.DeezerURL)
}
}
if links.DeezerURL != "" {
fmt.Println("Resolving streaming URLs from song.link via Deezer URL...")
deezerResp, err := s.fetchSongLinkLinksByURL(links.DeezerURL, region)
if err != nil {
attempts = append(attempts, fmt.Sprintf("song.link deezer: %v", err))
} else {
mergeSongLinkResponse(links, deezerResp)
}
if links.ISRC == "" {
if resolvedISRC, deezerISRCErr := getDeezerISRC(links.DeezerURL); deezerISRCErr == nil {
links.ISRC = resolvedISRC
}
}
}
if *links != before {
if len(attempts) == 0 {
return true, nil
}
return true, errors.New(strings.Join(attempts, " | "))
}
if len(attempts) == 0 {
attempts = append(attempts, "no links found via deezer-songlink")
}
return false, errors.New(strings.Join(attempts, " | "))
}
+30 -1
View File
@@ -28,6 +28,7 @@ type Metadata struct {
DiscNumber int
TotalDiscs int
URL string
Comment string
Copyright string
Publisher string
Lyrics string
@@ -88,6 +89,9 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
if metadata.Description != "" {
_ = cmt.Add("DESCRIPTION", metadata.Description)
}
if comment := resolveMetadataComment(metadata); comment != "" {
_ = cmt.Add("COMMENT", comment)
}
if metadata.ISRC != "" {
_ = cmt.Add("ISRC", metadata.ISRC)
@@ -166,6 +170,14 @@ func extractYear(releaseDate string) string {
return releaseDate
}
func resolveMetadataComment(metadata Metadata) string {
if comment := strings.TrimSpace(metadata.Comment); comment != "" {
return comment
}
return strings.TrimSpace(metadata.URL)
}
func EmbedLyricsOnly(filepath string, lyrics string) error {
if lyrics == "" {
return nil
@@ -891,7 +903,11 @@ func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
metadata.Publisher = value
case "url":
metadata.URL = value
case "description", "comment":
case "comment", "comments":
if metadata.Comment == "" {
metadata.Comment = value
}
case "description":
if metadata.Description == "" {
metadata.Description = value
}
@@ -982,6 +998,16 @@ func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) er
tag.AddTextFrame("TSRC", id3v2.EncodingUTF8, metadata.ISRC)
}
if comment := resolveMetadataComment(metadata); comment != "" {
tag.DeleteFrames(tag.CommonID("Comments"))
tag.AddCommentFrame(id3v2.CommentFrame{
Encoding: id3v2.EncodingUTF8,
Language: "eng",
Description: "",
Text: comment,
})
}
if coverPath != "" && fileExists(coverPath) {
tag.DeleteFrames(tag.CommonID("Attached picture"))
@@ -1068,6 +1094,9 @@ func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) er
if metadata.ISRC != "" {
args = append(args, "-metadata", "isrc="+metadata.ISRC)
}
if comment := resolveMetadataComment(metadata); comment != "" {
args = append(args, "-metadata", "comment="+comment)
}
tmpOutputFile := strings.TrimSuffix(filePath, pathfilepath.Ext(filePath)) + ".tmp" + pathfilepath.Ext(filePath)
defer func() {
+215
View File
@@ -0,0 +1,215 @@
package backend
import (
"encoding/json"
"fmt"
"path/filepath"
"sort"
"strings"
"sync"
"time"
bolt "go.etcd.io/bbolt"
)
const (
providerPriorityDBFile = "provider_priority.db"
providerPriorityBucket = "ProviderPriority"
)
type providerPriorityEntry struct {
Service string `json:"service"`
Provider string `json:"provider"`
LastOutcome string `json:"last_outcome"`
LastAttempt int64 `json:"last_attempt"`
LastSuccess int64 `json:"last_success"`
LastFailure int64 `json:"last_failure"`
SuccessCount int64 `json:"success_count"`
FailureCount int64 `json:"failure_count"`
}
var (
providerPriorityDB *bolt.DB
providerPriorityDBMu sync.Mutex
)
func InitProviderPriorityDB() error {
providerPriorityDBMu.Lock()
defer providerPriorityDBMu.Unlock()
if providerPriorityDB != nil {
return nil
}
appDir, err := EnsureAppDir()
if err != nil {
return err
}
dbPath := filepath.Join(appDir, providerPriorityDBFile)
db, err := bolt.Open(dbPath, 0o600, &bolt.Options{Timeout: 1 * time.Second})
if err != nil {
return err
}
if err := db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists([]byte(providerPriorityBucket))
return err
}); err != nil {
db.Close()
return err
}
providerPriorityDB = db
return nil
}
func CloseProviderPriorityDB() {
providerPriorityDBMu.Lock()
defer providerPriorityDBMu.Unlock()
if providerPriorityDB != nil {
_ = providerPriorityDB.Close()
providerPriorityDB = nil
}
}
func prioritizeProviders(service string, providers []string) []string {
ordered := append([]string(nil), providers...)
if len(ordered) < 2 {
return ordered
}
if err := InitProviderPriorityDB(); err != nil {
fmt.Printf("Warning: failed to init provider priority DB: %v\n", err)
return ordered
}
serviceKey := strings.TrimSpace(strings.ToLower(service))
entries := make(map[string]providerPriorityEntry, len(ordered))
if err := providerPriorityDB.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(providerPriorityBucket))
if bucket == nil {
return nil
}
for _, provider := range ordered {
if raw := bucket.Get([]byte(providerPriorityKey(serviceKey, provider))); len(raw) > 0 {
var entry providerPriorityEntry
if err := json.Unmarshal(raw, &entry); err != nil {
return err
}
entries[provider] = entry
}
}
return nil
}); err != nil {
fmt.Printf("Warning: failed to read provider priority DB: %v\n", err)
return ordered
}
originalIndex := make(map[string]int, len(ordered))
for idx, provider := range ordered {
originalIndex[provider] = idx
}
sort.SliceStable(ordered, func(i, j int) bool {
left := entries[ordered[i]]
right := entries[ordered[j]]
leftRank := providerOutcomeRank(left.LastOutcome)
rightRank := providerOutcomeRank(right.LastOutcome)
if leftRank != rightRank {
return leftRank > rightRank
}
if left.LastSuccess != right.LastSuccess {
return left.LastSuccess > right.LastSuccess
}
if left.LastAttempt != right.LastAttempt {
return left.LastAttempt > right.LastAttempt
}
return originalIndex[ordered[i]] < originalIndex[ordered[j]]
})
return ordered
}
func recordProviderSuccess(service string, provider string) {
recordProviderOutcome(service, provider, true)
}
func recordProviderFailure(service string, provider string) {
recordProviderOutcome(service, provider, false)
}
func recordProviderOutcome(service string, provider string, success bool) {
if strings.TrimSpace(service) == "" || strings.TrimSpace(provider) == "" {
return
}
if err := InitProviderPriorityDB(); err != nil {
fmt.Printf("Warning: failed to init provider priority DB: %v\n", err)
return
}
serviceKey := strings.TrimSpace(strings.ToLower(service))
providerKey := providerPriorityKey(serviceKey, provider)
now := time.Now().Unix()
if err := providerPriorityDB.Update(func(tx *bolt.Tx) error {
bucket, err := tx.CreateBucketIfNotExists([]byte(providerPriorityBucket))
if err != nil {
return err
}
entry := providerPriorityEntry{
Service: serviceKey,
Provider: provider,
}
if raw := bucket.Get([]byte(providerKey)); len(raw) > 0 {
if err := json.Unmarshal(raw, &entry); err != nil {
return err
}
}
entry.LastAttempt = now
if success {
entry.LastOutcome = "success"
entry.LastSuccess = now
entry.SuccessCount++
} else {
entry.LastOutcome = "failure"
entry.LastFailure = now
entry.FailureCount++
}
payload, err := json.Marshal(entry)
if err != nil {
return err
}
return bucket.Put([]byte(providerKey), payload)
}); err != nil {
fmt.Printf("Warning: failed to update provider priority DB: %v\n", err)
}
}
func providerOutcomeRank(outcome string) int {
switch strings.TrimSpace(strings.ToLower(outcome)) {
case "success":
return 2
case "":
return 1
default:
return 0
}
}
func providerPriorityKey(service string, provider string) string {
return strings.TrimSpace(strings.ToLower(service)) + "|" + strings.TrimSpace(provider)
}
+19 -19
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"path/filepath"
@@ -171,15 +170,16 @@ 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 := []string{
standardAPIs := prioritizeProviders("qobuz", []string{
"https://dab.yeet.su/api/stream?trackId=",
"https://dabmusic.xyz/api/stream?trackId=",
"https://qbz.afkarxyz.qzz.io/api/track/",
}
})
downloadFunc := func(qual string) (string, error) {
type Provider struct {
Name string
API string
Func func() (string, error)
}
@@ -189,27 +189,26 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
currentAPI := api
providers = append(providers, Provider{
Name: "Standard(" + currentAPI + ")",
API: currentAPI,
Func: func() (string, error) {
return q.DownloadFromStandard(currentAPI, trackID, qual)
},
})
}
rand.Seed(time.Now().UnixNano())
rand.Shuffle(len(providers), func(i, j int) { providers[i], providers[j] = providers[j], providers[i] })
var lastErr error
for _, p := range providers {
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual)
url, err := p.Func()
if err == nil {
fmt.Printf("✓ Success\n")
recordProviderSuccess("qobuz", p.API)
return url, nil
}
fmt.Printf("Provider failed: %v\n", err)
recordProviderFailure("qobuz", p.API)
lastErr = err
}
return "", lastErr
@@ -362,29 +361,29 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t
}
func (q *QobuzDownloader) DownloadTrack(spotifyID, 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, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
var deezerISRC string
var isrc string
if spotifyID != "" {
songlinkClient := NewSongLinkClient()
isrc, err := songlinkClient.GetISRCDirect(spotifyID)
linkClient := NewSongLinkClient()
resolvedISRC, err := linkClient.GetISRCDirect(spotifyID)
if err != nil {
return "", fmt.Errorf("failed to get ISRC: %v", err)
}
deezerISRC = isrc
isrc = resolvedISRC
} else {
return "", fmt.Errorf("spotify ID is required for Qobuz download")
}
return q.DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
return q.DownloadTrackWithISRC(isrc, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
}
func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, 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, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC)
func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, 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, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) {
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
metaChan := make(chan Metadata, 1)
if embedGenre && deezerISRC != "" {
if embedGenre && isrc != "" {
go func() {
fmt.Println("Fetching MusicBrainz metadata...")
if fetchedMeta, err := FetchMusicBrainzMetadata(deezerISRC, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName, useSingleGenre, embedGenre); err == nil {
fmt.Println("✓ MusicBrainz metadata fetched")
metaChan <- fetchedMeta
} else {
@@ -402,7 +401,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir
}
}
track, err := q.searchByISRC(deezerISRC)
track, err := q.searchByISRC(isrc)
if err != nil {
return "", err
}
@@ -477,7 +476,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir
}
var mbMeta Metadata
if deezerISRC != "" {
if isrc != "" {
mbMeta = <-metaChan
}
@@ -499,10 +498,11 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Comment: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC",
ISRC: deezerISRC,
ISRC: isrc,
Genre: mbMeta.Genre,
}
+14 -498
View File
@@ -2,12 +2,9 @@ package backend
import (
"encoding/json"
"errors"
"fmt"
"html"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"regexp"
"strings"
@@ -17,12 +14,9 @@ import (
const songLinkUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"
var (
errSongLinkRateLimited = errors.New("song.link rate limited")
isrcPattern = regexp.MustCompile(`\b([A-Z]{2}[A-Z0-9]{3}\d{7})\b`)
csrfTokenPattern = regexp.MustCompile(`name=["']csrfmiddlewaretoken["'][^>]*value=["']([^"']+)["']`)
songstatsScriptPattern = regexp.MustCompile(`(?is)<script[^>]+type=["']application/ld\+json["'][^>]*>(.*?)</script>`)
amazonAlbumTrackPath = regexp.MustCompile(`/albums/[A-Z0-9]{10}/(B[0-9A-Z]{9})`)
amazonTrackPath = regexp.MustCompile(`/tracks/(B[0-9A-Z]{9})`)
isrcPattern = regexp.MustCompile(`\b([A-Z]{2}[A-Z0-9]{3}\d{7})\b`)
amazonAlbumTrackPath = regexp.MustCompile(`/albums/[A-Z0-9]{10}/(B[0-9A-Z]{9})`)
amazonTrackPath = regexp.MustCompile(`/tracks/(B[0-9A-Z]{9})`)
)
type SongLinkClient struct {
@@ -53,13 +47,6 @@ type songLinkAPIResponse struct {
} `json:"linksByPlatform"`
}
type resolvedTrackLinks struct {
TidalURL string
AmazonURL string
DeezerURL string
ISRC string
}
func NewSongLinkClient() *SongLinkClient {
return &SongLinkClient{
client: &http.Client{
@@ -113,8 +100,8 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAv
}
if isrc == "" && availability.DeezerURL != "" {
if deezerISRC, deezerErr := getDeezerISRC(availability.DeezerURL); deezerErr == nil {
isrc = deezerISRC
if resolvedISRC, deezerErr := getDeezerISRC(availability.DeezerURL); deezerErr == nil {
isrc = resolvedISRC
}
}
@@ -145,7 +132,11 @@ func checkQobuzAvailability(isrc string) bool {
client := &http.Client{Timeout: 10 * time.Second}
appID := "798273057"
searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/track/search?query=%s&limit=1&app_id=%s", isrc, appID)
searchURL := fmt.Sprintf(
"https://www.qobuz.com/api.json/0.2/track/search?query=%s&limit=1&app_id=%s",
url.QueryEscape(strings.TrimSpace(isrc)),
appID,
)
resp, err := client.Get(searchURL)
if err != nil {
@@ -153,7 +144,7 @@ func checkQobuzAvailability(isrc string) bool {
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
if resp.StatusCode != http.StatusOK {
return false
}
@@ -222,7 +213,7 @@ func getDeezerISRC(deezerURL string) (string, error) {
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Deezer API returned status %d", resp.StatusCode)
}
@@ -277,84 +268,13 @@ func (s *SongLinkClient) GetISRCDirect(spotifyID string) (string, error) {
return s.lookupSpotifyISRC(spotifyID)
}
func (s *SongLinkClient) resolveSpotifyTrackLinks(spotifyTrackID string, region string) (*resolvedTrackLinks, error) {
links := &resolvedTrackLinks{}
var attempts []string
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
fmt.Println("Getting streaming URLs from song.link...")
resp, err := s.fetchSongLinkLinksByURL(spotifyURL, region)
if err == nil {
mergeSongLinkResponse(links, resp)
if links.DeezerURL != "" && links.ISRC == "" {
if isrc, deezerErr := getDeezerISRC(links.DeezerURL); deezerErr == nil {
links.ISRC = isrc
}
}
if hasAnySongLinkData(links) {
return links, nil
}
attempts = append(attempts, "song.link spotify: no links found")
} else {
if errors.Is(err, errSongLinkRateLimited) {
fmt.Println("song.link rate limited for Spotify URL, switching to fallback 1 (songstats)...")
} else {
fmt.Printf("song.link primary lookup failed: %v\n", err)
}
attempts = append(attempts, fmt.Sprintf("song.link spotify: %v", err))
}
isrc, lookupErr := s.lookupSpotifyISRC(spotifyTrackID)
if lookupErr != nil {
attempts = append(attempts, fmt.Sprintf("isrc lookup: %v", lookupErr))
} else {
links.ISRC = isrc
}
if links.ISRC != "" {
fmt.Printf("Fallback 1: fetching Songstats links for ISRC %s\n", links.ISRC)
if songstatsErr := s.populateLinksFromSongstats(links, links.ISRC); songstatsErr != nil {
attempts = append(attempts, fmt.Sprintf("songstats: %v", songstatsErr))
} else if links.TidalURL != "" && links.AmazonURL != "" {
return links, nil
}
fmt.Printf("Fallback 2: resolving Deezer track from ISRC %s\n", links.ISRC)
deezerURL, deezerErr := s.lookupDeezerTrackURLByISRC(links.ISRC)
if deezerErr != nil {
attempts = append(attempts, fmt.Sprintf("deezer isrc: %v", deezerErr))
} else {
if links.DeezerURL == "" {
links.DeezerURL = deezerURL
}
deezerResp, deezerSongLinkErr := s.fetchSongLinkLinksByURL(deezerURL, region)
if deezerSongLinkErr != nil {
attempts = append(attempts, fmt.Sprintf("song.link deezer: %v", deezerSongLinkErr))
} else {
mergeSongLinkResponse(links, deezerResp)
}
}
}
if hasAnySongLinkData(links) {
return links, nil
}
if len(attempts) == 0 {
attempts = append(attempts, "no streaming URLs found")
}
return links, errors.New(strings.Join(attempts, " | "))
}
func (s *SongLinkClient) fetchSongLinkLinksByURL(rawURL string, region string) (*songLinkAPIResponse, error) {
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?url=%s", url.QueryEscape(rawURL))
if region != "" {
apiURL += fmt.Sprintf("&userCountry=%s", url.QueryEscape(region))
}
req, err := http.NewRequest("GET", apiURL, nil)
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
@@ -366,9 +286,6 @@ func (s *SongLinkClient) fetchSongLinkLinksByURL(rawURL string, region string) (
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
return nil, errSongLinkRateLimited
}
if resp.StatusCode != http.StatusOK {
bodyPreview, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return nil, fmt.Errorf("song.link returned status %d (%s)", resp.StatusCode, strings.TrimSpace(string(bodyPreview)))
@@ -394,319 +311,10 @@ func (s *SongLinkClient) fetchSongLinkLinksByURL(rawURL string, region string) (
return &parsed, nil
}
func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error) {
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
providers := []struct {
name string
fn func(string) (string, error)
}{
{name: "isrcfinder", fn: s.lookupISRCViaISRCFinder},
{name: "phpstack", fn: lookupISRCViaPHPStack},
{name: "findmyisrc", fn: lookupISRCViaFindMyISRC},
{name: "mixvibe", fn: lookupISRCViaMixvibe},
}
var errorsList []string
for _, provider := range providers {
fmt.Printf("Trying ISRC provider: %s\n", provider.name)
isrc, err := provider.fn(spotifyURL)
if err == nil && isrc != "" {
fmt.Printf("Found ISRC via %s: %s\n", provider.name, isrc)
return isrc, nil
}
if err != nil {
errorsList = append(errorsList, fmt.Sprintf("%s: %v", provider.name, err))
} else {
errorsList = append(errorsList, fmt.Sprintf("%s: no ISRC found", provider.name))
}
}
return "", errors.New(strings.Join(errorsList, " | "))
}
func (s *SongLinkClient) lookupISRCViaISRCFinder(spotifyURL string) (string, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return "", fmt.Errorf("failed to create cookie jar: %w", err)
}
client := &http.Client{
Timeout: 20 * time.Second,
Jar: jar,
}
req, err := http.NewRequest("GET", "https://www.isrcfinder.com/", nil)
if err != nil {
return "", fmt.Errorf("failed to create GET request: %w", err)
}
req.Header.Set("User-Agent", songLinkUserAgent)
req.Header.Set("Referer", "https://www.isrcfinder.com/")
req.Header.Set("Origin", "https://www.isrcfinder.com")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to load isrcfinder: %w", err)
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return "", fmt.Errorf("failed to read isrcfinder response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("isrcfinder returned status %d", resp.StatusCode)
}
token := extractCSRFToken(string(body))
if token == "" {
if parsedURL, parseErr := url.Parse("https://www.isrcfinder.com/"); parseErr == nil {
for _, cookie := range jar.Cookies(parsedURL) {
if cookie.Name == "csrftoken" {
token = cookie.Value
break
}
}
}
}
if token == "" {
return "", fmt.Errorf("csrf token not found")
}
form := url.Values{}
form.Set("csrfmiddlewaretoken", token)
form.Set("URI", spotifyURL)
postReq, err := http.NewRequest("POST", "https://www.isrcfinder.com/", strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("failed to create POST request: %w", err)
}
postReq.Header.Set("User-Agent", songLinkUserAgent)
postReq.Header.Set("Referer", "https://www.isrcfinder.com/")
postReq.Header.Set("Origin", "https://www.isrcfinder.com")
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
postResp, err := client.Do(postReq)
if err != nil {
return "", fmt.Errorf("failed to submit isrcfinder form: %w", err)
}
postBody, err := io.ReadAll(postResp.Body)
postResp.Body.Close()
if err != nil {
return "", fmt.Errorf("failed to read isrcfinder POST response: %w", err)
}
if postResp.StatusCode != http.StatusOK {
return "", fmt.Errorf("isrcfinder POST returned status %d", postResp.StatusCode)
}
isrc := firstISRCMatch(string(postBody))
if isrc == "" {
return "", fmt.Errorf("ISRC not found in isrcfinder response")
}
return isrc, nil
}
func lookupISRCViaPHPStack(spotifyURL string) (string, error) {
apiURL := fmt.Sprintf(
"https://phpstack-822472-6184058.cloudwaysapps.com/api/spotify.php?q=%s",
url.QueryEscape(spotifyURL),
)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", songLinkUserAgent)
req.Header.Set("Referer", "https://phpstack-822472-6184058.cloudwaysapps.com/?")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("phpstack request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("phpstack returned status %d", resp.StatusCode)
}
var payload struct {
ISRC string `json:"isrc"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return "", fmt.Errorf("failed to decode phpstack response: %w", err)
}
if payload.ISRC == "" {
return "", fmt.Errorf("ISRC missing in phpstack response")
}
return strings.ToUpper(strings.TrimSpace(payload.ISRC)), nil
}
func lookupISRCViaFindMyISRC(spotifyURL string) (string, error) {
payloadBytes, err := json.Marshal(map[string][]string{
"uris": []string{spotifyURL},
})
if err != nil {
return "", fmt.Errorf("failed to encode payload: %w", err)
}
req, err := http.NewRequest(
"POST",
"https://lxtzsnh4l3.execute-api.ap-southeast-2.amazonaws.com/prod/find-my-isrc",
strings.NewReader(string(payloadBytes)),
)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", songLinkUserAgent)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", "https://www.findmyisrc.com")
req.Header.Set("Referer", "https://www.findmyisrc.com/")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("findmyisrc request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("findmyisrc returned status %d", resp.StatusCode)
}
var payload []struct {
Data struct {
ISRC string `json:"isrc"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return "", fmt.Errorf("failed to decode findmyisrc response: %w", err)
}
for _, item := range payload {
if item.Data.ISRC != "" {
return strings.ToUpper(strings.TrimSpace(item.Data.ISRC)), nil
}
}
return "", fmt.Errorf("ISRC missing in findmyisrc response")
}
func lookupISRCViaMixvibe(spotifyURL string) (string, error) {
payloadBytes, err := json.Marshal(map[string]string{
"url": spotifyURL,
})
if err != nil {
return "", fmt.Errorf("failed to encode payload: %w", err)
}
req, err := http.NewRequest(
"POST",
"https://tools.mixviberecords.com/api/find-isrc",
strings.NewReader(string(payloadBytes)),
)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", songLinkUserAgent)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", "https://tools.mixviberecords.com")
req.Header.Set("Referer", "https://tools.mixviberecords.com/isrc-finder")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("mixvibe request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read mixvibe response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("mixvibe returned status %d", resp.StatusCode)
}
var payload interface{}
if err := json.Unmarshal(body, &payload); err == nil {
if isrc := findISRCInValue(payload); isrc != "" {
return isrc, nil
}
}
if isrc := firstISRCMatch(string(body)); isrc != "" {
return isrc, nil
}
return "", fmt.Errorf("ISRC missing in mixvibe response")
}
func (s *SongLinkClient) populateLinksFromSongstats(links *resolvedTrackLinks, isrc string) error {
pageURL := fmt.Sprintf("https://songstats.com/%s?ref=ISRCFinder", strings.ToUpper(strings.TrimSpace(isrc)))
req, err := http.NewRequest("GET", pageURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", songLinkUserAgent)
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("failed to fetch Songstats page: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Songstats returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read Songstats response: %w", err)
}
matches := songstatsScriptPattern.FindAllStringSubmatch(string(body), -1)
if len(matches) == 0 {
return fmt.Errorf("Songstats JSON-LD not found")
}
found := false
for _, match := range matches {
if len(match) < 2 {
continue
}
scriptBody := strings.TrimSpace(html.UnescapeString(match[1]))
if scriptBody == "" {
continue
}
var payload interface{}
if err := json.Unmarshal([]byte(scriptBody), &payload); err != nil {
continue
}
before := *links
collectSongstatsLinks(payload, links)
if *links != before {
found = true
}
}
if !found && !hasAnySongLinkData(links) {
return fmt.Errorf("no platform links found in Songstats")
}
return nil
}
func (s *SongLinkClient) lookupDeezerTrackURLByISRC(isrc string) (string, error) {
apiURL := fmt.Sprintf("https://api.deezer.com/track/isrc:%s", strings.ToUpper(strings.TrimSpace(isrc)))
req, err := http.NewRequest("GET", apiURL, nil)
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
@@ -762,62 +370,6 @@ func mergeSongLinkResponse(links *resolvedTrackLinks, resp *songLinkAPIResponse)
}
}
func collectSongstatsLinks(value interface{}, links *resolvedTrackLinks) {
switch typed := value.(type) {
case map[string]interface{}:
if sameAs, ok := typed["sameAs"]; ok {
applySongstatsSameAs(sameAs, links)
}
for _, nested := range typed {
collectSongstatsLinks(nested, links)
}
case []interface{}:
for _, nested := range typed {
collectSongstatsLinks(nested, links)
}
}
}
func applySongstatsSameAs(value interface{}, links *resolvedTrackLinks) {
switch typed := value.(type) {
case string:
assignSongstatsLink(typed, links)
case []interface{}:
for _, item := range typed {
if link, ok := item.(string); ok {
assignSongstatsLink(link, links)
}
}
}
}
func assignSongstatsLink(rawLink string, links *resolvedTrackLinks) {
link := strings.TrimSpace(rawLink)
if link == "" {
return
}
switch {
case strings.Contains(link, "listen.tidal.com/track"):
if links.TidalURL == "" {
links.TidalURL = link
fmt.Println("✓ Tidal URL found via Songstats")
}
case strings.Contains(link, "music.amazon.com"):
if links.AmazonURL == "" {
if normalized := normalizeAmazonMusicURL(link); normalized != "" {
links.AmazonURL = normalized
fmt.Println("✓ Amazon URL found via Songstats")
}
}
case strings.Contains(link, "deezer.com"):
if links.DeezerURL == "" {
links.DeezerURL = normalizeDeezerTrackURL(link)
fmt.Println("✓ Deezer URL found via Songstats")
}
}
}
func normalizeAmazonMusicURL(rawURL string) string {
amazonURL := strings.TrimSpace(rawURL)
if amazonURL == "" {
@@ -880,14 +432,6 @@ func hasAnySongLinkData(links *resolvedTrackLinks) bool {
return links.TidalURL != "" || links.AmazonURL != "" || links.DeezerURL != ""
}
func extractCSRFToken(body string) string {
match := csrfTokenPattern.FindStringSubmatch(body)
if len(match) < 2 {
return ""
}
return strings.TrimSpace(match[1])
}
func firstISRCMatch(body string) string {
match := isrcPattern.FindStringSubmatch(strings.ToUpper(body))
if len(match) < 2 {
@@ -895,31 +439,3 @@ func firstISRCMatch(body string) string {
}
return strings.TrimSpace(match[1])
}
func findISRCInValue(value interface{}) string {
switch typed := value.(type) {
case map[string]interface{}:
for key, nested := range typed {
if strings.EqualFold(key, "isrc") {
if isrc, ok := nested.(string); ok {
if normalized := firstISRCMatch(isrc); normalized != "" {
return normalized
}
}
}
if isrc := findISRCInValue(nested); isrc != "" {
return isrc
}
}
case []interface{}:
for _, nested := range typed {
if isrc := findISRCInValue(nested); isrc != "" {
return isrc
}
}
case string:
return firstISRCMatch(typed)
}
return ""
}
+128
View File
@@ -0,0 +1,128 @@
package backend
import (
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"regexp"
"strings"
)
var songstatsScriptPattern = regexp.MustCompile(`(?is)<script[^>]+type=["']application/ld\+json["'][^>]*>(.*?)</script>`)
func (s *SongLinkClient) populateLinksFromSongstats(links *resolvedTrackLinks, isrc string) error {
pageURL := fmt.Sprintf("https://songstats.com/%s?ref=ISRCFinder", strings.ToUpper(strings.TrimSpace(isrc)))
req, err := http.NewRequest(http.MethodGet, pageURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", songLinkUserAgent)
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("failed to fetch Songstats page: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Songstats returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read Songstats response: %w", err)
}
matches := songstatsScriptPattern.FindAllStringSubmatch(string(body), -1)
if len(matches) == 0 {
return fmt.Errorf("Songstats JSON-LD not found")
}
found := false
for _, match := range matches {
if len(match) < 2 {
continue
}
scriptBody := strings.TrimSpace(html.UnescapeString(match[1]))
if scriptBody == "" {
continue
}
var payload interface{}
if err := json.Unmarshal([]byte(scriptBody), &payload); err != nil {
continue
}
before := *links
collectSongstatsLinks(payload, links)
if *links != before {
found = true
}
}
if !found && !hasAnySongLinkData(links) {
return fmt.Errorf("no platform links found in Songstats")
}
return nil
}
func collectSongstatsLinks(value interface{}, links *resolvedTrackLinks) {
switch typed := value.(type) {
case map[string]interface{}:
if sameAs, ok := typed["sameAs"]; ok {
applySongstatsSameAs(sameAs, links)
}
for _, nested := range typed {
collectSongstatsLinks(nested, links)
}
case []interface{}:
for _, nested := range typed {
collectSongstatsLinks(nested, links)
}
}
}
func applySongstatsSameAs(value interface{}, links *resolvedTrackLinks) {
switch typed := value.(type) {
case string:
assignSongstatsLink(typed, links)
case []interface{}:
for _, item := range typed {
if link, ok := item.(string); ok {
assignSongstatsLink(link, links)
}
}
}
}
func assignSongstatsLink(rawLink string, links *resolvedTrackLinks) {
link := strings.TrimSpace(rawLink)
if link == "" {
return
}
switch {
case strings.Contains(link, "listen.tidal.com/track"):
if links.TidalURL == "" {
links.TidalURL = link
fmt.Println("✓ Tidal URL found via Songstats")
}
case strings.Contains(link, "music.amazon.com"):
if links.AmazonURL == "" {
if normalized := normalizeAmazonMusicURL(link); normalized != "" {
links.AmazonURL = normalized
fmt.Println("✓ Amazon URL found via Songstats")
}
}
case strings.Contains(link, "deezer.com"):
if links.DeezerURL == "" {
links.DeezerURL = normalizeDeezerTrackURL(link)
fmt.Println("✓ Deezer URL found via Songstats")
}
}
}
+95
View File
@@ -0,0 +1,95 @@
package backend
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
const (
soundplateSpotifyAPIURL = "https://phpstack-822472-6184058.cloudwaysapps.com/api/spotify.php"
soundplateRefererURL = "https://phpstack-822472-6184058.cloudwaysapps.com/?"
soundplateUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
)
type soundplateSpotifyResponse struct {
Name string `json:"name"`
Artist string `json:"artist"`
Album string `json:"album"`
AlbumType string `json:"album_type"`
ArtworkURL string `json:"artwork_url"`
ISRC string `json:"isrc"`
Year string `json:"year"`
SpotifyURL string `json:"spotify_url"`
}
func (s *SongLinkClient) lookupSpotifyISRCViaSoundplate(spotifyTrackID string) (string, string, error) {
normalizedTrackID, err := extractSpotifyTrackID(spotifyTrackID)
if err != nil {
return "", "", err
}
spotifyTrackURL := fmt.Sprintf("https://open.spotify.com/track/%s", normalizedTrackID)
query := url.Values{}
query.Set("q", spotifyTrackURL)
req, err := http.NewRequest(http.MethodGet, soundplateSpotifyAPIURL+"?"+query.Encode(), nil)
if err != nil {
return "", "", fmt.Errorf("failed to create Soundplate ISRC request: %w", err)
}
req.Header.Set("User-Agent", soundplateUserAgent)
req.Header.Set("Accept", "*/*")
req.Header.Set("Referer", soundplateRefererURL)
req.Header.Set("Accept-Language", "en-US,en;q=0.9,id;q=0.8")
req.Header.Set("Sec-CH-UA", "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"")
req.Header.Set("Sec-CH-UA-Mobile", "?0")
req.Header.Set("Sec-CH-UA-Platform", "\"Windows\"")
req.Header.Set("Sec-Fetch-Dest", "empty")
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Site", "same-origin")
req.Header.Set("Priority", "u=1, i")
resp, err := s.client.Do(req)
if err != nil {
return "", "", fmt.Errorf("Soundplate ISRC request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", fmt.Errorf("failed to read Soundplate ISRC response: %w", err)
}
if resp.StatusCode != http.StatusOK {
bodyPreview := strings.TrimSpace(string(body))
if len(bodyPreview) > 256 {
bodyPreview = bodyPreview[:256]
}
return "", "", fmt.Errorf("Soundplate ISRC returned status %d (%s)", resp.StatusCode, bodyPreview)
}
var payload soundplateSpotifyResponse
if err := json.Unmarshal(body, &payload); err != nil {
return "", "", fmt.Errorf("failed to decode Soundplate ISRC response: %w", err)
}
isrc := firstISRCMatch(payload.ISRC)
if isrc == "" {
isrc = firstISRCMatch(string(body))
}
if isrc == "" {
return "", "", fmt.Errorf("ISRC missing in Soundplate response")
}
resolvedTrackID := ""
if payload.SpotifyURL != "" {
if trackID, err := extractSpotifyTrackID(payload.SpotifyURL); err == nil {
resolvedTrackID = trackID
}
}
return isrc, resolvedTrackID, nil
}
+12 -7
View File
@@ -6,7 +6,6 @@ import (
"encoding/xml"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"os/exec"
@@ -86,7 +85,7 @@ func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
"https://monochrome-api.samidy.com",
"https://tidal.kinoplus.online",
}
return apis, nil
return prioritizeProviders("tidal", apis), nil
}
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
@@ -552,6 +551,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Comment: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC",
@@ -711,6 +711,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Comment: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC",
@@ -906,15 +907,13 @@ func getDownloadURLRotated(apis []string, trackID int64, quality string) (string
return "", "", fmt.Errorf("no APIs available")
}
rand.Seed(time.Now().UnixNano())
rand.Shuffle(len(apis), func(i, j int) { apis[i], apis[j] = apis[j], apis[i] })
fmt.Printf("Rotating through %d APIs...\n", len(apis))
orderedAPIs := prioritizeProviders("tidal", apis)
fmt.Printf("Trying %d prioritized APIs...\n", len(orderedAPIs))
var lastError error
var errors []string
for _, apiURL := range apis {
for _, apiURL := range orderedAPIs {
fmt.Printf("Trying API: %s\n", apiURL)
client := &http.Client{
@@ -925,6 +924,7 @@ func getDownloadURLRotated(apis []string, trackID int64, quality string) (string
resp, err := client.Get(url)
if err != nil {
lastError = err
recordProviderFailure("tidal", apiURL)
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
continue
}
@@ -932,6 +932,7 @@ func getDownloadURLRotated(apis []string, trackID int64, quality string) (string
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))
continue
}
@@ -940,6 +941,7 @@ func getDownloadURLRotated(apis []string, trackID int64, quality string) (string
resp.Body.Close()
if err != nil {
lastError = err
recordProviderFailure("tidal", apiURL)
errors = append(errors, fmt.Sprintf("%s: read body failed", apiURL))
continue
}
@@ -947,6 +949,7 @@ func getDownloadURLRotated(apis []string, trackID int64, quality string) (string
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
}
@@ -955,12 +958,14 @@ func getDownloadURLRotated(apis []string, trackID int64, quality string) (string
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))
}