.isrc finder

This commit is contained in:
afkarxyz
2026-04-02 08:17:35 +07:00
parent 3e04868746
commit 6de2bae67b
6 changed files with 565 additions and 472 deletions
+5 -2
View File
@@ -408,7 +408,10 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
if req.Service == "qobuz" {
go func() {
client := backend.NewSongLinkClient()
isrc, _ := client.GetISRCDirect(req.SpotifyID)
isrc, err := client.GetISRCDirect(req.SpotifyID)
if err != nil {
fmt.Printf("Warning: failed to resolve ISRC for Qobuz: %v\n", err)
}
isrcChan <- isrc
}()
} else {
@@ -455,7 +458,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
if quality == "" {
quality = "6"
}
filename, err = downloader.DownloadTrackWithISRC(isrc, req.SpotifyID, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
filename, err = downloader.DownloadTrackWithISRC(isrc, req.OutputDir, quality, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre)
default:
return DownloadResponse{
+382
View File
@@ -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
View File
@@ -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,
}
+28 -449
View File
@@ -4,10 +4,8 @@ import (
"encoding/json"
"errors"
"fmt"
"html"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"regexp"
"strings"
@@ -17,10 +15,7 @@ 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})`)
)
@@ -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,59 +280,43 @@ 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 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 ""
}
+128
View File
@@ -0,0 +1,128 @@
package backend
import (
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"regexp"
"strings"
)
var songstatsScriptPattern = regexp.MustCompile(`(?is)<script[^>]+type=["']application/ld\+json["'][^>]*>(.*?)</script>`)
func (s *SongLinkClient) populateLinksFromSongstats(links *resolvedTrackLinks, isrc string) error {
pageURL := fmt.Sprintf("https://songstats.com/%s?ref=ISRCFinder", strings.ToUpper(strings.TrimSpace(isrc)))
req, err := http.NewRequest(http.MethodGet, pageURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", songLinkUserAgent)
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("failed to fetch Songstats page: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Songstats returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read Songstats response: %w", err)
}
matches := songstatsScriptPattern.FindAllStringSubmatch(string(body), -1)
if len(matches) == 0 {
return fmt.Errorf("Songstats JSON-LD not found")
}
found := false
for _, match := range matches {
if len(match) < 2 {
continue
}
scriptBody := strings.TrimSpace(html.UnescapeString(match[1]))
if scriptBody == "" {
continue
}
var payload interface{}
if err := json.Unmarshal([]byte(scriptBody), &payload); err != nil {
continue
}
before := *links
collectSongstatsLinks(payload, links)
if *links != before {
found = true
}
}
if !found && !hasAnySongLinkData(links) {
return fmt.Errorf("no platform links found in Songstats")
}
return nil
}
func collectSongstatsLinks(value interface{}, links *resolvedTrackLinks) {
switch typed := value.(type) {
case map[string]interface{}:
if sameAs, ok := typed["sameAs"]; ok {
applySongstatsSameAs(sameAs, links)
}
for _, nested := range typed {
collectSongstatsLinks(nested, links)
}
case []interface{}:
for _, nested := range typed {
collectSongstatsLinks(nested, links)
}
}
}
func applySongstatsSameAs(value interface{}, links *resolvedTrackLinks) {
switch typed := value.(type) {
case string:
assignSongstatsLink(typed, links)
case []interface{}:
for _, item := range typed {
if link, ok := item.(string); ok {
assignSongstatsLink(link, links)
}
}
}
}
func assignSongstatsLink(rawLink string, links *resolvedTrackLinks) {
link := strings.TrimSpace(rawLink)
if link == "" {
return
}
switch {
case strings.Contains(link, "listen.tidal.com/track"):
if links.TidalURL == "" {
links.TidalURL = link
fmt.Println("✓ Tidal URL found via Songstats")
}
case strings.Contains(link, "music.amazon.com"):
if links.AmazonURL == "" {
if normalized := normalizeAmazonMusicURL(link); normalized != "" {
links.AmazonURL = normalized
fmt.Println("✓ Amazon URL found via Songstats")
}
}
case strings.Contains(link, "deezer.com"):
if links.DeezerURL == "" {
links.DeezerURL = normalizeDeezerTrackURL(link)
fmt.Println("✓ Deezer URL found via Songstats")
}
}
}
+1
Submodule ref/isrc-finder added at de70048712