From 93e77aeb84046b5f37d6e0908f4d59c5f74ec35d Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 30 Mar 2026 23:23:24 +0700 Subject: [PATCH] refactor: remove legacy API clients, Yoinkify fallback, and unused lyrics provider - Delete dead metadata client and extract shared types to metadata_types.go - Remove Yoinkify download fallback from Deezer, use MusicDL only - Clean up retired settings fields and metadataSource - Remove dead l10n keys for retired provider - Add migration to strip retired provider from existing users' lyrics config --- go_backend/deezer_download.go | 199 +-- go_backend/lyrics.go | 245 +--- go_backend/metadata_types.go | 145 +++ go_backend/spotify.go | 1090 ----------------- lib/l10n/app_localizations.dart | 6 - lib/l10n/app_localizations_de.dart | 4 - lib/l10n/app_localizations_en.dart | 4 - lib/l10n/app_localizations_es.dart | 4 - lib/l10n/app_localizations_fr.dart | 4 - lib/l10n/app_localizations_hi.dart | 4 - lib/l10n/app_localizations_id.dart | 4 - lib/l10n/app_localizations_ja.dart | 4 - lib/l10n/app_localizations_ko.dart | 4 - lib/l10n/app_localizations_nl.dart | 4 - lib/l10n/app_localizations_pt.dart | 4 - lib/l10n/app_localizations_ru.dart | 4 - lib/l10n/app_localizations_tr.dart | 4 - lib/l10n/app_localizations_zh.dart | 4 - lib/l10n/arb/app_en.arb | 4 - lib/models/settings.dart | 18 - lib/models/settings.g.dart | 18 +- lib/providers/extension_provider.dart | 3 +- lib/providers/settings_provider.dart | 68 +- .../settings/download_settings_page.dart | 1 - .../lyrics_provider_priority_page.dart | 11 +- .../settings/options_settings_page.dart | 16 +- lib/screens/setup_screen.dart | 1 - 27 files changed, 198 insertions(+), 1679 deletions(-) create mode 100644 go_backend/metadata_types.go delete mode 100644 go_backend/spotify.go diff --git a/go_backend/deezer_download.go b/go_backend/deezer_download.go index cf706e9f..2db32c39 100644 --- a/go_backend/deezer_download.go +++ b/go_backend/deezer_download.go @@ -14,15 +14,8 @@ import ( "strings" ) -const deezerYoinkifyURL = "https://yoinkify.lol/api/download" const deezerMusicDLURL = "https://www.musicdl.me/api/download" -type YoinkifyRequest struct { - URL string `json:"url"` - Format string `json:"format"` - GenreSource string `json:"genreSource"` -} - type DeezerDownloadResult struct { FilePath string BitDepth int @@ -37,41 +30,6 @@ type DeezerDownloadResult struct { LyricsLRC string } -func resolveSpotifyURLForYoinkify(req DownloadRequest) (string, error) { - rawSpotify := strings.TrimSpace(req.SpotifyID) - if rawSpotify != "" { - if isLikelySpotifyTrackID(rawSpotify) { - return fmt.Sprintf("https://open.spotify.com/track/%s", rawSpotify), nil - } - - if parsed, err := parseSpotifyURI(rawSpotify); err == nil && parsed.Type == "track" && parsed.ID != "" { - return fmt.Sprintf("https://open.spotify.com/track/%s", parsed.ID), nil - } - } - - deezerID := strings.TrimSpace(req.DeezerID) - if deezerID == "" { - if prefixed, found := strings.CutPrefix(rawSpotify, "deezer:"); found { - deezerID = strings.TrimSpace(prefixed) - } - } - - if deezerID != "" { - songlink := NewSongLinkClient() - spotifyID, err := songlink.GetSpotifyIDFromDeezer(deezerID) - if err != nil { - return "", fmt.Errorf("failed to map deezer:%s to Spotify ID: %w", deezerID, err) - } - spotifyID = strings.TrimSpace(spotifyID) - if spotifyID == "" { - return "", fmt.Errorf("SongLink returned empty Spotify ID for deezer:%s", deezerID) - } - return fmt.Sprintf("https://open.spotify.com/track/%s", spotifyID), nil - } - - return "", fmt.Errorf("missing Spotify track ID for Deezer Yoinkify") -} - func isLikelySpotifyTrackID(value string) bool { if len(value) != 22 { return false @@ -88,113 +46,6 @@ func isLikelySpotifyTrackID(value string) bool { return true } -func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outputFD int, itemID string) error { - payload := YoinkifyRequest{ - URL: spotifyURL, - Format: "flac", - GenreSource: "spotify", - } - - jsonData, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to encode Yoinkify request: %w", err) - } - - ctx := context.Background() - if itemID != "" { - StartItemProgress(itemID) - defer CompleteItemProgress(itemID) - ctx = initDownloadCancel(itemID) - defer clearDownloadCancel(itemID) - } - - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, deezerYoinkifyURL, bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("failed to create Yoinkify request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "*/*") - req.Header.Set("User-Agent", getRandomUserAgent()) - - resp, err := GetDownloadClient().Do(req) - if err != nil { - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - return fmt.Errorf("failed to call Yoinkify: %w", err) - } - defer resp.Body.Close() - - contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type"))) - if resp.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - bodyText := strings.TrimSpace(string(bodyBytes)) - if bodyText != "" { - return fmt.Errorf("Yoinkify returned status %d: %s", resp.StatusCode, bodyText) - } - return fmt.Errorf("Yoinkify returned status %d", resp.StatusCode) - } - - if strings.Contains(contentType, "application/json") { - bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - bodyText := strings.TrimSpace(string(bodyBytes)) - if bodyText == "" { - bodyText = "empty JSON payload" - } - return fmt.Errorf("Yoinkify returned JSON instead of audio: %s", bodyText) - } - - expectedSize := resp.ContentLength - if expectedSize > 0 && itemID != "" { - SetItemBytesTotal(itemID, expectedSize) - } - - out, err := openOutputForWrite(outputPath, outputFD) - if err != nil { - return err - } - - bufWriter := bufio.NewWriterSize(out, 256*1024) - var written int64 - if itemID != "" { - pw := NewItemProgressWriter(bufWriter, itemID) - written, err = io.Copy(pw, resp.Body) - } else { - written, err = io.Copy(bufWriter, resp.Body) - } - - flushErr := bufWriter.Flush() - closeErr := out.Close() - - if err != nil { - cleanupOutputOnError(outputPath, outputFD) - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - return fmt.Errorf("download interrupted: %w", err) - } - if flushErr != nil { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("failed to flush output: %w", flushErr) - } - if closeErr != nil { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("failed to close output: %w", closeErr) - } - - if expectedSize > 0 && written != expectedSize { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) - } - - GoLog("[Deezer] Downloaded via Yoinkify: %.2f MB\n", float64(written)/(1024*1024)) - return nil -} - func resolveDeezerTrackURL(req DownloadRequest) (string, error) { deezerID := strings.TrimSpace(req.DeezerID) if deezerID == "" { @@ -479,41 +330,29 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) { ) }() - // Try MusicDL first (better quality), fallback to Yoinkify - var downloadErr error deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req) - if deezerURLErr == nil { - GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL) - downloadErr = deezerClient.DownloadFromMusicDL(deezerTrackURL, outputPath, req.OutputFD, req.ItemID) - if downloadErr != nil { - if errors.Is(downloadErr, ErrDownloadCancelled) { - return DeezerDownloadResult{}, ErrDownloadCancelled - } - GoLog("[Deezer] MusicDL failed: %v, falling back to Yoinkify\n", downloadErr) - } - } else { - GoLog("[Deezer] Could not resolve Deezer URL: %v, using Yoinkify directly\n", deezerURLErr) + if deezerURLErr != nil { + return DeezerDownloadResult{}, fmt.Errorf( + "deezer download failed: could not resolve Deezer URL: %w", + deezerURLErr, + ) } - if downloadErr != nil || deezerURLErr != nil { - spotifyURL, err := resolveSpotifyURLForYoinkify(req) - if err != nil { - if deezerURLErr != nil { - return DeezerDownloadResult{}, fmt.Errorf( - "deezer download failed: direct Deezer resolution error: %v; Yoinkify fallback error: %w", - deezerURLErr, - err, - ) - } - return DeezerDownloadResult{}, err - } - downloadErr = deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID) - if downloadErr != nil { - if errors.Is(downloadErr, ErrDownloadCancelled) { - return DeezerDownloadResult{}, ErrDownloadCancelled - } - return DeezerDownloadResult{}, fmt.Errorf("deezer download failed (MusicDL + Yoinkify): %w", downloadErr) + GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL) + downloadErr := deezerClient.DownloadFromMusicDL( + deezerTrackURL, + outputPath, + req.OutputFD, + req.ItemID, + ) + if downloadErr != nil { + if errors.Is(downloadErr, ErrDownloadCancelled) { + return DeezerDownloadResult{}, ErrDownloadCancelled } + return DeezerDownloadResult{}, fmt.Errorf( + "deezer download failed via MusicDL: %w", + downloadErr, + ) } <-parallelDone diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index cd4c7906..03d7757a 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -3,7 +3,6 @@ package gobackend import ( "encoding/json" "fmt" - "io" "math" "net/http" "net/url" @@ -23,7 +22,6 @@ const ( // Lyrics provider names (used in settings and cascade ordering) const ( - LyricsProviderSpotifyAPI = "spotify_api" LyricsProviderLRCLIB = "lrclib" LyricsProviderNetease = "netease" LyricsProviderMusixmatch = "musixmatch" @@ -35,7 +33,6 @@ const ( // LRCLIB first (no proxy dependency), then the others. var DefaultLyricsProviders = []string{ LyricsProviderLRCLIB, - LyricsProviderSpotifyAPI, LyricsProviderMusixmatch, LyricsProviderNetease, LyricsProviderAppleMusic, @@ -47,11 +44,6 @@ var ( lyricsProviders []string // ordered list of enabled providers ) -var ( - spotifyLyricsRateLimitMu sync.RWMutex - spotifyLyricsRateLimitedTil time.Time -) - // LyricsFetchOptions controls optional provider-specific enhancements. type LyricsFetchOptions struct { IncludeTranslationNetease bool `json:"include_translation_netease"` @@ -84,7 +76,6 @@ func SetLyricsProviderOrder(providers []string) { } validNames := map[string]bool{ - LyricsProviderSpotifyAPI: true, LyricsProviderLRCLIB: true, LyricsProviderNetease: true, LyricsProviderMusixmatch: true, @@ -119,7 +110,6 @@ func GetLyricsProviderOrder() []string { func GetAvailableLyricsProviders() []map[string]interface{} { return []map[string]interface{}{ - {"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"}, {"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"}, {"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"}, {"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"}, @@ -249,18 +239,6 @@ type LRCLibResponse struct { SyncedLyrics string `json:"syncedLyrics"` } -type SpotifyLyricsLine struct { - TimeTag string `json:"timeTag"` - Words string `json:"words"` -} - -type SpotifyLyricsAPIResponse struct { - Error bool `json:"error"` - Message string `json:"message"` - SyncType string `json:"syncType"` - Lines []SpotifyLyricsLine `json:"lines"` -} - type LyricsLine struct { StartTimeMs int64 `json:"startTimeMs"` Words string `json:"words"` @@ -368,214 +346,6 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo return c.parseLRCLibResponse(&results[0]), nil } -func parseSpotifyLyricsTimeTagToMs(tag string) int64 { - raw := strings.TrimSpace(tag) - raw = strings.TrimPrefix(raw, "[") - raw = strings.TrimSuffix(raw, "]") - if raw == "" { - return 0 - } - - if ms, err := strconv.ParseInt(raw, 10, 64); err == nil { - return ms - } - - re := regexp.MustCompile(`^(\d{1,2}):(\d{2})\.(\d{1,3})$`) - matches := re.FindStringSubmatch(raw) - if len(matches) != 4 { - return 0 - } - - minutes, _ := strconv.ParseInt(matches[1], 10, 64) - seconds, _ := strconv.ParseInt(matches[2], 10, 64) - fraction := matches[3] - fractionInt, _ := strconv.ParseInt(fraction, 10, 64) - if len(fraction) == 2 { - fractionInt *= 10 - } else if len(fraction) == 1 { - fractionInt *= 100 - } - return minutes*60*1000 + seconds*1000 + fractionInt -} - -func getSpotifyLyricsRateLimitUntil() time.Time { - spotifyLyricsRateLimitMu.RLock() - defer spotifyLyricsRateLimitMu.RUnlock() - return spotifyLyricsRateLimitedTil -} - -func setSpotifyLyricsRateLimitUntil(until time.Time) { - spotifyLyricsRateLimitMu.Lock() - spotifyLyricsRateLimitedTil = until - spotifyLyricsRateLimitMu.Unlock() -} - -func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time { - raw := strings.TrimSpace(retryAfter) - if raw == "" { - return now.Add(10 * time.Minute) - } - - if sec, err := strconv.Atoi(raw); err == nil && sec > 0 { - return now.Add(time.Duration(sec) * time.Second) - } - - if when, err := http.ParseTime(raw); err == nil && when.After(now) { - return when - } - - return now.Add(10 * time.Minute) -} - -func buildSpotifyLyricsResponse(lines []LyricsLine, syncType, plainLyrics string) (*LyricsResponse, error) { - if len(lines) == 0 { - return nil, fmt.Errorf("Spotify Lyrics API returned empty lines") - } - if syncType == "" { - if len(lines) > 0 && lines[0].StartTimeMs > 0 { - syncType = "LINE_SYNCED" - } else { - syncType = "UNSYNCED" - } - } - return &LyricsResponse{ - Lines: lines, - SyncType: syncType, - Instrumental: false, - PlainLyrics: plainLyrics, - Provider: "Spotify Lyrics API", - Source: "Spotify Lyrics API", - }, nil -} - -func plainLyricsFromTimedLines(lines []LyricsLine) string { - parts := make([]string, 0, len(lines)) - for _, line := range lines { - words := strings.TrimSpace(line.Words) - if words == "" { - continue - } - parts = append(parts, words) - } - return strings.Join(parts, "\n") -} - -func parseSpotifyLyricsResponseBody(body []byte) (*LyricsResponse, error) { - var lrcPayload string - if err := json.Unmarshal(body, &lrcPayload); err == nil { - trimmed := strings.TrimSpace(lrcPayload) - if trimmed == "" { - return nil, fmt.Errorf("Spotify Lyrics API returned empty payload") - } - - lines := parseSyncedLyrics(trimmed) - if len(lines) > 0 { - return buildSpotifyLyricsResponse(lines, "LINE_SYNCED", plainLyricsFromTimedLines(lines)) - } - - plainLines := plainTextLyricsLines(trimmed) - return buildSpotifyLyricsResponse(plainLines, "UNSYNCED", trimmed) - } - - var apiResp SpotifyLyricsAPIResponse - if err := json.Unmarshal(body, &apiResp); err != nil { - return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err) - } - - if apiResp.Error { - msg := strings.TrimSpace(apiResp.Message) - if msg == "" { - msg = "Spotify Lyrics API returned error" - } - return nil, fmt.Errorf("%s", msg) - } - - lines := make([]LyricsLine, 0, len(apiResp.Lines)) - for _, line := range apiResp.Lines { - words := strings.TrimSpace(line.Words) - if words == "" { - continue - } - startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag) - lines = append(lines, LyricsLine{ - StartTimeMs: startMs, - Words: words, - EndTimeMs: 0, - }) - } - - for i := 0; i < len(lines)-1; i++ { - nextStart := lines[i+1].StartTimeMs - if nextStart > lines[i].StartTimeMs { - lines[i].EndTimeMs = nextStart - } - } - if len(lines) > 0 { - last := len(lines) - 1 - if lines[last].EndTimeMs == 0 { - lines[last].EndTimeMs = lines[last].StartTimeMs + 5000 - } - } - - return buildSpotifyLyricsResponse(lines, apiResp.SyncType, plainLyricsFromTimedLines(lines)) -} - -func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) { - now := time.Now() - if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) { - waitFor := int(math.Ceil(limitedUntil.Sub(now).Seconds())) - return nil, fmt.Errorf( - "Spotify Lyrics API cooldown active (%ds remaining after previous 429)", - waitFor, - ) - } - - spotifyID = strings.TrimSpace(spotifyID) - if spotifyID == "" { - return nil, fmt.Errorf("spotify ID is empty") - } - if parsed, err := parseSpotifyURI(spotifyID); err == nil && parsed.Type == "track" && parsed.ID != "" { - spotifyID = parsed.ID - } - - apiURL := fmt.Sprintf("https://lyrics.paxsenix.org/spotify/lyrics?id=%s", url.QueryEscape(spotifyID)) - req, err := http.NewRequest("GET", apiURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("User-Agent", getRandomUserAgent()) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch from Spotify Lyrics API: %w", err) - } - defer resp.Body.Close() - - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read Spotify Lyrics API response: %w", err) - } - - if resp.StatusCode != 200 { - if resp.StatusCode == http.StatusTooManyRequests { - retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now) - setSpotifyLyricsRateLimitUntil(retryUntil) - } - var payload map[string]interface{} - if err := json.Unmarshal(bodyBytes, &payload); err == nil { - if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" { - return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg)) - } - if msg, ok := payload["error"].(string); ok && strings.TrimSpace(msg) != "" { - return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg)) - } - } - return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode) - } - - return parseSpotifyLyricsResponseBody(bodyBytes) -} - func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse { var bestSynced *LRCLibResponse var bestPlain *LRCLibResponse @@ -600,6 +370,18 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec return bestPlain } +func plainLyricsFromTimedLines(lines []LyricsLine) string { + parts := make([]string, 0, len(lines)) + for _, line := range lines { + words := strings.TrimSpace(line.Words) + if words == "" { + continue + } + parts = append(parts, words) + } + return strings.Join(parts, "\n") +} + func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool { diff := math.Abs(lrcDuration - targetDuration) return diff <= durationToleranceSec @@ -669,9 +451,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st var err error switch providerName { - case LyricsProviderSpotifyAPI: - lyrics, err = c.FetchLyricsFromSpotifyAPI(spotifyID) - case LyricsProviderLRCLIB: lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec) diff --git a/go_backend/metadata_types.go b/go_backend/metadata_types.go new file mode 100644 index 00000000..e2523e96 --- /dev/null +++ b/go_backend/metadata_types.go @@ -0,0 +1,145 @@ +package gobackend + +import "time" + +type cacheEntry struct { + data interface{} + expiresAt time.Time +} + +func (e *cacheEntry) isExpired() bool { + return time.Now().After(e.expiresAt) +} + +type TrackMetadata struct { + SpotifyID string `json:"spotify_id,omitempty"` + Artists string `json:"artists"` + Name string `json:"name"` + AlbumName string `json:"album_name"` + AlbumArtist string `json:"album_artist,omitempty"` + DurationMS int `json:"duration_ms"` + Images string `json:"images"` + ReleaseDate string `json:"release_date"` + TrackNumber int `json:"track_number"` + TotalTracks int `json:"total_tracks,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + ExternalURL string `json:"external_urls"` + ISRC string `json:"isrc"` + AlbumID string `json:"album_id,omitempty"` + ArtistID string `json:"artist_id,omitempty"` + AlbumType string `json:"album_type,omitempty"` +} + +type AlbumTrackMetadata struct { + SpotifyID string `json:"spotify_id,omitempty"` + Artists string `json:"artists"` + Name string `json:"name"` + AlbumName string `json:"album_name"` + AlbumArtist string `json:"album_artist,omitempty"` + DurationMS int `json:"duration_ms"` + Images string `json:"images"` + ReleaseDate string `json:"release_date"` + TrackNumber int `json:"track_number"` + TotalTracks int `json:"total_tracks,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + ExternalURL string `json:"external_urls"` + ISRC string `json:"isrc"` + AlbumID string `json:"album_id,omitempty"` + AlbumURL string `json:"album_url,omitempty"` + AlbumType string `json:"album_type,omitempty"` +} + +type AlbumInfoMetadata struct { + TotalTracks int `json:"total_tracks"` + Name string `json:"name"` + ReleaseDate string `json:"release_date"` + Artists string `json:"artists"` + ArtistId string `json:"artist_id,omitempty"` + Images string `json:"images"` + Genre string `json:"genre,omitempty"` + Label string `json:"label,omitempty"` + Copyright string `json:"copyright,omitempty"` +} + +type AlbumResponsePayload struct { + AlbumInfo AlbumInfoMetadata `json:"album_info"` + TrackList []AlbumTrackMetadata `json:"track_list"` +} + +type PlaylistInfoMetadata struct { + Name string `json:"name,omitempty"` + Images string `json:"images,omitempty"` + Tracks struct { + Total int `json:"total"` + } `json:"tracks"` + Owner struct { + DisplayName string `json:"display_name"` + Name string `json:"name"` + Images string `json:"images"` + } `json:"owner"` +} + +type PlaylistResponsePayload struct { + PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"` + TrackList []AlbumTrackMetadata `json:"track_list"` +} + +type ArtistInfoMetadata struct { + ID string `json:"id"` + Name string `json:"name"` + Images string `json:"images"` + Followers int `json:"followers"` + Popularity int `json:"popularity"` +} + +type ArtistAlbumMetadata struct { + ID string `json:"id"` + Name string `json:"name"` + ReleaseDate string `json:"release_date"` + TotalTracks int `json:"total_tracks"` + Images string `json:"images"` + AlbumType string `json:"album_type"` + Artists string `json:"artists"` +} + +type ArtistResponsePayload struct { + ArtistInfo ArtistInfoMetadata `json:"artist_info"` + Albums []ArtistAlbumMetadata `json:"albums"` +} + +type TrackResponse struct { + Track TrackMetadata `json:"track"` +} + +type SearchArtistResult struct { + ID string `json:"id"` + Name string `json:"name"` + Images string `json:"images"` + Followers int `json:"followers"` + Popularity int `json:"popularity"` +} + +type SearchAlbumResult struct { + ID string `json:"id"` + Name string `json:"name"` + Artists string `json:"artists"` + Images string `json:"images"` + ReleaseDate string `json:"release_date"` + TotalTracks int `json:"total_tracks"` + AlbumType string `json:"album_type"` +} + +type SearchPlaylistResult struct { + ID string `json:"id"` + Name string `json:"name"` + Owner string `json:"owner"` + Images string `json:"images"` + TotalTracks int `json:"total_tracks"` +} + +type SearchAllResult struct { + Tracks []TrackMetadata `json:"tracks"` + Artists []SearchArtistResult `json:"artists"` + Albums []SearchAlbumResult `json:"albums"` + Playlists []SearchPlaylistResult `json:"playlists"` +} diff --git a/go_backend/spotify.go b/go_backend/spotify.go deleted file mode 100644 index 0514c5d4..00000000 --- a/go_backend/spotify.go +++ /dev/null @@ -1,1090 +0,0 @@ -package gobackend - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "math/rand" - "net/http" - "net/url" - "strings" - "sync" - "time" -) - -const ( - spotifyTokenURL = "https://accounts.spotify.com/api/token" - playlistBaseURL = "https://api.spotify.com/v1/playlists/%s" - albumBaseURL = "https://api.spotify.com/v1/albums/%s" - trackBaseURL = "https://api.spotify.com/v1/tracks/%s" - artistBaseURL = "https://api.spotify.com/v1/artists/%s" - artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums" - artistRelatedURL = "https://api.spotify.com/v1/artists/%s/related-artists" - searchBaseURL = "https://api.spotify.com/v1/search" - - artistCacheTTL = 10 * time.Minute - searchCacheTTL = 5 * time.Minute - albumCacheTTL = 10 * time.Minute -) - -var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL") - -type cacheEntry struct { - data interface{} - expiresAt time.Time -} - -func (e *cacheEntry) isExpired() bool { - return time.Now().After(e.expiresAt) -} - -type SpotifyMetadataClient struct { - httpClient *http.Client - clientID string - clientSecret string - cachedToken string - tokenExpiresAt time.Time - tokenMu sync.Mutex - rng *rand.Rand - rngMu sync.Mutex - userAgent string - - artistCache map[string]*cacheEntry - searchCache map[string]*cacheEntry - albumCache map[string]*cacheEntry - cacheMu sync.RWMutex -} - -var ( - customClientID string - customClientSecret string - credentialsMu sync.RWMutex -) - -var ErrNoSpotifyCredentials = errors.New("built-in Spotify API metadata provider has been removed; use Deezer or the spotify-web extension instead") - -func SetSpotifyCredentials(clientID, clientSecret string) { - credentialsMu.Lock() - defer credentialsMu.Unlock() - customClientID = "" - customClientSecret = "" -} - -func HasSpotifyCredentials() bool { - return false -} - -func getCredentials() (string, string, error) { - return "", "", ErrNoSpotifyCredentials -} - -func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) { - clientID, clientSecret, err := getCredentials() - if err != nil { - return nil, err - } - - src := rand.NewSource(time.Now().UnixNano()) - - c := &SpotifyMetadataClient{ - httpClient: NewMetadataHTTPClient(15 * time.Second), - clientID: clientID, - clientSecret: clientSecret, - rng: rand.New(src), - artistCache: make(map[string]*cacheEntry), - searchCache: make(map[string]*cacheEntry), - albumCache: make(map[string]*cacheEntry), - } - c.userAgent = c.randomUserAgent() - return c, nil -} - -type TrackMetadata struct { - SpotifyID string `json:"spotify_id,omitempty"` - Artists string `json:"artists"` - Name string `json:"name"` - AlbumName string `json:"album_name"` - AlbumArtist string `json:"album_artist,omitempty"` - DurationMS int `json:"duration_ms"` - Images string `json:"images"` - ReleaseDate string `json:"release_date"` - TrackNumber int `json:"track_number"` - TotalTracks int `json:"total_tracks,omitempty"` - DiscNumber int `json:"disc_number,omitempty"` - ExternalURL string `json:"external_urls"` - ISRC string `json:"isrc"` - AlbumID string `json:"album_id,omitempty"` - ArtistID string `json:"artist_id,omitempty"` - AlbumType string `json:"album_type,omitempty"` -} - -type AlbumTrackMetadata struct { - SpotifyID string `json:"spotify_id,omitempty"` - Artists string `json:"artists"` - Name string `json:"name"` - AlbumName string `json:"album_name"` - AlbumArtist string `json:"album_artist,omitempty"` - DurationMS int `json:"duration_ms"` - Images string `json:"images"` - ReleaseDate string `json:"release_date"` - TrackNumber int `json:"track_number"` - TotalTracks int `json:"total_tracks,omitempty"` - DiscNumber int `json:"disc_number,omitempty"` - ExternalURL string `json:"external_urls"` - ISRC string `json:"isrc"` - AlbumID string `json:"album_id,omitempty"` - AlbumURL string `json:"album_url,omitempty"` - AlbumType string `json:"album_type,omitempty"` -} - -type AlbumInfoMetadata struct { - TotalTracks int `json:"total_tracks"` - Name string `json:"name"` - ReleaseDate string `json:"release_date"` - Artists string `json:"artists"` - ArtistId string `json:"artist_id,omitempty"` - Images string `json:"images"` - Genre string `json:"genre,omitempty"` - Label string `json:"label,omitempty"` - Copyright string `json:"copyright,omitempty"` -} - -type AlbumResponsePayload struct { - AlbumInfo AlbumInfoMetadata `json:"album_info"` - TrackList []AlbumTrackMetadata `json:"track_list"` -} - -type PlaylistInfoMetadata struct { - Name string `json:"name,omitempty"` - Images string `json:"images,omitempty"` - Tracks struct { - Total int `json:"total"` - } `json:"tracks"` - Owner struct { - DisplayName string `json:"display_name"` - Name string `json:"name"` - Images string `json:"images"` - } `json:"owner"` -} - -type PlaylistResponsePayload struct { - PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"` - TrackList []AlbumTrackMetadata `json:"track_list"` -} - -type ArtistInfoMetadata struct { - ID string `json:"id"` - Name string `json:"name"` - Images string `json:"images"` - Followers int `json:"followers"` - Popularity int `json:"popularity"` -} - -type ArtistAlbumMetadata struct { - ID string `json:"id"` - Name string `json:"name"` - ReleaseDate string `json:"release_date"` - TotalTracks int `json:"total_tracks"` - Images string `json:"images"` - AlbumType string `json:"album_type"` - Artists string `json:"artists"` -} - -type ArtistResponsePayload struct { - ArtistInfo ArtistInfoMetadata `json:"artist_info"` - Albums []ArtistAlbumMetadata `json:"albums"` -} - -type TrackResponse struct { - Track TrackMetadata `json:"track"` -} - -type SearchResult struct { - Tracks []TrackMetadata `json:"tracks"` - Total int `json:"total"` -} - -type SearchArtistResult struct { - ID string `json:"id"` - Name string `json:"name"` - Images string `json:"images"` - Followers int `json:"followers"` - Popularity int `json:"popularity"` -} - -type SearchAlbumResult struct { - ID string `json:"id"` - Name string `json:"name"` - Artists string `json:"artists"` - Images string `json:"images"` - ReleaseDate string `json:"release_date"` - TotalTracks int `json:"total_tracks"` - AlbumType string `json:"album_type"` -} - -type SearchPlaylistResult struct { - ID string `json:"id"` - Name string `json:"name"` - Owner string `json:"owner"` - Images string `json:"images"` - TotalTracks int `json:"total_tracks"` -} - -type SearchAllResult struct { - Tracks []TrackMetadata `json:"tracks"` - Artists []SearchArtistResult `json:"artists"` - Albums []SearchAlbumResult `json:"albums"` - Playlists []SearchPlaylistResult `json:"playlists"` -} - -type spotifyURI struct { - Type string - ID string -} - -type accessTokenResponse struct { - AccessToken string `json:"access_token"` - ExpiresIn interface{} `json:"expires_in"` - TokenType string `json:"token_type"` -} - -type image struct { - URL string `json:"url"` -} - -type externalURL struct { - Spotify string `json:"spotify"` -} - -type externalID struct { - ISRC string `json:"isrc"` -} - -type artist struct { - ID string `json:"id"` - Name string `json:"name"` -} - -type albumSimplified struct { - ID string `json:"id"` - Name string `json:"name"` - ReleaseDate string `json:"release_date"` - TotalTracks int `json:"total_tracks"` - Images []image `json:"images"` - ExternalURL externalURL `json:"external_urls"` - Artists []artist `json:"artists"` - AlbumType string `json:"album_type"` -} - -type trackFull struct { - ID string `json:"id"` - Name string `json:"name"` - DurationMS int `json:"duration_ms"` - TrackNumber int `json:"track_number"` - DiscNumber int `json:"disc_number"` - ExternalURL externalURL `json:"external_urls"` - ExternalID externalID `json:"external_ids"` - Album albumSimplified `json:"album"` - Artists []artist `json:"artists"` -} - -func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) { - parsed, err := parseSpotifyURI(spotifyURL) - if err != nil { - return nil, err - } - - token, err := c.getAccessToken(ctx) - if err != nil { - return nil, err - } - - switch parsed.Type { - case "track": - return c.fetchTrack(ctx, parsed.ID, token) - case "album": - return c.fetchAlbum(ctx, parsed.ID, token) - case "playlist": - return c.fetchPlaylist(ctx, parsed.ID, token) - case "artist": - return c.fetchArtist(ctx, parsed.ID, token) - default: - return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type) - } -} - -func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, limit int) (*SearchResult, error) { - token, err := c.getAccessToken(ctx) - if err != nil { - return nil, err - } - - searchURL := fmt.Sprintf("%s?q=%s&type=track&limit=%d", searchBaseURL, url.QueryEscape(query), limit) - - var response struct { - Tracks struct { - Items []trackFull `json:"items"` - Total int `json:"total"` - } `json:"tracks"` - } - - if err := c.getJSON(ctx, searchURL, token, &response); err != nil { - return nil, err - } - - result := &SearchResult{ - Tracks: make([]TrackMetadata, 0, len(response.Tracks.Items)), - Total: response.Tracks.Total, - } - - for _, track := range response.Tracks.Items { - var firstArtistID string - if len(track.Artists) > 0 { - firstArtistID = track.Artists[0].ID - } - result.Tracks = append(result.Tracks, TrackMetadata{ - SpotifyID: track.ID, - Artists: joinArtists(track.Artists), - Name: track.Name, - AlbumName: track.Album.Name, - AlbumArtist: joinArtists(track.Album.Artists), - DurationMS: track.DurationMS, - Images: firstImageURL(track.Album.Images), - ReleaseDate: track.Album.ReleaseDate, - TrackNumber: track.TrackNumber, - TotalTracks: track.Album.TotalTracks, - DiscNumber: track.DiscNumber, - ExternalURL: track.ExternalURL.Spotify, - ISRC: track.ExternalID.ISRC, - AlbumID: track.Album.ID, - ArtistID: firstArtistID, - AlbumType: track.Album.AlbumType, - }) - } - - return result, nil -} - -func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) { - cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit) - - c.cacheMu.RLock() - if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() { - c.cacheMu.RUnlock() - return entry.data.(*SearchAllResult), nil - } - c.cacheMu.RUnlock() - - token, err := c.getAccessToken(ctx) - if err != nil { - return nil, err - } - - searchURL := fmt.Sprintf("%s?q=%s&type=track,artist&limit=%d", searchBaseURL, url.QueryEscape(query), trackLimit) - - var response struct { - Tracks struct { - Items []trackFull `json:"items"` - } `json:"tracks"` - Artists struct { - Items []struct { - ID string `json:"id"` - Name string `json:"name"` - Images []image `json:"images"` - Followers struct { - Total int `json:"total"` - } `json:"followers"` - Popularity int `json:"popularity"` - } `json:"items"` - } `json:"artists"` - } - - if err := c.getJSON(ctx, searchURL, token, &response); err != nil { - return nil, err - } - - result := &SearchAllResult{ - Tracks: make([]TrackMetadata, 0, len(response.Tracks.Items)), - Artists: make([]SearchArtistResult, 0, len(response.Artists.Items)), - } - - for _, track := range response.Tracks.Items { - var firstArtistID string - if len(track.Artists) > 0 { - firstArtistID = track.Artists[0].ID - } - result.Tracks = append(result.Tracks, TrackMetadata{ - SpotifyID: track.ID, - Artists: joinArtists(track.Artists), - Name: track.Name, - AlbumName: track.Album.Name, - AlbumArtist: joinArtists(track.Album.Artists), - DurationMS: track.DurationMS, - Images: firstImageURL(track.Album.Images), - ReleaseDate: track.Album.ReleaseDate, - TrackNumber: track.TrackNumber, - TotalTracks: track.Album.TotalTracks, - DiscNumber: track.DiscNumber, - ExternalURL: track.ExternalURL.Spotify, - ISRC: track.ExternalID.ISRC, - AlbumID: track.Album.ID, - ArtistID: firstArtistID, - AlbumType: track.Album.AlbumType, - }) - } - - artistCount := len(response.Artists.Items) - if artistCount > artistLimit { - artistCount = artistLimit - } - - for i := 0; i < artistCount; i++ { - artist := response.Artists.Items[i] - result.Artists = append(result.Artists, SearchArtistResult{ - ID: artist.ID, - Name: artist.Name, - Images: firstImageURL(artist.Images), - Followers: artist.Followers.Total, - Popularity: artist.Popularity, - }) - } - - c.cacheMu.Lock() - c.searchCache[cacheKey] = &cacheEntry{ - data: result, - expiresAt: time.Now().Add(searchCacheTTL), - } - c.cacheMu.Unlock() - - return result, nil -} - -func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token string) (*TrackResponse, error) { - var data trackFull - if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil { - return nil, err - } - - return &TrackResponse{ - Track: TrackMetadata{ - SpotifyID: data.ID, - Artists: joinArtists(data.Artists), - Name: data.Name, - AlbumName: data.Album.Name, - AlbumArtist: joinArtists(data.Album.Artists), - DurationMS: data.DurationMS, - Images: firstImageURL(data.Album.Images), - ReleaseDate: data.Album.ReleaseDate, - TrackNumber: data.TrackNumber, - TotalTracks: data.Album.TotalTracks, - DiscNumber: data.DiscNumber, - ExternalURL: data.ExternalURL.Spotify, - ISRC: data.ExternalID.ISRC, - }, - }, nil -} - -func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) { - c.cacheMu.RLock() - if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() { - c.cacheMu.RUnlock() - return entry.data.(*AlbumResponsePayload), nil - } - c.cacheMu.RUnlock() - - type trackItem struct { - ID string `json:"id"` - Name string `json:"name"` - DurationMS int `json:"duration_ms"` - TrackNumber int `json:"track_number"` - DiscNumber int `json:"disc_number"` - ExternalURL externalURL `json:"external_urls"` - Artists []artist `json:"artists"` - } - - var data struct { - Name string `json:"name"` - ReleaseDate string `json:"release_date"` - TotalTracks int `json:"total_tracks"` - Images []image `json:"images"` - Artists []artist `json:"artists"` - Tracks struct { - Items []trackItem `json:"items"` - Next string `json:"next"` - } `json:"tracks"` - } - - if err := c.getJSON(ctx, fmt.Sprintf(albumBaseURL, albumID), token, &data); err != nil { - return nil, err - } - - albumImage := firstImageURL(data.Images) - - var firstArtistId string - if len(data.Artists) > 0 { - firstArtistId = data.Artists[0].ID - } - - info := AlbumInfoMetadata{ - TotalTracks: data.TotalTracks, - Name: data.Name, - ReleaseDate: data.ReleaseDate, - Artists: joinArtists(data.Artists), - ArtistId: firstArtistId, - Images: albumImage, - } - - allTrackItems := data.Tracks.Items - nextURL := data.Tracks.Next - - for nextURL != "" { - var pageData struct { - Items []trackItem `json:"items"` - Next string `json:"next"` - } - if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil { - fmt.Printf("[Spotify] Warning: failed to fetch album tracks page: %v\n", err) - break - } - allTrackItems = append(allTrackItems, pageData.Items...) - nextURL = pageData.Next - } - - fmt.Printf("[Spotify] Album has %d tracks (total: %d)\n", len(allTrackItems), data.TotalTracks) - - trackIDs := make([]string, len(allTrackItems)) - for i, item := range allTrackItems { - trackIDs[i] = item.ID - } - - isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token) - - tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems)) - for _, item := range allTrackItems { - isrc := isrcMap[item.ID] - - tracks = append(tracks, AlbumTrackMetadata{ - SpotifyID: item.ID, - Artists: joinArtists(item.Artists), - Name: item.Name, - AlbumName: data.Name, - AlbumArtist: joinArtists(data.Artists), - DurationMS: item.DurationMS, - Images: albumImage, - ReleaseDate: data.ReleaseDate, - TrackNumber: item.TrackNumber, - TotalTracks: data.TotalTracks, - DiscNumber: item.DiscNumber, - ExternalURL: item.ExternalURL.Spotify, - ISRC: isrc, - AlbumID: albumID, - }) - } - - result := &AlbumResponsePayload{ - AlbumInfo: info, - TrackList: tracks, - } - - c.cacheMu.Lock() - c.albumCache[albumID] = &cacheEntry{ - data: result, - expiresAt: time.Now().Add(albumCacheTTL), - } - c.cacheMu.Unlock() - - return result, nil -} - -func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string { - const maxParallelISRC = 10 - - result := make(map[string]string) - var resultMu sync.Mutex - - if len(trackIDs) == 0 { - return result - } - - sem := make(chan struct{}, maxParallelISRC) - var wg sync.WaitGroup - - for _, trackID := range trackIDs { - wg.Add(1) - go func(id string) { - defer wg.Done() - - select { - case sem <- struct{}{}: - defer func() { <-sem }() - case <-ctx.Done(): - return - } - - isrc := c.fetchTrackISRC(ctx, id, token) - - resultMu.Lock() - result[id] = isrc - resultMu.Unlock() - }(trackID) - } - - wg.Wait() - return result -} - -func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) { - var data struct { - Name string `json:"name"` - Images []image `json:"images"` - Owner struct { - DisplayName string `json:"display_name"` - } `json:"owner"` - Tracks struct { - Items []struct { - Track *trackFull `json:"track"` - } `json:"items"` - Total int `json:"total"` - Next string `json:"next"` - } `json:"tracks"` - } - - if err := c.getJSON(ctx, fmt.Sprintf(playlistBaseURL, playlistID), token, &data); err != nil { - return nil, err - } - - var info PlaylistInfoMetadata - info.Tracks.Total = data.Tracks.Total - info.Owner.DisplayName = data.Owner.DisplayName - info.Owner.Name = data.Name - info.Owner.Images = firstImageURL(data.Images) - - tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total) - - for _, item := range data.Tracks.Items { - if item.Track == nil { - continue - } - tracks = append(tracks, AlbumTrackMetadata{ - SpotifyID: item.Track.ID, - Artists: joinArtists(item.Track.Artists), - Name: item.Track.Name, - AlbumName: item.Track.Album.Name, - AlbumArtist: joinArtists(item.Track.Album.Artists), - DurationMS: item.Track.DurationMS, - Images: firstImageURL(item.Track.Album.Images), - ReleaseDate: item.Track.Album.ReleaseDate, - TrackNumber: item.Track.TrackNumber, - TotalTracks: item.Track.Album.TotalTracks, - DiscNumber: item.Track.DiscNumber, - ExternalURL: item.Track.ExternalURL.Spotify, - ISRC: item.Track.ExternalID.ISRC, - AlbumID: item.Track.Album.ID, - AlbumURL: item.Track.Album.ExternalURL.Spotify, - }) - } - - nextURL := data.Tracks.Next - - for nextURL != "" { - var pageData struct { - Items []struct { - Track *trackFull `json:"track"` - } `json:"items"` - Next string `json:"next"` - } - - if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil { - fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err) - break - } - - for _, item := range pageData.Items { - if item.Track == nil { - continue - } - tracks = append(tracks, AlbumTrackMetadata{ - SpotifyID: item.Track.ID, - Artists: joinArtists(item.Track.Artists), - Name: item.Track.Name, - AlbumName: item.Track.Album.Name, - AlbumArtist: joinArtists(item.Track.Album.Artists), - DurationMS: item.Track.DurationMS, - Images: firstImageURL(item.Track.Album.Images), - ReleaseDate: item.Track.Album.ReleaseDate, - TrackNumber: item.Track.TrackNumber, - TotalTracks: item.Track.Album.TotalTracks, - DiscNumber: item.Track.DiscNumber, - ExternalURL: item.Track.ExternalURL.Spotify, - ISRC: item.Track.ExternalID.ISRC, - AlbumID: item.Track.Album.ID, - AlbumURL: item.Track.Album.ExternalURL.Spotify, - }) - } - - nextURL = pageData.Next - } - - fmt.Printf("[Spotify] Fetched %d tracks from playlist (total: %d)\n", len(tracks), data.Tracks.Total) - - return &PlaylistResponsePayload{ - PlaylistInfo: info, - TrackList: tracks, - }, nil -} - -func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) { - c.cacheMu.RLock() - if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() { - c.cacheMu.RUnlock() - return entry.data.(*ArtistResponsePayload), nil - } - c.cacheMu.RUnlock() - - var artistData struct { - ID string `json:"id"` - Name string `json:"name"` - Images []image `json:"images"` - Followers struct { - Total int `json:"total"` - } `json:"followers"` - Popularity int `json:"popularity"` - } - - if err := c.getJSON(ctx, fmt.Sprintf(artistBaseURL, artistID), token, &artistData); err != nil { - return nil, err - } - - artistInfo := ArtistInfoMetadata{ - ID: artistData.ID, - Name: artistData.Name, - Images: firstImageURL(artistData.Images), - Followers: artistData.Followers.Total, - Popularity: artistData.Popularity, - } - - albums := make([]ArtistAlbumMetadata, 0) - offset := 0 - limit := 50 - - for { - albumsURL := fmt.Sprintf("%s?include_groups=album,single,compilation&limit=%d&offset=%d", - fmt.Sprintf(artistAlbumsURL, artistID), limit, offset) - - var albumsData struct { - Items []struct { - ID string `json:"id"` - Name string `json:"name"` - ReleaseDate string `json:"release_date"` - TotalTracks int `json:"total_tracks"` - Images []image `json:"images"` - AlbumType string `json:"album_type"` - Artists []artist `json:"artists"` - ExternalURL externalURL `json:"external_urls"` - } `json:"items"` - Next string `json:"next"` - Total int `json:"total"` - } - - if err := c.getJSON(ctx, albumsURL, token, &albumsData); err != nil { - return nil, err - } - - for _, album := range albumsData.Items { - albums = append(albums, ArtistAlbumMetadata{ - ID: album.ID, - Name: album.Name, - ReleaseDate: album.ReleaseDate, - TotalTracks: album.TotalTracks, - Images: firstImageURL(album.Images), - AlbumType: album.AlbumType, - Artists: joinArtists(album.Artists), - }) - } - - if albumsData.Next == "" || len(albumsData.Items) < limit { - break - } - offset += limit - - if offset > 500 { - break - } - } - - result := &ArtistResponsePayload{ - ArtistInfo: artistInfo, - Albums: albums, - } - - c.cacheMu.Lock() - c.artistCache[artistID] = &cacheEntry{ - data: result, - expiresAt: time.Now().Add(artistCacheTTL), - } - c.cacheMu.Unlock() - - return result, nil -} - -func (c *SpotifyMetadataClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) { - token, err := c.getAccessToken(ctx) - if err != nil { - return nil, err - } - - var data struct { - Artists []struct { - ID string `json:"id"` - Name string `json:"name"` - Images []image `json:"images"` - Followers struct { - Total int `json:"total"` - } `json:"followers"` - Popularity int `json:"popularity"` - } `json:"artists"` - } - - if err := c.getJSON(ctx, fmt.Sprintf(artistRelatedURL, artistID), token, &data); err != nil { - return nil, err - } - - maxItems := len(data.Artists) - if limit > 0 && limit < maxItems { - maxItems = limit - } - - result := make([]SearchArtistResult, 0, maxItems) - for i := 0; i < maxItems; i++ { - artist := data.Artists[i] - result = append(result, SearchArtistResult{ - ID: artist.ID, - Name: artist.Name, - Images: firstImageURL(artist.Images), - Followers: artist.Followers.Total, - Popularity: artist.Popularity, - }) - } - return result, nil -} - -func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string { - var data struct { - ExternalID externalID `json:"external_ids"` - } - if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil { - return "" - } - return data.ExternalID.ISRC -} - -func (c *SpotifyMetadataClient) getAccessToken(ctx context.Context) (string, error) { - if c.cachedToken != "" && time.Now().Before(c.tokenExpiresAt) { - return c.cachedToken, nil - } - - data := url.Values{} - data.Set("grant_type", "client_credentials") - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, spotifyTokenURL, strings.NewReader(data.Encode())) - if err != nil { - return "", err - } - - req.SetBasicAuth(c.clientID, c.clientSecret) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := c.httpClient.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("failed to get access token: %d", resp.StatusCode) - } - - var token accessTokenResponse - if err := json.Unmarshal(body, &token); err != nil { - return "", err - } - - c.cachedToken = token.AccessToken - if expiresIn, ok := token.ExpiresIn.(float64); ok { - c.tokenExpiresAt = time.Now().Add(time.Duration(expiresIn-60) * time.Second) - } - - return token.AccessToken, nil -} - -func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token string, dst interface{}) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return err - } - - req.Header.Set("User-Agent", c.userAgent) - req.Header.Set("Accept", "application/json") - req.Header.Set("Accept-Language", "en-US,en;q=0.9") - req.Header.Set("sec-ch-ua-platform", "\"Windows\"") - req.Header.Set("sec-fetch-dest", "empty") - req.Header.Set("sec-fetch-mode", "cors") - req.Header.Set("sec-fetch-site", "same-origin") - req.Header.Set("Referer", "https://open.spotify.com/") - req.Header.Set("Origin", "https://open.spotify.com") - if token != "" { - req.Header.Set("Authorization", "Bearer "+token) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("spotify API returned status %d", resp.StatusCode) - } - - return json.Unmarshal(body, dst) -} - -func (c *SpotifyMetadataClient) randomUserAgent() string { - c.rngMu.Lock() - defer c.rngMu.Unlock() - - macMajor := c.rng.Intn(4) + 11 - macMinor := c.rng.Intn(5) + 4 - webkitMajor := c.rng.Intn(7) + 530 - webkitMinor := c.rng.Intn(7) + 30 - chromeMajor := c.rng.Intn(25) + 80 - chromeBuild := c.rng.Intn(1500) + 3000 - chromePatch := c.rng.Intn(65) + 60 - safariMajor := c.rng.Intn(7) + 530 - safariMinor := c.rng.Intn(6) + 30 - - return fmt.Sprintf( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d", - macMajor, macMinor, - webkitMajor, webkitMinor, - chromeMajor, chromeBuild, chromePatch, - safariMajor, safariMinor, - ) -} - -func parseSpotifyURI(input string) (spotifyURI, error) { - trimmed := strings.TrimSpace(input) - if trimmed == "" { - return spotifyURI{}, errInvalidSpotifyURL - } - - if strings.HasPrefix(trimmed, "spotify:") { - parts := strings.Split(trimmed, ":") - if len(parts) == 3 { - switch parts[1] { - case "album", "track", "playlist", "artist": - return spotifyURI{Type: parts[1], ID: parts[2]}, nil - } - } - } - - parsed, err := url.Parse(trimmed) - if err != nil { - return spotifyURI{}, err - } - - if parsed.Host == "embed.spotify.com" { - if parsed.RawQuery == "" { - return spotifyURI{}, errInvalidSpotifyURL - } - qs, _ := url.ParseQuery(parsed.RawQuery) - embedded := qs.Get("uri") - if embedded == "" { - return spotifyURI{}, errInvalidSpotifyURL - } - return parseSpotifyURI(embedded) - } - - if parsed.Scheme == "" && parsed.Host == "" { - id := strings.Trim(strings.TrimSpace(parsed.Path), "/") - if id == "" { - return spotifyURI{}, errInvalidSpotifyURL - } - return spotifyURI{Type: "playlist", ID: id}, nil - } - - if parsed.Host != "open.spotify.com" && parsed.Host != "play.spotify.com" { - return spotifyURI{}, errInvalidSpotifyURL - } - - parts := cleanPathParts(parsed.Path) - if len(parts) == 0 { - return spotifyURI{}, errInvalidSpotifyURL - } - - // Skip embed prefix if present - if parts[0] == "embed" { - parts = parts[1:] - } - if len(parts) == 0 { - return spotifyURI{}, errInvalidSpotifyURL - } - - if strings.HasPrefix(parts[0], "intl-") { - parts = parts[1:] - } - if len(parts) == 0 { - return spotifyURI{}, errInvalidSpotifyURL - } - - if len(parts) == 2 { - switch parts[0] { - case "album", "track", "playlist", "artist": - return spotifyURI{Type: parts[0], ID: parts[1]}, nil - } - } - - if len(parts) == 4 && parts[2] == "playlist" { - return spotifyURI{Type: "playlist", ID: parts[3]}, nil - } - - return spotifyURI{}, errInvalidSpotifyURL -} - -func cleanPathParts(path string) []string { - raw := strings.Split(path, "/") - parts := make([]string, 0, len(raw)) - for _, part := range raw { - if part != "" { - parts = append(parts, part) - } - } - return parts -} - -func joinArtists(artists []artist) string { - names := make([]string, len(artists)) - for i, a := range artists { - names[i] = a.Name - } - return strings.Join(names, ", ") -} - -func firstImageURL(images []image) string { - if len(images) > 0 { - return images[0].URL - } - return "" -} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index eb925b90..d811ec9b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4647,12 +4647,6 @@ abstract class AppLocalizations { /// **'You have unsaved changes that will be lost.'** String get lyricsProvidersDiscardContent; - /// Description for Spotify Lyrics API provider - /// - /// In en, this message translates to: - /// **'Spotify-sourced synced lyrics via community API'** - String get lyricsProviderSpotifyApiDesc; - /// Description for LRCLIB provider /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 6235383b..1ba2aa2b 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2706,10 +2706,6 @@ class AppLocalizationsDe extends AppLocalizations { String get lyricsProvidersDiscardContent => 'You have unsaved changes that will be lost.'; - @override - String get lyricsProviderSpotifyApiDesc => - 'Spotify-sourced synced lyrics via community API'; - @override String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 635a7017..50bf5a0a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2674,10 +2674,6 @@ class AppLocalizationsEn extends AppLocalizations { String get lyricsProvidersDiscardContent => 'You have unsaved changes that will be lost.'; - @override - String get lyricsProviderSpotifyApiDesc => - 'Spotify-sourced synced lyrics via community API'; - @override String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 79be5bb0..9c6a9988 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2674,10 +2674,6 @@ class AppLocalizationsEs extends AppLocalizations { String get lyricsProvidersDiscardContent => 'You have unsaved changes that will be lost.'; - @override - String get lyricsProviderSpotifyApiDesc => - 'Spotify-sourced synced lyrics via community API'; - @override String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 32d73a17..0a9d7722 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2675,10 +2675,6 @@ class AppLocalizationsFr extends AppLocalizations { String get lyricsProvidersDiscardContent => 'You have unsaved changes that will be lost.'; - @override - String get lyricsProviderSpotifyApiDesc => - 'Spotify-sourced synced lyrics via community API'; - @override String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index b5ba9892..460510f4 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2673,10 +2673,6 @@ class AppLocalizationsHi extends AppLocalizations { String get lyricsProvidersDiscardContent => 'You have unsaved changes that will be lost.'; - @override - String get lyricsProviderSpotifyApiDesc => - 'Spotify-sourced synced lyrics via community API'; - @override String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 1741bf34..3dc9058b 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2684,10 +2684,6 @@ class AppLocalizationsId extends AppLocalizations { String get lyricsProvidersDiscardContent => 'You have unsaved changes that will be lost.'; - @override - String get lyricsProviderSpotifyApiDesc => - 'Spotify-sourced synced lyrics via community API'; - @override String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 9bfac412..b0cd22b4 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2660,10 +2660,6 @@ class AppLocalizationsJa extends AppLocalizations { String get lyricsProvidersDiscardContent => 'You have unsaved changes that will be lost.'; - @override - String get lyricsProviderSpotifyApiDesc => - 'Spotify-sourced synced lyrics via community API'; - @override String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index f697810a..67e594d0 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2653,10 +2653,6 @@ class AppLocalizationsKo extends AppLocalizations { String get lyricsProvidersDiscardContent => 'You have unsaved changes that will be lost.'; - @override - String get lyricsProviderSpotifyApiDesc => - 'Spotify-sourced synced lyrics via community API'; - @override String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index ac4dcb88..7681f859 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2673,10 +2673,6 @@ class AppLocalizationsNl extends AppLocalizations { String get lyricsProvidersDiscardContent => 'You have unsaved changes that will be lost.'; - @override - String get lyricsProviderSpotifyApiDesc => - 'Spotify-sourced synced lyrics via community API'; - @override String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 4fd1594f..33d34cd3 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2674,10 +2674,6 @@ class AppLocalizationsPt extends AppLocalizations { String get lyricsProvidersDiscardContent => 'You have unsaved changes that will be lost.'; - @override - String get lyricsProviderSpotifyApiDesc => - 'Spotify-sourced synced lyrics via community API'; - @override String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 28d0d0d7..fd5a195e 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2733,10 +2733,6 @@ class AppLocalizationsRu extends AppLocalizations { String get lyricsProvidersDiscardContent => 'You have unsaved changes that will be lost.'; - @override - String get lyricsProviderSpotifyApiDesc => - 'Spotify-sourced synced lyrics via community API'; - @override String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index f5c8c41a..bd86162e 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2679,10 +2679,6 @@ class AppLocalizationsTr extends AppLocalizations { String get lyricsProvidersDiscardContent => 'You have unsaved changes that will be lost.'; - @override - String get lyricsProviderSpotifyApiDesc => - 'Spotify-sourced synced lyrics via community API'; - @override String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index ebe8c626..5b9024bd 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2674,10 +2674,6 @@ class AppLocalizationsZh extends AppLocalizations { String get lyricsProvidersDiscardContent => 'You have unsaved changes that will be lost.'; - @override - String get lyricsProviderSpotifyApiDesc => - 'Spotify-sourced synced lyrics via community API'; - @override String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index d03dd3d2..1a219977 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -3551,10 +3551,6 @@ "@lyricsProvidersDiscardContent": { "description": "Body text of the discard-changes dialog on lyrics provider page" }, - "lyricsProviderSpotifyApiDesc": "Spotify-sourced synced lyrics via community API", - "@lyricsProviderSpotifyApiDesc": { - "description": "Description for Spotify Lyrics API provider" - }, "lyricsProviderLrclibDesc": "Open-source synced lyrics database", "@lyricsProviderLrclibDesc": { "description": "Description for LRCLIB provider" diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 4d712b19..9a5a1ef5 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -30,10 +30,6 @@ class AppSettings { final String historyViewMode; final String historyFilterMode; final bool askQualityBeforeDownload; - final String spotifyClientId; - final String spotifyClientSecret; - final bool useCustomSpotifyCredentials; - final String metadataSource; final bool enableLogging; final bool useExtensionProviders; final String? searchProvider; @@ -107,10 +103,6 @@ class AppSettings { this.historyViewMode = 'grid', this.historyFilterMode = 'all', this.askQualityBeforeDownload = true, - this.spotifyClientId = '', - this.spotifyClientSecret = '', - this.useCustomSpotifyCredentials = false, - this.metadataSource = 'deezer', this.enableLogging = false, this.useExtensionProviders = true, this.searchProvider, @@ -134,7 +126,6 @@ class AppSettings { this.hasCompletedTutorial = false, this.lyricsProviders = const [ 'lrclib', - 'spotify_api', 'musixmatch', 'netease', 'apple_music', @@ -172,10 +163,6 @@ class AppSettings { String? historyViewMode, String? historyFilterMode, bool? askQualityBeforeDownload, - String? spotifyClientId, - String? spotifyClientSecret, - bool? useCustomSpotifyCredentials, - String? metadataSource, bool? enableLogging, bool? useExtensionProviders, String? searchProvider, @@ -235,11 +222,6 @@ class AppSettings { historyFilterMode: historyFilterMode ?? this.historyFilterMode, askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload, - spotifyClientId: spotifyClientId ?? this.spotifyClientId, - spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret, - useCustomSpotifyCredentials: - useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials, - metadataSource: metadataSource ?? this.metadataSource, enableLogging: enableLogging ?? this.enableLogging, useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index c3b39fd4..5987820f 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -32,11 +32,6 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( historyViewMode: json['historyViewMode'] as String? ?? 'grid', historyFilterMode: json['historyFilterMode'] as String? ?? 'all', askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true, - spotifyClientId: json['spotifyClientId'] as String? ?? '', - spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '', - useCustomSpotifyCredentials: - json['useCustomSpotifyCredentials'] as bool? ?? false, - metadataSource: json['metadataSource'] as String? ?? 'deezer', enableLogging: json['enableLogging'] as bool? ?? false, useExtensionProviders: json['useExtensionProviders'] as bool? ?? true, searchProvider: json['searchProvider'] as String?, @@ -65,14 +60,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( (json['lyricsProviders'] as List?) ?.map((e) => e as String) .toList() ?? - const [ - 'lrclib', - 'spotify_api', - 'musixmatch', - 'netease', - 'apple_music', - 'qqmusic', - ], + const ['lrclib', 'musixmatch', 'netease', 'apple_music', 'qqmusic'], lyricsIncludeTranslationNetease: json['lyricsIncludeTranslationNetease'] as bool? ?? false, lyricsIncludeRomanizationNetease: @@ -111,10 +99,6 @@ Map _$AppSettingsToJson( 'historyViewMode': instance.historyViewMode, 'historyFilterMode': instance.historyFilterMode, 'askQualityBeforeDownload': instance.askQualityBeforeDownload, - 'spotifyClientId': instance.spotifyClientId, - 'spotifyClientSecret': instance.spotifyClientSecret, - 'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials, - 'metadataSource': instance.metadataSource, 'enableLogging': instance.enableLogging, 'useExtensionProviders': instance.useExtensionProviders, 'searchProvider': instance.searchProvider, diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 3ea5ba7a..5c7426fd 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -662,9 +662,8 @@ class ExtensionNotifier extends Notifier { if (settings.searchProvider == extensionId) { ref.read(settingsProvider.notifier).setSearchProvider(null); - ref.read(settingsProvider.notifier).setMetadataSource('deezer'); _log.d( - 'Cleared search provider and reset to Deezer because extension $extensionId was disabled', + 'Cleared search provider because extension $extensionId was disabled', ); } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index cc0854c9..055fc7a2 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -12,7 +12,7 @@ import 'package:spotiflac_android/utils/logger.dart'; const _settingsKey = 'app_settings'; const _migrationVersionKey = 'settings_migration_version'; -const _currentMigrationVersion = 7; +const _currentMigrationVersion = 9; const _spotifyClientSecretKey = 'spotify_client_secret'; final _log = AppLogger('SettingsProvider'); @@ -44,7 +44,7 @@ class SettingsNotifier extends Notifier { await _normalizeSongLinkRegionIfNeeded(); } - await _retireBuiltInSpotifyProvider(); + await _cleanupRetiredSpotifySettings(); LogBuffer.loggingEnabled = state.enableLogging; @@ -86,13 +86,6 @@ class SettingsNotifier extends Notifier { Future _runMigrations(SharedPreferences prefs) async { final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0; - if (lastMigration < 1) { - if (!state.useCustomSpotifyCredentials) { - state = state.copyWith(metadataSource: 'deezer'); - await _saveSettings(); - } - } - if (lastMigration < _currentMigrationVersion) { if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') { state = state.copyWith(storageMode: 'saf'); @@ -101,26 +94,20 @@ class SettingsNotifier extends Notifier { if (!state.isFirstLaunch && !state.hasCompletedTutorial) { state = state.copyWith(hasCompletedTutorial: true); } - // Migration 4: include Spotify Lyrics API in provider order for existing users - if (!state.lyricsProviders.contains('spotify_api')) { - final updatedProviders = List.from(state.lyricsProviders); - final lrclibIndex = updatedProviders.indexOf('lrclib'); - if (lrclibIndex >= 0) { - updatedProviders.insert(lrclibIndex + 1, 'spotify_api'); - } else { - updatedProviders.add('spotify_api'); - } - state = state.copyWith(lyricsProviders: updatedProviders); - } - if (state.metadataSource != 'deezer' || - state.spotifyClientId.isNotEmpty || - state.spotifyClientSecret.isNotEmpty || - state.useCustomSpotifyCredentials) { + if (state.lyricsProviders.contains('spotify_api')) { + final updatedProviders = state.lyricsProviders + .where((provider) => provider != 'spotify_api') + .toList(); state = state.copyWith( - metadataSource: 'deezer', - spotifyClientId: '', - spotifyClientSecret: '', - useCustomSpotifyCredentials: false, + lyricsProviders: updatedProviders.isEmpty + ? const [ + 'lrclib', + 'musixmatch', + 'netease', + 'apple_music', + 'qqmusic', + ] + : updatedProviders, ); } state = state.copyWith(lastSeenVersion: AppInfo.version); @@ -134,8 +121,7 @@ class SettingsNotifier extends Notifier { } Future _saveSettings() async { - final settingsToSave = state.copyWith(spotifyClientSecret: ''); - _pendingSettingsJson = jsonEncode(settingsToSave.toJson()); + _pendingSettingsJson = jsonEncode(state.toJson()); if (_isSavingSettings) { _saveQueued = true; @@ -186,28 +172,13 @@ class SettingsNotifier extends Notifier { await _saveSettings(); } - Future _retireBuiltInSpotifyProvider() async { + Future _cleanupRetiredSpotifySettings() async { final storedSecret = await _secureStorage.read( key: _spotifyClientSecretKey, ); if (storedSecret != null && storedSecret.isNotEmpty) { await _secureStorage.delete(key: _spotifyClientSecretKey); } - - if (state.metadataSource == 'deezer' && - state.spotifyClientId.isEmpty && - state.spotifyClientSecret.isEmpty && - !state.useCustomSpotifyCredentials) { - return; - } - - state = state.copyWith( - metadataSource: 'deezer', - spotifyClientId: '', - spotifyClientSecret: '', - useCustomSpotifyCredentials: false, - ); - await _saveSettings(); } void setDefaultService(String service) { @@ -380,11 +351,6 @@ class SettingsNotifier extends Notifier { _saveSettings(); } - void setMetadataSource(String source) { - state = state.copyWith(metadataSource: source); - _saveSettings(); - } - void setSearchProvider(String? provider) { if (provider == null || provider.isEmpty) { state = state.copyWith(clearSearchProvider: true); diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index a330ac51..a7fae66e 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -1563,7 +1563,6 @@ class _DownloadSettingsPageState extends ConsumerState { } static const _providerDisplayNames = { - 'spotify_api': 'Spotify Lyrics API', 'lrclib': 'LRCLIB', 'netease': 'Netease', 'musixmatch': 'Musixmatch', diff --git a/lib/screens/settings/lyrics_provider_priority_page.dart b/lib/screens/settings/lyrics_provider_priority_page.dart index 3e3537a2..32c8fd83 100644 --- a/lib/screens/settings/lyrics_provider_priority_page.dart +++ b/lib/screens/settings/lyrics_provider_priority_page.dart @@ -17,7 +17,6 @@ class _LyricsProviderPriorityPageState extends ConsumerState { static const _allProviderIds = [ 'lrclib', - 'spotify_api', 'netease', 'musixmatch', 'apple_music', @@ -133,9 +132,7 @@ class _LyricsProviderPriorityPageState void _disableProvider(String id) { if (_enabledProviders.length <= 1) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.lyricsProvidersAtLeastOne), - ), + SnackBar(content: Text(context.l10n.lyricsProvidersAtLeastOne)), ); return; } @@ -184,12 +181,6 @@ class _LyricsProviderPriorityPageState BuildContext context, ) { switch (id) { - case 'spotify_api': - return _LyricsProviderInfo( - name: 'Spotify Lyrics API', - description: context.l10n.lyricsProviderSpotifyApiDesc, - icon: Icons.music_note_outlined, - ); case 'lrclib': return _LyricsProviderInfo( name: 'LRCLIB', diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 47792a44..b46b6c27 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -70,15 +70,7 @@ class OptionsSettingsPage extends ConsumerWidget { ), ), SliverToBoxAdapter( - child: SettingsGroup( - children: [ - _MetadataSourceSelector( - onChanged: (v) => ref - .read(settingsProvider.notifier) - .setMetadataSource(v), - ), - ], - ), + child: SettingsGroup(children: [const _MetadataSourceSelector()]), ), SliverToBoxAdapter( @@ -706,8 +698,7 @@ class _ChannelChip extends StatelessWidget { } class _MetadataSourceSelector extends ConsumerWidget { - final ValueChanged onChanged; - const _MetadataSourceSelector({required this.onChanged}); + const _MetadataSourceSelector(); static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'}; @@ -770,7 +761,6 @@ class _MetadataSourceSelector extends ConsumerWidget { if (hasNonDefaultProvider) { ref.read(settingsProvider.notifier).setSearchProvider(null); } - onChanged('deezer'); }, ), const SizedBox(width: 8), @@ -782,7 +772,6 @@ class _MetadataSourceSelector extends ConsumerWidget { ref .read(settingsProvider.notifier) .setSearchProvider('tidal'); - onChanged('tidal'); }, ), const SizedBox(width: 8), @@ -794,7 +783,6 @@ class _MetadataSourceSelector extends ConsumerWidget { ref .read(settingsProvider.notifier) .setSearchProvider('qobuz'); - onChanged('qobuz'); }, ), ], diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 444c7c30..01a43d5f 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -426,7 +426,6 @@ class _SetupScreenState extends ConsumerState { ); } - ref.read(settingsProvider.notifier).setMetadataSource('deezer'); ref.read(settingsProvider.notifier).setFirstLaunchComplete(); if (mounted) context.go('/tutorial');