diff --git a/backend/config.go b/backend/config.go index 51333d1..18b56be 100644 --- a/backend/config.go +++ b/backend/config.go @@ -1,6 +1,7 @@ package backend import ( + "encoding/json" "os" "path/filepath" ) @@ -15,3 +16,54 @@ func GetDefaultMusicPath() string { return filepath.Join(homeDir, "Music") } + +func GetConfigPath() (string, error) { + dir, err := EnsureAppDir() + if err != nil { + return "", err + } + + return filepath.Join(dir, "config.json"), nil +} + +func LoadConfigSettings() (map[string]interface{}, error) { + configPath, err := GetConfigPath() + if err != nil { + return nil, err + } + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return nil, nil + } + + data, err := os.ReadFile(configPath) + if err != nil { + return nil, err + } + + var settings map[string]interface{} + if err := json.Unmarshal(data, &settings); err != nil { + return nil, err + } + + return settings, nil +} + +func GetSpotFetchAPISettings() (bool, string) { + settings, err := LoadConfigSettings() + if err != nil || settings == nil { + return false, "" + } + + useAPI, _ := settings["useSpotFetchAPI"].(bool) + if !useAPI { + return false, "" + } + + apiURL, _ := settings["spotFetchAPIUrl"].(string) + if apiURL == "" { + apiURL = "https://sp.afkarxyz.qzz.io/api" + } + + return true, apiURL +} diff --git a/backend/filename.go b/backend/filename.go index 1e6b3d4..0c8a373 100644 --- a/backend/filename.go +++ b/backend/filename.go @@ -1,9 +1,7 @@ package backend import ( - "encoding/json" "fmt" - "os" "path/filepath" "regexp" "strings" @@ -139,25 +137,17 @@ func NormalizePath(folderPath string) string { } func GetSeparator() string { - dir, err := GetFFmpegDir() - if err != nil { - return "; " - } - configPath := filepath.Join(dir, "config.json") - data, err := os.ReadFile(configPath) - if err != nil { + settings, err := LoadConfigSettings() + if err != nil || settings == nil { return "; " } - var settings map[string]interface{} - if err := json.Unmarshal(data, &settings); err == nil { - if sep, ok := settings["separator"].(string); ok { - if sep == "comma" { - return ", " - } - if sep == "semicolon" { - return "; " - } + if sep, ok := settings["separator"].(string); ok { + if sep == "comma" { + return ", " + } + if sep == "semicolon" { + return "; " } } return "; " diff --git a/backend/isrc_finder.go b/backend/isrc_finder.go index ef72729..eca31d7 100644 --- a/backend/isrc_finder.go +++ b/backend/isrc_finder.go @@ -20,14 +20,16 @@ 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" + spotifyServerTimeURL = "https://open.spotify.com/api/server-time" + spotifySessionTokenURL = "https://open.spotify.com/api/token" + spotifyTOTPSecretsURL = "https://git.gay/thereallo/totp-secrets/raw/branch/main/secrets/secretDict.json" + spotifyGIDMetadataURL = "https://spclient.wg.spotify.com/metadata/4/%s/%s?market=from_token" + spotifyTOTPPeriod = 30 + spotifyTOTPDigits = 6 + spotifyBase62Alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + spotifyTokenCacheFile = ".isrc-finder-token.json" + spotifySecretsCacheFile = "spotify-secret-dict-cache.json" + spotifySecretsCacheTTL = 24 * time.Hour ) var spotifyAnonymousTokenMu sync.Mutex @@ -41,6 +43,11 @@ type spotifyServerTimeResponse struct { ServerTime int64 `json:"serverTime"` } +type spotifySecretsCache struct { + FetchedAtUnix int64 `json:"fetched_at_unix"` + Secrets map[string][]int `json:"secrets"` +} + type spotifyTrackRawData struct { ExternalID []struct { Type string `json:"type"` @@ -48,6 +55,19 @@ type spotifyTrackRawData struct { } `json:"external_id"` } +type spotFetchISRCResponse struct { + Input string `json:"input"` + TrackID string `json:"track_id"` + GID string `json:"gid"` + CanonicalURI string `json:"canonical_uri"` + Name string `json:"name"` + Artists []string `json:"artists"` + AlbumName string `json:"album_name"` + ReleaseDate string `json:"release_date"` + Label string `json:"label"` + ISRC string `json:"isrc"` +} + func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error) { normalizedTrackID, err := extractSpotifyTrackID(spotifyTrackID) if err != nil { @@ -62,6 +82,26 @@ func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error 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) if err != nil { return "", err @@ -79,6 +119,49 @@ func (s *SongLinkClient) lookupSpotifyISRC(spotifyTrackID string) (string, error 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) { req, err := http.NewRequest(http.MethodGet, targetURL, nil) if err != nil { @@ -168,6 +251,50 @@ 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 { @@ -177,6 +304,15 @@ 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 @@ -185,6 +321,14 @@ 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 @@ -237,8 +381,30 @@ func requestSpotifyAnonymousAccessToken(client *http.Client) (string, error) { } var secrets map[string][]int - if err := requestSpotifyJSON(client, spotifyTOTPSecretsURL, nil, &secrets); err != nil { - return "", err + cachedSecrets, err := loadSpotifyCachedSecrets() + if err != nil { + fmt.Printf("Warning: failed to read Spotify secrets cache: %v\n", err) + } + + if spotifySecretsCacheIsValid(cachedSecrets) { + secrets = cachedSecrets.Secrets + } else { + if err := requestSpotifyJSON(client, spotifyTOTPSecretsURL, nil, &secrets); err != nil { + if cachedSecrets != nil && len(cachedSecrets.Secrets) > 0 { + fmt.Printf("Warning: failed to refresh Spotify secrets cache, using stale cache: %v\n", err) + secrets = cachedSecrets.Secrets + } else { + return "", err + } + } else { + cache := &spotifySecretsCache{ + FetchedAtUnix: time.Now().Unix(), + Secrets: secrets, + } + if err := saveSpotifyCachedSecrets(cache); err != nil { + fmt.Printf("Warning: failed to write Spotify secrets cache: %v\n", err) + } + } } version, err := latestSpotifySecretVersion(secrets)