.spotfetch isrc
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
@@ -15,3 +16,54 @@ func GetDefaultMusicPath() string {
|
|||||||
|
|
||||||
return filepath.Join(homeDir, "Music")
|
return filepath.Join(homeDir, "Music")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetConfigPath() (string, error) {
|
||||||
|
dir, err := EnsureAppDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(dir, "config.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfigSettings() (map[string]interface{}, error) {
|
||||||
|
configPath, err := GetConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &settings); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSpotFetchAPISettings() (bool, string) {
|
||||||
|
settings, err := LoadConfigSettings()
|
||||||
|
if err != nil || settings == nil {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
useAPI, _ := settings["useSpotFetchAPI"].(bool)
|
||||||
|
if !useAPI {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL, _ := settings["spotFetchAPIUrl"].(string)
|
||||||
|
if apiURL == "" {
|
||||||
|
apiURL = "https://sp.afkarxyz.qzz.io/api"
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, apiURL
|
||||||
|
}
|
||||||
|
|||||||
+2
-12
@@ -1,9 +1,7 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -139,18 +137,11 @@ func NormalizePath(folderPath string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetSeparator() string {
|
func GetSeparator() string {
|
||||||
dir, err := GetFFmpegDir()
|
settings, err := LoadConfigSettings()
|
||||||
if err != nil {
|
if err != nil || settings == nil {
|
||||||
return "; "
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(dir, "config.json")
|
|
||||||
data, err := os.ReadFile(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return "; "
|
return "; "
|
||||||
}
|
}
|
||||||
|
|
||||||
var settings map[string]interface{}
|
|
||||||
if err := json.Unmarshal(data, &settings); err == nil {
|
|
||||||
if sep, ok := settings["separator"].(string); ok {
|
if sep, ok := settings["separator"].(string); ok {
|
||||||
if sep == "comma" {
|
if sep == "comma" {
|
||||||
return ", "
|
return ", "
|
||||||
@@ -159,7 +150,6 @@ func GetSeparator() string {
|
|||||||
return "; "
|
return "; "
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return "; "
|
return "; "
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const (
|
|||||||
spotifyTOTPDigits = 6
|
spotifyTOTPDigits = 6
|
||||||
spotifyBase62Alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
spotifyBase62Alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
spotifyTokenCacheFile = ".isrc-finder-token.json"
|
spotifyTokenCacheFile = ".isrc-finder-token.json"
|
||||||
|
spotifySecretsCacheFile = "spotify-secret-dict-cache.json"
|
||||||
|
spotifySecretsCacheTTL = 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
var spotifyAnonymousTokenMu sync.Mutex
|
var spotifyAnonymousTokenMu sync.Mutex
|
||||||
@@ -41,6 +43,11 @@ type spotifyServerTimeResponse struct {
|
|||||||
ServerTime int64 `json:"serverTime"`
|
ServerTime int64 `json:"serverTime"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type spotifySecretsCache struct {
|
||||||
|
FetchedAtUnix int64 `json:"fetched_at_unix"`
|
||||||
|
Secrets map[string][]int `json:"secrets"`
|
||||||
|
}
|
||||||
|
|
||||||
type spotifyTrackRawData struct {
|
type spotifyTrackRawData struct {
|
||||||
ExternalID []struct {
|
ExternalID []struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
@@ -48,6 +55,19 @@ type spotifyTrackRawData struct {
|
|||||||
} `json:"external_id"`
|
} `json:"external_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type spotFetchISRCResponse struct {
|
||||||
|
Input string `json:"input"`
|
||||||
|
TrackID string `json:"track_id"`
|
||||||
|
GID string `json:"gid"`
|
||||||
|
CanonicalURI string `json:"canonical_uri"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Artists []string `json:"artists"`
|
||||||
|
AlbumName string `json:"album_name"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error) {
|
func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error) {
|
||||||
normalizedTrackID, err := extractSpotifyTrackID(spotifyTrackID)
|
normalizedTrackID, err := extractSpotifyTrackID(spotifyTrackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -62,6 +82,26 @@ func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error
|
|||||||
return cachedISRC, nil
|
return cachedISRC, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useSpotFetchAPI, spotFetchAPIURL := GetSpotFetchAPISettings()
|
||||||
|
if useSpotFetchAPI {
|
||||||
|
isrc, resolvedTrackID, err := s.lookupSpotifyISRCViaSpotFetchAPI(normalizedTrackID, spotFetchAPIURL)
|
||||||
|
if err == nil && isrc != "" {
|
||||||
|
fmt.Printf("Found ISRC via SpotFetch API: %s\n", isrc)
|
||||||
|
if err := PutCachedISRC(normalizedTrackID, isrc); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to write ISRC cache: %v\n", err)
|
||||||
|
}
|
||||||
|
if resolvedTrackID != "" && resolvedTrackID != normalizedTrackID {
|
||||||
|
if err := PutCachedISRC(resolvedTrackID, isrc); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to write ISRC cache for resolved track ID: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isrc, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: SpotFetch ISRC lookup failed, falling back to Spotify metadata: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
payload, err := fetchSpotifyTrackRawData(s.client, normalizedTrackID)
|
payload, err := fetchSpotifyTrackRawData(s.client, normalizedTrackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -79,6 +119,49 @@ func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error
|
|||||||
return isrc, nil
|
return isrc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SongLinkClient) lookupSpotifyISRCViaSpotFetchAPI(spotifyTrackID string, apiBaseURL string) (string, string, error) {
|
||||||
|
normalizedTrackID := strings.TrimSpace(spotifyTrackID)
|
||||||
|
baseURL := strings.TrimRight(strings.TrimSpace(apiBaseURL), "/")
|
||||||
|
if normalizedTrackID == "" {
|
||||||
|
return "", "", fmt.Errorf("spotify track ID is required")
|
||||||
|
}
|
||||||
|
if baseURL == "" {
|
||||||
|
return "", "", fmt.Errorf("spotfetch api url is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
requestURL := fmt.Sprintf("%s/isrc/%s", baseURL, url.PathEscape(normalizedTrackID))
|
||||||
|
req, err := http.NewRequest(http.MethodGet, requestURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to create SpotFetch ISRC request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", songLinkUserAgent)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("SpotFetch ISRC request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
bodyPreview, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||||
|
return "", "", fmt.Errorf("SpotFetch ISRC returned status %d (%s)", resp.StatusCode, strings.TrimSpace(string(bodyPreview)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload spotFetchISRCResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to decode SpotFetch ISRC response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
isrc := firstISRCMatch(payload.ISRC)
|
||||||
|
if isrc == "" {
|
||||||
|
return "", "", fmt.Errorf("ISRC missing in SpotFetch response")
|
||||||
|
}
|
||||||
|
|
||||||
|
return isrc, strings.TrimSpace(payload.TrackID), nil
|
||||||
|
}
|
||||||
|
|
||||||
func requestSpotifyBytes(client *http.Client, targetURL string, headers map[string]string) ([]byte, error) {
|
func requestSpotifyBytes(client *http.Client, targetURL string, headers map[string]string) ([]byte, error) {
|
||||||
req, err := http.NewRequest(http.MethodGet, targetURL, nil)
|
req, err := http.NewRequest(http.MethodGet, targetURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -168,6 +251,50 @@ func saveSpotifyCachedToken(token *spotifyAnonymousToken) error {
|
|||||||
return nil
|
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) {
|
func spotifyTokenCachePath() (string, error) {
|
||||||
appDir, err := EnsureAppDir()
|
appDir, err := EnsureAppDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -177,6 +304,15 @@ func spotifyTokenCachePath() (string, error) {
|
|||||||
return filepath.Join(appDir, spotifyTokenCacheFile), nil
|
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 {
|
func spotifyTokenIsValid(token *spotifyAnonymousToken) bool {
|
||||||
if token == nil || token.AccessToken == "" || token.AccessTokenExpirationTimestampMs == 0 {
|
if token == nil || token.AccessToken == "" || token.AccessTokenExpirationTimestampMs == 0 {
|
||||||
return false
|
return false
|
||||||
@@ -185,6 +321,14 @@ func spotifyTokenIsValid(token *spotifyAnonymousToken) bool {
|
|||||||
return time.Now().UnixMilli() < token.AccessTokenExpirationTimestampMs-30_000
|
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 {
|
func deriveSpotifyTOTPSecret(ciphertext []int) []byte {
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
|
|
||||||
@@ -237,9 +381,31 @@ func requestSpotifyAnonymousAccessToken(client *http.Client) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var secrets map[string][]int
|
var secrets map[string][]int
|
||||||
|
cachedSecrets, err := loadSpotifyCachedSecrets()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: failed to read Spotify secrets cache: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if spotifySecretsCacheIsValid(cachedSecrets) {
|
||||||
|
secrets = cachedSecrets.Secrets
|
||||||
|
} else {
|
||||||
if err := requestSpotifyJSON(client, spotifyTOTPSecretsURL, nil, &secrets); err != nil {
|
if 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
|
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)
|
version, err := latestSpotifySecretVersion(secrets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user