From 882afd938b2b270f8bbcbeefdfd87611e35340ee Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 19 Feb 2026 19:16:55 +0700 Subject: [PATCH] feat: add SongLink region setting and fix track metadata lookup with name+artist fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add configurable SongLink region (userCountry) setting with picker UI - Pass songLinkRegion through download request payload to Go backend - Go backend: thread-safe global SongLink region with per-request override - Fix downloaded track not recognized in collection tap: add findByTrackAndArtist fallback in download history lookup chain (Spotify ID → ISRC → name+artist) - Apply same name+artist fallback to isDownloaded check in track options sheet - Add missing library_database.dart import for LocalLibraryItem --- go_backend/exports.go | 12 + go_backend/songlink.go | 39 ++- lib/models/settings.dart | 5 + lib/models/settings.g.dart | 2 + lib/providers/download_queue_provider.dart | 17 ++ lib/providers/settings_provider.dart | 21 ++ lib/screens/library_tracks_folder_screen.dart | 79 +++-- .../settings/download_settings_page.dart | 286 ++++++++++++++++++ lib/services/download_request_payload.dart | 4 + 9 files changed, 446 insertions(+), 19 deletions(-) diff --git a/go_backend/exports.go b/go_backend/exports.go index 1315280d..6f2412e4 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -177,6 +177,7 @@ type DownloadRequest struct { LyricsMode string `json:"lyrics_mode,omitempty"` UseExtensions bool `json:"use_extensions,omitempty"` UseFallback bool `json:"use_fallback,omitempty"` + SongLinkRegion string `json:"songlink_region,omitempty"` } type DownloadResponse struct { @@ -378,11 +379,19 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) { } } +func applySongLinkRegionFromRequest(req *DownloadRequest) { + if req == nil { + return + } + SetSongLinkRegion(req.SongLinkRegion) +} + func DownloadTrack(requestJSON string) (string, error) { var req DownloadRequest if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return errorResponse("Invalid request: " + err.Error()) } + applySongLinkRegionFromRequest(&req) defer closeOwnedOutputFD(req.OutputFD) req.TrackName = strings.TrimSpace(req.TrackName) @@ -566,6 +575,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return errorResponse("Invalid request: " + err.Error()) } + applySongLinkRegionFromRequest(&req) defer closeOwnedOutputFD(req.OutputFD) req.TrackName = strings.TrimSpace(req.TrackName) @@ -1533,6 +1543,7 @@ func DownloadFromYouTube(requestJSON string) (string, error) { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return errorResponse("Invalid request: " + err.Error()) } + applySongLinkRegionFromRequest(&req) defer closeOwnedOutputFD(req.OutputFD) req.TrackName = strings.TrimSpace(req.TrackName) @@ -2251,6 +2262,7 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { return "", fmt.Errorf("invalid request: %w", err) } + applySongLinkRegionFromRequest(&req) defer closeOwnedOutputFD(req.OutputFD) req.TrackName = strings.TrimSpace(req.TrackName) diff --git a/go_backend/songlink.go b/go_backend/songlink.go index 62f926f8..43cca7aa 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -34,6 +34,8 @@ type TrackAvailability struct { var ( globalSongLinkClient *SongLinkClient songLinkClientOnce sync.Once + songLinkRegion = "US" + songLinkRegionMu sync.RWMutex ) func NewSongLinkClient() *SongLinkClient { @@ -45,6 +47,33 @@ func NewSongLinkClient() *SongLinkClient { return globalSongLinkClient } +func normalizeSongLinkRegion(region string) string { + normalized := strings.ToUpper(strings.TrimSpace(region)) + if len(normalized) != 2 { + return "US" + } + for _, ch := range normalized { + if ch < 'A' || ch > 'Z' { + return "US" + } + } + return normalized +} + +func SetSongLinkRegion(region string) { + normalized := normalizeSongLinkRegion(region) + songLinkRegionMu.Lock() + songLinkRegion = normalized + songLinkRegionMu.Unlock() +} + +func GetSongLinkRegion() string { + songLinkRegionMu.RLock() + region := songLinkRegion + songLinkRegionMu.RUnlock() + return region +} + func songLinkBaseURL() string { opts := GetNetworkCompatibilityOptions() if opts.AllowHTTP { @@ -54,6 +83,9 @@ func songLinkBaseURL() string { } func buildSongLinkURLFromTarget(targetURL string, userCountry string) string { + if userCountry == "" { + userCountry = GetSongLinkRegion() + } apiURL := fmt.Sprintf("%s?url=%s", songLinkBaseURL(), url.QueryEscape(targetURL)) if userCountry != "" { apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry)) @@ -62,6 +94,9 @@ func buildSongLinkURLFromTarget(targetURL string, userCountry string) string { } func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry string) string { + if userCountry == "" { + userCountry = GetSongLinkRegion() + } apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s", songLinkBaseURL(), url.QueryEscape(platform), @@ -448,7 +483,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin songLinkRateLimiter.WaitForSlot() deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID) - apiURL := buildSongLinkURLFromTarget(deezerURL, "US") + apiURL := buildSongLinkURLFromTarget(deezerURL, "") req, err := http.NewRequest("GET", apiURL, nil) if err != nil { @@ -552,7 +587,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit songLinkRateLimiter.WaitForSlot() - apiURL := buildSongLinkURLByPlatform(platform, entityType, entityID, "US") + apiURL := buildSongLinkURLByPlatform(platform, entityType, entityID, "") req, err := http.NewRequest("GET", apiURL, nil) if err != nil { diff --git a/lib/models/settings.dart b/lib/models/settings.dart index ba7b6f09..d499bf01 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -51,6 +51,8 @@ class AppSettings { downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only final bool networkCompatibilityMode; // Try HTTP + allow invalid TLS cert for API requests + final String + songLinkRegion; // SongLink userCountry region code used for platform lookup // Local Library Settings final bool localLibraryEnabled; // Enable local library scanning @@ -115,6 +117,7 @@ class AppSettings { this.autoExportFailedDownloads = false, this.downloadNetworkMode = 'any', this.networkCompatibilityMode = false, + this.songLinkRegion = 'US', // Local Library defaults this.localLibraryEnabled = false, this.localLibraryPath = '', @@ -177,6 +180,7 @@ class AppSettings { bool? autoExportFailedDownloads, String? downloadNetworkMode, bool? networkCompatibilityMode, + String? songLinkRegion, // Local Library bool? localLibraryEnabled, String? localLibraryPath, @@ -241,6 +245,7 @@ class AppSettings { downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode, networkCompatibilityMode: networkCompatibilityMode ?? this.networkCompatibilityMode, + songLinkRegion: songLinkRegion ?? this.songLinkRegion, // Local Library localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled, localLibraryPath: localLibraryPath ?? this.localLibraryPath, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 04f4028f..fd02b464 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -54,6 +54,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( json['networkCompatibilityMode'] as bool? ?? json['songLinkCompatibilityMode'] as bool? ?? false, + songLinkRegion: json['songLinkRegion'] as String? ?? 'US', localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false, localLibraryPath: json['localLibraryPath'] as String? ?? '', localLibraryShowDuplicates: @@ -117,6 +118,7 @@ Map _$AppSettingsToJson( 'autoExportFailedDownloads': instance.autoExportFailedDownloads, 'downloadNetworkMode': instance.downloadNetworkMode, 'networkCompatibilityMode': instance.networkCompatibilityMode, + 'songLinkRegion': instance.songLinkRegion, 'localLibraryEnabled': instance.localLibraryEnabled, 'localLibraryPath': instance.localLibraryPath, 'localLibraryShowDuplicates': instance.localLibraryShowDuplicates, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index a41f5484..6860ea0f 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -232,6 +232,22 @@ class DownloadHistoryState { DownloadHistoryItem? getByIsrc(String isrc) => _byIsrc[isrc]; + DownloadHistoryItem? findByTrackAndArtist( + String trackName, + String artistName, + ) { + final normalizedTrack = trackName.trim().toLowerCase(); + final normalizedArtist = artistName.trim().toLowerCase(); + if (normalizedTrack.isEmpty) return null; + for (final item in items) { + if (item.trackName.trim().toLowerCase() == normalizedTrack && + item.artistName.trim().toLowerCase() == normalizedArtist) { + return item; + } + } + return null; + } + DownloadHistoryState copyWith({List? items}) { return DownloadHistoryState(items: items ?? this.items); } @@ -3111,6 +3127,7 @@ class DownloadQueueNotifier extends Notifier { safRelativeDir: relativeDir, safFileName: fileName, safOutputExt: outputExt, + songLinkRegion: settings.songLinkRegion, ); return PlatformBridge.downloadByStrategy( diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 696d893e..f293d005 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -15,6 +15,7 @@ final _log = AppLogger('SettingsProvider'); class SettingsNotifier extends Notifier { static const List _youtubeOpusSupportedBitrates = [128, 256]; static const List _youtubeMp3SupportedBitrates = [128, 256, 320]; + static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$'); final Future _prefs = SharedPreferences.getInstance(); final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); @@ -36,6 +37,7 @@ class SettingsNotifier extends Notifier { await _runMigrations(prefs); await _normalizeYouTubeBitratesIfNeeded(); + await _normalizeSongLinkRegionIfNeeded(); } await _loadSpotifyClientSecret(prefs); @@ -165,6 +167,19 @@ class SettingsNotifier extends Notifier { await _saveSettings(); } + String _normalizeSongLinkRegion(String region) { + final normalized = region.trim().toUpperCase(); + if (_isoRegionPattern.hasMatch(normalized)) return normalized; + return 'US'; + } + + Future _normalizeSongLinkRegionIfNeeded() async { + final normalized = _normalizeSongLinkRegion(state.songLinkRegion); + if (normalized == state.songLinkRegion) return; + state = state.copyWith(songLinkRegion: normalized); + await _saveSettings(); + } + Future _loadSpotifyClientSecret(SharedPreferences prefs) async { final storedSecret = await _secureStorage.read( key: _spotifyClientSecretKey, @@ -483,6 +498,12 @@ class SettingsNotifier extends Notifier { _syncNetworkCompatibilitySettingsToBackend(); } + void setSongLinkRegion(String region) { + final normalized = _normalizeSongLinkRegion(region); + state = state.copyWith(songLinkRegion: normalized); + _saveSettings(); + } + void setLocalLibraryEnabled(bool enabled) { state = state.copyWith(localLibraryEnabled: enabled); _saveSettings(); diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 00a0665f..0167849a 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -8,6 +8,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; @@ -992,9 +994,12 @@ class _CollectionTrackTile extends ConsumerWidget { void _showTrackOptionsSheet(BuildContext context, WidgetRef ref) { final track = entry.track; final colorScheme = Theme.of(context).colorScheme; - final isDownloaded = ref.read( - downloadHistoryProvider.select((state) => state.isDownloaded(track.id)), - ); + final historyState = ref.read(downloadHistoryProvider); + final isDownloaded = historyState.isDownloaded(track.id) || + (track.isrc != null && + track.isrc!.isNotEmpty && + historyState.getByIsrc(track.isrc!) != null) || + historyState.findByTrackAndArtist(track.name, track.artistName) != null; // Wishlist: only show "Add to Playlist" if track is already downloaded final showAddToPlaylist = mode != LibraryTracksFolderMode.wishlist || isDownloaded; @@ -1168,22 +1173,62 @@ class _CollectionTrackTile extends ConsumerWidget { Future _navigateToMetadata(BuildContext context, WidgetRef ref) async { final track = entry.track; - final historyItem = ref - .read(downloadHistoryProvider.notifier) - .getBySpotifyId(track.id); + final historyState = ref.read(downloadHistoryProvider); - if (historyItem == null) return; + // 1. Download history by Spotify ID + var historyItem = historyState.getBySpotifyId(track.id); - await Navigator.of(context).push( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(item: historyItem), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), - ); + // 2. Download history by ISRC + if (historyItem == null && + track.isrc != null && + track.isrc!.isNotEmpty) { + historyItem = historyState.getByIsrc(track.isrc!); + } + + // 3. Download history by track name + artist (handles ID/ISRC mismatch) + historyItem ??= + historyState.findByTrackAndArtist(track.name, track.artistName); + + if (historyItem != null) { + await Navigator.of(context).push( + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => + TrackMetadataScreen(item: historyItem), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), + ), + ); + return; + } + + // 4. Local library by ISRC + final localState = ref.read(localLibraryProvider); + LocalLibraryItem? localItem; + if (track.isrc != null && track.isrc!.isNotEmpty) { + localItem = localState.getByIsrc(track.isrc!); + } + + // 5. Local library by track name + artist + localItem ??= localState.findByTrackAndArtist(track.name, track.artistName); + + if (localItem != null) { + await Navigator.of(context).push( + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + pageBuilder: (context, animation, secondaryAnimation) => + TrackMetadataScreen(localItem: localItem), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), + ), + ); + return; + } + + // 6. Not found anywhere — offer to download + _downloadTrack(context, ref); } } diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 6577367b..389629f1 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -24,6 +24,206 @@ class DownloadSettingsPage extends ConsumerStatefulWidget { class _DownloadSettingsPageState extends ConsumerState { static const _builtInServices = ['tidal', 'qobuz', 'amazon']; + static const _songLinkRegions = [ + 'AD', + 'AE', + 'AG', + 'AL', + 'AM', + 'AO', + 'AR', + 'AT', + 'AU', + 'AZ', + 'BA', + 'BB', + 'BD', + 'BE', + 'BF', + 'BG', + 'BH', + 'BI', + 'BJ', + 'BN', + 'BO', + 'BR', + 'BS', + 'BT', + 'BW', + 'BZ', + 'CA', + 'CD', + 'CG', + 'CH', + 'CI', + 'CL', + 'CM', + 'CO', + 'CR', + 'CV', + 'CW', + 'CY', + 'CZ', + 'DE', + 'DJ', + 'DK', + 'DM', + 'DO', + 'DZ', + 'EC', + 'EE', + 'EG', + 'ES', + 'ET', + 'FI', + 'FJ', + 'FM', + 'FR', + 'GA', + 'GB', + 'GD', + 'GE', + 'GH', + 'GM', + 'GN', + 'GQ', + 'GR', + 'GT', + 'GW', + 'GY', + 'HK', + 'HN', + 'HR', + 'HT', + 'HU', + 'ID', + 'IE', + 'IL', + 'IN', + 'IQ', + 'IS', + 'IT', + 'JM', + 'JO', + 'JP', + 'KE', + 'KG', + 'KH', + 'KI', + 'KM', + 'KN', + 'KR', + 'KW', + 'KZ', + 'LA', + 'LB', + 'LC', + 'LI', + 'LK', + 'LR', + 'LS', + 'LT', + 'LU', + 'LV', + 'LY', + 'MA', + 'MC', + 'MD', + 'ME', + 'MG', + 'MH', + 'MK', + 'ML', + 'MN', + 'MO', + 'MR', + 'MT', + 'MU', + 'MV', + 'MW', + 'MX', + 'MY', + 'MZ', + 'NA', + 'NE', + 'NG', + 'NI', + 'NL', + 'NO', + 'NP', + 'NR', + 'NZ', + 'OM', + 'PA', + 'PE', + 'PG', + 'PH', + 'PK', + 'PL', + 'PS', + 'PT', + 'PW', + 'PY', + 'QA', + 'RO', + 'RS', + 'RW', + 'SA', + 'SB', + 'SC', + 'SE', + 'SG', + 'SI', + 'SK', + 'SL', + 'SM', + 'SN', + 'SR', + 'ST', + 'SV', + 'SZ', + 'TD', + 'TG', + 'TH', + 'TJ', + 'TL', + 'TN', + 'TO', + 'TR', + 'TT', + 'TV', + 'TW', + 'TZ', + 'UA', + 'UG', + 'US', + 'UY', + 'UZ', + 'VC', + 'VE', + 'VN', + 'VU', + 'WS', + 'XK', + 'ZA', + 'ZM', + 'ZW', + ]; + static const _songLinkRegionNames = { + 'US': 'United States', + 'GB': 'United Kingdom', + 'FR': 'France', + 'DE': 'Germany', + 'JP': 'Japan', + 'KR': 'South Korea', + 'IN': 'India', + 'ID': 'Indonesia', + 'BR': 'Brazil', + 'MX': 'Mexico', + 'AU': 'Australia', + 'CA': 'Canada', + 'XK': 'Kosovo', + }; int _androidSdkVersion = 0; bool _hasAllFilesAccess = false; bool _artistFolderFiltersExpanded = false; @@ -536,6 +736,16 @@ class _DownloadSettingsPageState extends ConsumerState { settings.downloadNetworkMode, ), ), + SettingsItem( + icon: Icons.public, + title: 'SongLink Region', + subtitle: _getSongLinkRegionLabel(settings.songLinkRegion), + onTap: () => _showSongLinkRegionPicker( + context, + ref, + settings.songLinkRegion, + ), + ), SettingsSwitchItem( icon: Icons.security_outlined, title: 'Network compatibility mode', @@ -1225,6 +1435,14 @@ class _DownloadSettingsPageState extends ConsumerState { } } + String _getSongLinkRegionLabel(String code) { + final normalized = code.trim().toUpperCase(); + final effective = normalized.isEmpty ? 'US' : normalized; + final name = _songLinkRegionNames[effective]; + if (name == null) return effective; + return '$effective - $name'; + } + void _showLyricsModePicker( BuildContext context, WidgetRef ref, @@ -1630,6 +1848,74 @@ class _DownloadSettingsPageState extends ConsumerState { ); } + void _showSongLinkRegionPicker( + BuildContext context, + WidgetRef ref, + String current, + ) { + final colorScheme = Theme.of(context).colorScheme; + final normalizedCurrent = current.trim().toUpperCase(); + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (context) => SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.7, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + 'SongLink Region', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + 'Used as userCountry for SongLink API lookup.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: ListView.builder( + itemCount: _songLinkRegions.length, + itemBuilder: (context, index) { + final code = _songLinkRegions[index]; + final isSelected = code == normalizedCurrent; + final displayName = _songLinkRegionNames[code]; + return ListTile( + title: Text(code), + subtitle: displayName != null ? Text(displayName) : null, + trailing: isSelected + ? Icon(Icons.check, color: colorScheme.primary) + : null, + onTap: () { + ref + .read(settingsProvider.notifier) + .setSongLinkRegion(code); + Navigator.pop(context); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ); + } + void _showFolderOrganizationPicker( BuildContext context, WidgetRef ref, diff --git a/lib/services/download_request_payload.dart b/lib/services/download_request_payload.dart index bff0a941..afdbfdcf 100644 --- a/lib/services/download_request_payload.dart +++ b/lib/services/download_request_payload.dart @@ -33,6 +33,7 @@ class DownloadRequestPayload { final String safRelativeDir; final String safFileName; final String safOutputExt; + final String songLinkRegion; const DownloadRequestPayload({ this.isrc = '', @@ -69,6 +70,7 @@ class DownloadRequestPayload { this.safRelativeDir = '', this.safFileName = '', this.safOutputExt = '', + this.songLinkRegion = 'US', }); Map toJson() { @@ -107,6 +109,7 @@ class DownloadRequestPayload { 'saf_relative_dir': safRelativeDir, 'saf_file_name': safFileName, 'saf_output_ext': safOutputExt, + 'songlink_region': songLinkRegion, }; } @@ -149,6 +152,7 @@ class DownloadRequestPayload { safRelativeDir: safRelativeDir, safFileName: safFileName, safOutputExt: safOutputExt, + songLinkRegion: songLinkRegion, ); } }