.isrc finder
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
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"
|
||||
)
|
||||
|
||||
var spotifyAnonymousTokenMu sync.Mutex
|
||||
|
||||
type spotifyAnonymousToken struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
AccessTokenExpirationTimestampMs int64 `json:"accessTokenExpirationTimestampMs"`
|
||||
}
|
||||
|
||||
type spotifyServerTimeResponse struct {
|
||||
ServerTime int64 `json:"serverTime"`
|
||||
}
|
||||
|
||||
type spotifyTrackRawData struct {
|
||||
ExternalID []struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
} `json:"external_id"`
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error) {
|
||||
normalizedTrackID, err := extractSpotifyTrackID(spotifyTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
payload, err := fetchSpotifyTrackRawData(s.client, normalizedTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
isrc, err := extractSpotifyTrackISRC(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
fmt.Printf("Found ISRC via Spotify metadata: %s\n", isrc)
|
||||
return isrc, 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 spotifyTokenCachePath() (string, error) {
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
if err == nil && cacheDir != "" {
|
||||
return filepath.Join(cacheDir, "SpotiFLAC", spotifyTokenCacheFile), nil
|
||||
}
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to determine working directory: %w", err)
|
||||
}
|
||||
|
||||
return filepath.Join(wd, spotifyTokenCacheFile), 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 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
|
||||
if err := requestSpotifyJSON(client, spotifyTOTPSecretsURL, nil, &secrets); err != nil {
|
||||
return "", 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")
|
||||
}
|
||||
+12
-12
@@ -362,29 +362,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 +402,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 +477,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir
|
||||
}
|
||||
|
||||
var mbMeta Metadata
|
||||
if deezerISRC != "" {
|
||||
if isrc != "" {
|
||||
mbMeta = <-metaChan
|
||||
}
|
||||
|
||||
@@ -502,7 +502,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir
|
||||
Copyright: spotifyCopyright,
|
||||
Publisher: spotifyPublisher,
|
||||
Description: "https://github.com/afkarxyz/SpotiFLAC",
|
||||
ISRC: deezerISRC,
|
||||
ISRC: isrc,
|
||||
Genre: mbMeta.Genre,
|
||||
}
|
||||
|
||||
|
||||
+37
-458
@@ -4,10 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -17,12 +15,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 {
|
||||
@@ -113,8 +108,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 +140,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 +152,7 @@ func checkQobuzAvailability(isrc string) bool {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -222,7 +221,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)
|
||||
}
|
||||
|
||||
@@ -281,58 +280,42 @@ func (s *SongLinkClient) resolveSpotifyTrackLinks(spotifyTrackID string, region
|
||||
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))
|
||||
isrc, err := s.lookupSpotifyISRC(spotifyTrackID)
|
||||
if err != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("spotify isrc: %v", err))
|
||||
} else {
|
||||
links.ISRC = isrc
|
||||
}
|
||||
|
||||
if links.ISRC != "" {
|
||||
fmt.Printf("Fallback 1: fetching Songstats links for ISRC %s\n", links.ISRC)
|
||||
fmt.Printf("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)
|
||||
if links.ISRC != "" && links.DeezerURL == "" {
|
||||
fmt.Printf("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)
|
||||
links.DeezerURL = deezerURL
|
||||
}
|
||||
}
|
||||
|
||||
if links.DeezerURL != "" {
|
||||
fmt.Println("Resolving streaming URLs from song.link via Deezer URL...")
|
||||
deezerResp, deezerSongLinkErr := s.fetchSongLinkLinksByURL(links.DeezerURL, region)
|
||||
if deezerSongLinkErr != nil {
|
||||
attempts = append(attempts, fmt.Sprintf("song.link deezer: %v", deezerSongLinkErr))
|
||||
} else {
|
||||
mergeSongLinkResponse(links, deezerResp)
|
||||
}
|
||||
|
||||
if links.ISRC == "" {
|
||||
if resolvedISRC, deezerISRCErr := getDeezerISRC(links.DeezerURL); deezerISRCErr == nil {
|
||||
links.ISRC = resolvedISRC
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -354,7 +337,7 @@ func (s *SongLinkClient) fetchSongLinkLinksByURL(rawURL string, region string) (
|
||||
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 +349,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 +374,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 +433,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 +495,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 +502,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user