This commit is contained in:
afkarxyz
2026-04-14 07:36:41 +07:00
parent 59a057b14a
commit 7346730be9
336 changed files with 13800 additions and 1142 deletions
+158 -266
View File
@@ -1,9 +1,6 @@
package backend
import (
"crypto/hmac"
"crypto/sha1"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
@@ -20,16 +17,10 @@ import (
)
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
spotifySessionTokenURL = "https://open.spotify.com/api/token"
spotifyGIDMetadataURL = "https://spclient.wg.spotify.com/metadata/4/%s/%s?market=from_token"
spotifyBase62Alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
spotifyTokenCacheFile = ".isrc-finder-token.json"
)
var spotifyAnonymousTokenMu sync.Mutex
@@ -39,91 +30,104 @@ type spotifyAnonymousToken struct {
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 {
Album struct {
GID string `json:"gid"`
} `json:"album"`
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"`
type spotifyAlbumRawData struct {
ExternalID []struct {
Type string `json:"type"`
ID string `json:"id"`
} `json:"external_id"`
}
func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error) {
type SpotifyTrackIdentifiers struct {
ISRC string `json:"isrc,omitempty"`
UPC string `json:"upc,omitempty"`
}
func GetSpotifyTrackIdentifiersDirect(spotifyTrackID string) (SpotifyTrackIdentifiers, error) {
normalizedTrackID, err := extractSpotifyTrackID(spotifyTrackID)
if err != nil {
return "", err
return SpotifyTrackIdentifiers{}, err
}
identifiers := SpotifyTrackIdentifiers{}
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
identifiers.ISRC = cachedISRC
}
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)
}
}
httpClient := &http.Client{Timeout: 30 * time.Second}
payload, metadataErr := fetchSpotifyTrackRawData(s.client, normalizedTrackID)
payload, metadataErr := fetchSpotifyTrackRawData(httpClient, normalizedTrackID)
if metadataErr == nil {
isrc, extractErr := extractSpotifyTrackISRC(payload)
metadataIdentifiers, extractErr := extractSpotifyTrackIdentifiers(httpClient, payload)
if extractErr == nil {
fmt.Printf("Found ISRC via Spotify metadata: %s\n", isrc)
cacheResolvedSpotifyTrackISRC(normalizedTrackID, "", isrc)
return isrc, nil
mergeSpotifyTrackIdentifiers(&identifiers, metadataIdentifiers)
if identifiers.ISRC != "" {
fmt.Printf("Found identifiers via Spotify metadata: isrc=%s upc=%s\n", identifiers.ISRC, identifiers.UPC)
cacheResolvedSpotifyTrackISRC(normalizedTrackID, "", identifiers.ISRC)
}
if identifiers.ISRC != "" && identifiers.UPC != "" {
return identifiers, nil
}
}
metadataErr = extractErr
}
if metadataErr != nil {
fmt.Printf("Warning: Spotify metadata ISRC lookup failed, falling back to Soundplate: %v\n", metadataErr)
fmt.Printf("Warning: Spotify metadata identifier 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 identifiers.ISRC == "" {
client := NewSongLinkClient()
isrc, resolvedTrackID, soundplateErr := client.lookupSpotifyISRCViaSoundplate(normalizedTrackID)
if soundplateErr == nil && isrc != "" {
identifiers.ISRC = isrc
fmt.Printf("Found ISRC via Soundplate: %s\n", isrc)
cacheResolvedSpotifyTrackISRC(normalizedTrackID, resolvedTrackID, isrc)
return identifiers, nil
}
if metadataErr != nil && soundplateErr != nil {
return identifiers, fmt.Errorf("spotify metadata lookup failed: %v | soundplate lookup failed: %w", metadataErr, soundplateErr)
}
if soundplateErr != nil && identifiers.UPC == "" {
return identifiers, soundplateErr
}
}
if metadataErr != nil && soundplateErr != nil {
return "", fmt.Errorf("spotify metadata lookup failed: %v | soundplate lookup failed: %w", metadataErr, soundplateErr)
if identifiers.ISRC != "" || identifiers.UPC != "" {
return identifiers, nil
}
if soundplateErr != nil {
return "", soundplateErr
if metadataErr != nil {
return identifiers, metadataErr
}
return "", metadataErr
return identifiers, fmt.Errorf("no Spotify identifiers found for track %s", normalizedTrackID)
}
func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error) {
identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyTrackID)
if err != nil {
return "", err
}
if identifiers.ISRC == "" {
return "", fmt.Errorf("no Spotify ISRC found for track %s", strings.TrimSpace(spotifyTrackID))
}
return identifiers.ISRC, nil
}
func cacheResolvedSpotifyTrackISRC(trackID string, resolvedTrackID string, isrc string) {
@@ -137,47 +141,28 @@ func cacheResolvedSpotifyTrackISRC(trackID string, resolvedTrackID string, isrc
}
}
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")
func mergeSpotifyTrackIdentifiers(target *SpotifyTrackIdentifiers, incoming SpotifyTrackIdentifiers) {
if incoming.ISRC != "" {
target.ISRC = strings.TrimSpace(incoming.ISRC)
}
if baseURL == "" {
return "", "", fmt.Errorf("spotfetch api url is required")
if incoming.UPC != "" {
target.UPC = strings.TrimSpace(incoming.UPC)
}
}
func lookupSpotifyAlbumUPC(albumID string) (string, error) {
normalizedAlbumID := strings.TrimSpace(albumID)
if normalizedAlbumID == "" {
return "", fmt.Errorf("spotify album ID is required")
}
requestURL := fmt.Sprintf("%s/isrc/%s", baseURL, url.PathEscape(normalizedTrackID))
req, err := http.NewRequest(http.MethodGet, requestURL, nil)
httpClient := &http.Client{Timeout: 30 * time.Second}
payload, err := fetchSpotifyAlbumRawData(httpClient, normalizedAlbumID)
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)))
return "", err
}
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
return extractSpotifyAlbumUPC(payload)
}
func requestSpotifyBytes(client *http.Client, targetURL string, headers map[string]string) ([]byte, error) {
@@ -269,50 +254,6 @@ func saveSpotifyCachedToken(token *spotifyAnonymousToken) error {
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 {
@@ -322,15 +263,6 @@ func spotifyTokenCachePath() (string, error) {
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
@@ -339,47 +271,6 @@ func spotifyTokenIsValid(token *spotifyAnonymousToken) bool {
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()
@@ -393,52 +284,17 @@ func requestSpotifyAnonymousAccessToken(client *http.Client) (string, error) {
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()
generatedTOTP, version, err := generateSpotifyTOTP(time.Now())
if err != nil {
fmt.Printf("Warning: failed to read Spotify secrets cache: %v\n", err)
return "", fmt.Errorf("failed to generate Spotify TOTP: %w", 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},
"totpVer": {strconv.Itoa(version)},
}
var token spotifyAnonymousToken
@@ -453,30 +309,6 @@ func requestSpotifyAnonymousAccessToken(client *http.Client) (string, error) {
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 == "" {
@@ -504,14 +336,18 @@ func extractSpotifyTrackID(value string) (string, error) {
}
func spotifyTrackIDToGID(trackID string) (string, error) {
if trackID == "" {
return "", errors.New("track ID is empty")
return spotifyEntityIDToGID(trackID)
}
func spotifyEntityIDToGID(entityID string) (string, error) {
if entityID == "" {
return "", errors.New("entity ID is empty")
}
value := big.NewInt(0)
base := big.NewInt(62)
for _, char := range trackID {
for _, char := range entityID {
index := strings.IndexRune(spotifyBase62Alphabet, char)
if index < 0 {
return "", fmt.Errorf("invalid base62 character: %q", string(char))
@@ -530,43 +366,99 @@ func spotifyTrackIDToGID(trackID string) (string, error) {
}
func fetchSpotifyTrackRawData(client *http.Client, trackID string) ([]byte, error) {
accessToken, err := requestSpotifyAnonymousAccessToken(client)
gid, err := spotifyTrackIDToGID(trackID)
if err != nil {
return nil, err
}
gid, err := spotifyTrackIDToGID(trackID)
return fetchSpotifyRawMetadataByGID(client, "track", gid)
}
func fetchSpotifyAlbumRawData(client *http.Client, albumID string) ([]byte, error) {
gid, err := spotifyEntityIDToGID(albumID)
if err != nil {
return nil, err
}
return fetchSpotifyRawMetadataByGID(client, "album", gid)
}
func fetchSpotifyRawMetadataByGID(client *http.Client, entityType string, gid string) ([]byte, error) {
accessToken, err := requestSpotifyAnonymousAccessToken(client)
if err != nil {
return nil, err
}
return requestSpotifyBytes(
client,
fmt.Sprintf(spotifyGIDMetadataURL, "track", gid),
fmt.Sprintf(spotifyGIDMetadataURL, entityType, gid),
map[string]string{
"authorization": "Bearer " + accessToken,
"accept": "application/json",
"user-agent": songLinkUserAgent,
},
)
}
func extractSpotifyTrackISRC(payload []byte) (string, error) {
func extractSpotifyTrackIdentifiers(client *http.Client, payload []byte) (SpotifyTrackIdentifiers, error) {
var track spotifyTrackRawData
if err := json.Unmarshal(payload, &track); err != nil {
return "", fmt.Errorf("failed to decode Spotify track metadata: %w", err)
return SpotifyTrackIdentifiers{}, fmt.Errorf("failed to decode Spotify track metadata: %w", err)
}
identifiers := SpotifyTrackIdentifiers{}
for _, externalID := range track.ExternalID {
if strings.EqualFold(strings.TrimSpace(externalID.Type), "isrc") {
if isrc := firstISRCMatch(externalID.ID); isrc != "" {
return isrc, nil
identifiers.ISRC = isrc
break
}
}
}
if fallbackISRC := firstISRCMatch(string(payload)); fallbackISRC != "" {
return fallbackISRC, nil
if identifiers.ISRC == "" {
identifiers.ISRC = firstISRCMatch(string(payload))
}
albumGID := strings.TrimSpace(track.Album.GID)
if client != nil && albumGID != "" {
albumPayload, err := fetchSpotifyRawMetadataByGID(client, "album", albumGID)
if err == nil {
if upc, upcErr := extractSpotifyAlbumUPC(albumPayload); upcErr == nil {
identifiers.UPC = upc
}
}
}
return identifiers, nil
}
func extractSpotifyTrackISRC(payload []byte) (string, error) {
identifiers, err := extractSpotifyTrackIdentifiers(nil, payload)
if err != nil {
return "", err
}
if identifiers.ISRC != "" {
return identifiers.ISRC, nil
}
return "", fmt.Errorf("ISRC not found in Spotify track metadata")
}
func extractSpotifyAlbumUPC(payload []byte) (string, error) {
var album spotifyAlbumRawData
if err := json.Unmarshal(payload, &album); err != nil {
return "", fmt.Errorf("failed to decode Spotify album metadata: %w", err)
}
for _, externalID := range album.ExternalID {
if strings.EqualFold(strings.TrimSpace(externalID.Type), "upc") {
upc := strings.TrimSpace(externalID.ID)
if upc != "" {
return upc, nil
}
}
}
return "", fmt.Errorf("UPC not found in Spotify album metadata")
}