v7.1.3
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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: "*.*",
|
||||
|
||||
@@ -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
@@ -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
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user