From 2b9357cb6d0f211ce4a31968bd6693a099828d5b Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 12 Jan 2026 02:10:40 +0700 Subject: [PATCH] feat: remove default Spotify credentials, require user's own API key - Remove hardcoded Spotify client ID/secret from Go backend - Spotify now requires user to provide their own credentials - Deezer remains free (no credentials required) - Update UI to show 'Free' badge for Deezer, 'API Key' for Spotify - Show warning card when Spotify selected without credentials - Add hasSpotifyCredentials check to platform bridge --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 6 + go_backend/exports.go | 53 ++++-- go_backend/spotify.go | 156 ++++++++++-------- ios/Runner/AppDelegate.swift | 4 + lib/providers/settings_provider.dart | 10 +- .../settings/options_settings_page.dart | 84 +++++++--- lib/screens/setup_screen.dart | 5 +- lib/services/platform_bridge.dart | 8 +- 8 files changed, 207 insertions(+), 119 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 46650c87..78be4902 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -218,6 +218,12 @@ class MainActivity: FlutterActivity() { } result.success(null) } + "hasSpotifyCredentials" -> { + val hasCredentials = withContext(Dispatchers.IO) { + Gobackend.checkSpotifyCredentials() + } + result.success(hasCredentials) + } "preWarmTrackCache" -> { val tracksJson = call.argument("tracks") ?: "[]" withContext(Dispatchers.IO) { diff --git a/go_backend/exports.go b/go_backend/exports.go index 656fa28b..19025dbc 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -32,18 +32,26 @@ func ParseSpotifyURL(url string) (string, error) { } // SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter -// Pass empty strings to use default credentials func SetSpotifyAPICredentials(clientID, clientSecret string) { SetSpotifyCredentials(clientID, clientSecret) } +// CheckSpotifyCredentials checks if Spotify credentials are configured +// Returns true if credentials are available (custom or env vars) +func CheckSpotifyCredentials() bool { + return HasSpotifyCredentials() +} + // GetSpotifyMetadata fetches metadata from Spotify URL // Returns JSON with track/album/playlist data func GetSpotifyMetadata(spotifyURL string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - client := NewSpotifyMetadataClient() + client, err := NewSpotifyMetadataClient() + if err != nil { + return "", err + } data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) if err != nil { return "", err @@ -63,7 +71,10 @@ func SearchSpotify(query string, limit int) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - client := NewSpotifyMetadataClient() + client, err := NewSpotifyMetadataClient() + if err != nil { + return "", err + } results, err := client.SearchTracks(ctx, query, limit) if err != nil { return "", err @@ -83,7 +94,10 @@ func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - client := NewSpotifyMetadataClient() + client, err := NewSpotifyMetadataClient() + if err != nil { + return "", err + } results, err := client.SearchAll(ctx, query, trackLimit, artistLimit) if err != nil { return "", err @@ -893,21 +907,26 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) { defer cancel() // Try Spotify first - client := NewSpotifyMetadataClient() - data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) - if err == nil { - jsonBytes, err := json.Marshal(data) - if err != nil { + client, err := NewSpotifyMetadataClient() + if err != nil { + // No Spotify credentials - fall through to Deezer fallback + LogWarn("Spotify", "Credentials not configured, falling back to Deezer") + } else { + data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) + if err == nil { + jsonBytes, err := json.Marshal(data) + if err != nil { + return "", err + } + return string(jsonBytes), nil + } + + // Check if it's a rate limit error + errStr := strings.ToLower(err.Error()) + if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") { + // Not a rate limit error, return original error return "", err } - return string(jsonBytes), nil - } - - // Check if it's a rate limit error - errStr := strings.ToLower(err.Error()) - if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") { - // Not a rate limit error, return original error - return "", err } // Rate limited - try Deezer fallback for tracks and albums diff --git a/go_backend/spotify.go b/go_backend/spotify.go index 3e2d866c..ad8f56aa 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -2,7 +2,6 @@ package gobackend import ( "context" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -17,14 +16,14 @@ import ( ) 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" - searchBaseURL = "https://api.spotify.com/v1/search" - + 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" + searchBaseURL = "https://api.spotify.com/v1/search" + // Cache TTL settings artistCacheTTL = 10 * time.Minute searchCacheTTL = 5 * time.Minute @@ -54,7 +53,7 @@ type SpotifyMetadataClient struct { rng *rand.Rand rngMu sync.Mutex userAgent string - + // Caches to reduce API calls artistCache map[string]*cacheEntry // key: artistID searchCache map[string]*cacheEntry // key: query+type @@ -69,8 +68,10 @@ var ( credentialsMu sync.RWMutex ) +// ErrNoSpotifyCredentials is returned when Spotify credentials are not configured +var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)") + // SetSpotifyCredentials sets custom Spotify API credentials -// Pass empty strings to use default credentials func SetSpotifyCredentials(clientID, clientSecret string) { credentialsMu.Lock() defer credentialsMu.Unlock() @@ -78,39 +79,56 @@ func SetSpotifyCredentials(clientID, clientSecret string) { customClientSecret = clientSecret } -// getCredentials returns the current credentials (custom or default) -func getCredentials() (string, string) { +// HasSpotifyCredentials checks if Spotify credentials are configured +func HasSpotifyCredentials() bool { credentialsMu.RLock() defer credentialsMu.RUnlock() - + + // Check custom credentials first if customClientID != "" && customClientSecret != "" { - return customClientID, customClientSecret - } - - // Fall back to default credentials - clientID := os.Getenv("SPOTIFY_CLIENT_ID") - if clientID == "" { - if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil { - clientID = string(decoded) - } + return true } - clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET") - if clientSecret == "" { - if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil { - clientSecret = string(decoded) - } + // Check environment variables + if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" { + return true } - - return clientID, clientSecret + + return false +} + +// getCredentials returns the current credentials or error if not configured +func getCredentials() (string, string, error) { + credentialsMu.RLock() + defer credentialsMu.RUnlock() + + // Check custom credentials first + if customClientID != "" && customClientSecret != "" { + return customClientID, customClientSecret, nil + } + + // Check environment variables + clientID := os.Getenv("SPOTIFY_CLIENT_ID") + clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET") + + if clientID != "" && clientSecret != "" { + return clientID, clientSecret, nil + } + + // No credentials available + return "", "", ErrNoSpotifyCredentials } // NewSpotifyMetadataClient creates a new Spotify client -func NewSpotifyMetadataClient() *SpotifyMetadataClient { - src := rand.NewSource(time.Now().UnixNano()) +// Returns error if credentials are not configured +func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) { + // Get credentials - will error if not configured + clientID, clientSecret, err := getCredentials() + if err != nil { + return nil, err + } - // Get credentials (custom or default) - clientID, clientSecret := getCredentials() + src := rand.NewSource(time.Now().UnixNano()) c := &SpotifyMetadataClient{ httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling @@ -122,7 +140,7 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient { albumCache: make(map[string]*cacheEntry), } c.userAgent = c.randomUserAgent() - return c + return c, nil } // TrackMetadata represents track information @@ -331,14 +349,14 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, } 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 } @@ -373,7 +391,7 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) { // Create cache key cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit) - + // Check cache first c.cacheMu.RLock() if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() { @@ -388,24 +406,24 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra } 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 { + 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 } @@ -438,7 +456,7 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra if artistCount > artistLimit { artistCount = artistLimit } - + for i := 0; i < artistCount; i++ { artist := response.Artists.Items[i] result.Artists = append(result.Artists, SearchArtistResult{ @@ -534,7 +552,7 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s // Collect all tracks (including paginated) allTrackItems := data.Tracks.Items nextURL := data.Tracks.Next - + // Fetch remaining tracks using pagination (no limit) for nextURL != "" { var pageData struct { @@ -563,7 +581,7 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s 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), @@ -602,23 +620,23 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s // Similar to Deezer implementation for consistency func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string { const maxParallelISRC = 10 // Max concurrent ISRC fetches - + result := make(map[string]string) var resultMu sync.Mutex - + if len(trackIDs) == 0 { return result } - + // Use semaphore to limit concurrent requests sem := make(chan struct{}, maxParallelISRC) var wg sync.WaitGroup - + for _, trackID := range trackIDs { wg.Add(1) go func(id string) { defer wg.Done() - + // Acquire semaphore select { case sem <- struct{}{}: @@ -626,15 +644,15 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs case <-ctx.Done(): return } - + isrc := c.fetchTrackISRC(ctx, id, token) - + resultMu.Lock() result[id] = isrc resultMu.Unlock() }(trackID) } - + wg.Wait() return result } @@ -668,7 +686,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t // Pre-allocate with expected capacity tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total) - + // Add first batch of tracks for _, item := range data.Tracks.Items { if item.Track == nil { @@ -695,7 +713,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t // Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks) nextURL := data.Tracks.Next - + for nextURL != "" { var pageData struct { Items []struct { @@ -755,10 +773,10 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token // Fetch artist info var artistData struct { - ID string `json:"id"` - Name string `json:"name"` - Images []image `json:"images"` - Followers 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"` @@ -941,15 +959,15 @@ func (c *SpotifyMetadataClient) randomUserAgent() string { defer c.rngMu.Unlock() // Use Mac User-Agent format (same as PC version) - macMajor := c.rng.Intn(4) + 11 // 11-14 - macMinor := c.rng.Intn(5) + 4 // 4-8 - webkitMajor := c.rng.Intn(7) + 530 // 530-536 - webkitMinor := c.rng.Intn(7) + 30 // 30-36 - chromeMajor := c.rng.Intn(25) + 80 // 80-104 + macMajor := c.rng.Intn(4) + 11 // 11-14 + macMinor := c.rng.Intn(5) + 4 // 4-8 + webkitMajor := c.rng.Intn(7) + 530 // 530-536 + webkitMinor := c.rng.Intn(7) + 30 // 30-36 + chromeMajor := c.rng.Intn(25) + 80 // 80-104 chromeBuild := c.rng.Intn(1500) + 3000 // 3000-4499 - chromePatch := c.rng.Intn(65) + 60 // 60-124 - safariMajor := c.rng.Intn(7) + 530 // 530-536 - safariMinor := c.rng.Intn(6) + 30 // 30-35 + chromePatch := c.rng.Intn(65) + 60 // 60-124 + safariMajor := c.rng.Intn(7) + 530 // 530-536 + safariMinor := c.rng.Intn(6) + 30 // 30-35 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", diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index e4db3473..042c2be8 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -256,6 +256,10 @@ import Gobackend // Import Go framework GobackendSetSpotifyAPICredentials(clientId, clientSecret) return nil + case "hasSpotifyCredentials": + let hasCredentials = GobackendCheckSpotifyCredentials() + return hasCredentials + // Log methods case "getLogs": let response = GobackendGetLogs() diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 1d7fbf0a..be6a785a 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -60,18 +60,16 @@ class SettingsNotifier extends Notifier { /// Apply current Spotify credentials to Go backend Future _applySpotifyCredentials() async { - // Only apply custom credentials if enabled and both fields are set - if (state.useCustomSpotifyCredentials && - state.spotifyClientId.isNotEmpty && + // Only apply if both fields are set + if (state.spotifyClientId.isNotEmpty && state.spotifyClientSecret.isNotEmpty) { await PlatformBridge.setSpotifyCredentials( state.spotifyClientId, state.spotifyClientSecret, ); - } else { - // Clear to use default - await PlatformBridge.setSpotifyCredentials('', ''); } + // Note: If credentials are empty, Spotify API will return error + // User should use Deezer as metadata source instead } void setDefaultService(String service) { diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 78f1f749..cbd0ebe7 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -76,38 +76,50 @@ class OptionsSettingsPage extends ConsumerWidget { .setMetadataSource(v), ), if (settings.metadataSource == 'spotify') ...[ - SettingsSwitchItem( - icon: Icons.toggle_on, - title: 'Use Custom Credentials', - subtitle: settings.useCustomSpotifyCredentials - ? 'Using your credentials' - : 'Using default credentials', - value: settings.useCustomSpotifyCredentials, - onChanged: (v) { - ref - .read(settingsProvider.notifier) - .setUseCustomSpotifyCredentials(v); - if (v && settings.spotifyClientId.isEmpty) { - _showSpotifyCredentialsDialog(context, ref, settings); - } - }, - showDivider: true, - ), + // Info card about Spotify credentials requirement + if (settings.spotifyClientId.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Card( + color: Theme.of(context).colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon( + Icons.warning_amber_rounded, + color: Theme.of(context).colorScheme.onErrorContainer, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Spotify requires your own API credentials. Get them free from developer.spotify.com', + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer, + fontSize: 12, + ), + ), + ), + ], + ), + ), + ), + ), SettingsItem( icon: Icons.key, - title: 'Set Credentials', + title: 'Spotify Credentials', subtitle: settings.spotifyClientId.isNotEmpty ? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}' - : 'Not configured', + : 'Required - tap to configure', onTap: () => _showSpotifyCredentialsDialog(context, ref, settings), trailing: Icon( settings.spotifyClientId.isNotEmpty - ? Icons.edit - : Icons.add, + ? Icons.check_circle + : Icons.error_outline, color: settings.spotifyClientId.isNotEmpty - ? Theme.of(context).colorScheme.onSurfaceVariant - : Theme.of(context).colorScheme.primary, + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.error, size: 20, ), showDivider: false, @@ -820,6 +832,8 @@ class _MetadataSourceSelector extends ConsumerWidget { _SourceChip( icon: Icons.graphic_eq, label: 'Deezer', + badge: 'Free', + badgeColor: colorScheme.tertiary, // Not selected if extension is active isSelected: currentSource == 'deezer' && !hasExtensionSearch, onTap: () { @@ -834,6 +848,8 @@ class _MetadataSourceSelector extends ConsumerWidget { _SourceChip( icon: Icons.music_note, label: 'Spotify', + badge: 'API Key', + badgeColor: colorScheme.secondary, // Not selected if extension is active isSelected: currentSource == 'spotify' && !hasExtensionSearch, onTap: () { @@ -878,12 +894,16 @@ class _SourceChip extends StatelessWidget { final String label; final bool isSelected; final VoidCallback? onTap; + final String? badge; + final Color? badgeColor; const _SourceChip({ required this.icon, required this.label, required this.isSelected, this.onTap, + this.badge, + this.badgeColor, }); @override @@ -929,6 +949,24 @@ class _SourceChip extends StatelessWidget { : colorScheme.onSurfaceVariant, ), ), + if (badge != null) ...[ + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: (badgeColor ?? colorScheme.tertiary).withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + badge!, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w500, + color: badgeColor ?? colorScheme.tertiary, + ), + ), + ), + ], ], ), ), diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 3014da53..261f28c1 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -380,11 +380,10 @@ class _SetupScreenState extends ConsumerState { _clientIdController.text.trim(), _clientSecretController.text.trim(), ); - ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(true); - // Set search source to Spotify when using custom credentials + // Set search source to Spotify when credentials are provided ref.read(settingsProvider.notifier).setMetadataSource('spotify'); } else { - // Use Deezer as default search source + // Use Deezer as default search source (free, no credentials required) ref.read(settingsProvider.notifier).setMetadataSource('deezer'); } diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index a5f58e38..03038ec6 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -331,7 +331,6 @@ class PlatformBridge { } /// Set custom Spotify API credentials - /// Pass empty strings to use default credentials static Future setSpotifyCredentials(String clientId, String clientSecret) async { await _channel.invokeMethod('setSpotifyCredentials', { 'client_id': clientId, @@ -339,6 +338,13 @@ class PlatformBridge { }); } + /// Check if Spotify credentials are configured + /// Returns true if credentials are available (custom or env vars) + static Future hasSpotifyCredentials() async { + final result = await _channel.invokeMethod('hasSpotifyCredentials'); + return result as bool; + } + /// Pre-warm track ID cache for album/playlist tracks /// This runs in background and returns immediately /// Speeds up subsequent downloads by caching ISRC → Track ID mappings