feat: Add Deezer as alternative metadata source with auto-fallback

- Add Deezer API client (no auth required, rate limit per user IP)
- Add search source selector in Settings (Deezer/Spotify)
- Default to Deezer for better reliability
- Auto-fallback to Deezer when Spotify API is rate limited (429)
- Support fallback for tracks and albums via SongLink API
- Update README with metadata source documentation
- Version 2.1.5-preview (build 42)
This commit is contained in:
zarzet committed 2026-01-07 03:25:14 +07:00
1 parent 3b2ec319e2
commit 482457205a
17 files changed
+1211 -33

No files matched your search

+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '2.1.0';
static const String buildNumber = '41';
static const String version = '2.1.5-preview';
static const String buildNumber = '42';
static const String fullVersion = '$version+$buildNumber';
+4
View File
@@ -22,6 +22,7 @@ class AppSettings {
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
final String metadataSource; // spotify, deezer - source for search and metadata
const AppSettings({
this.defaultService = 'tidal',
@@ -42,6 +43,7 @@ class AppSettings {
this.spotifyClientId = '', // Default: use built-in credentials
this.spotifyClientSecret = '', // Default: use built-in credentials
this.useCustomSpotifyCredentials = true, // Default: use custom if set
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
});
AppSettings copyWith({
@@ -63,6 +65,7 @@ class AppSettings {
String? spotifyClientId,
String? spotifyClientSecret,
bool? useCustomSpotifyCredentials,
String? metadataSource,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -83,6 +86,7 @@ class AppSettings {
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
metadataSource: metadataSource ?? this.metadataSource,
);
}
+2
View File
@@ -26,6 +26,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
useCustomSpotifyCredentials:
json['useCustomSpotifyCredentials'] as bool? ?? true,
metadataSource: json['metadataSource'] as String? ?? 'deezer',
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -48,4 +49,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'spotifyClientId': instance.spotifyClientId,
'spotifyClientSecret': instance.spotifyClientSecret,
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
'metadataSource': instance.metadataSource,
};
+5
View File
@@ -156,6 +156,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
_applySpotifyCredentials();
}
void setMetadataSource(String source) {
state = state.copyWith(metadataSource: source);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+36 -4
View File
@@ -114,7 +114,7 @@ class TrackNotifier extends Notifier<TrackState> {
/// Check if request is still valid (not cancelled by newer request)
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
Future<void> fetchFromUrl(String url) async {
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
@@ -127,7 +127,22 @@ class TrackNotifier extends Notifier<TrackState> {
final type = parsed['type'] as String;
final metadata = await PlatformBridge.getSpotifyMetadata(url);
// Use the new fallback-enabled method
Map<String, dynamic> metadata;
try {
// ignore: avoid_print
print('[FetchURL] Fetching $type with Deezer fallback enabled...');
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
// ignore: avoid_print
print('[FetchURL] Metadata fetch success');
} catch (e) {
// If fallback also fails, show error
// ignore: avoid_print
print('[FetchURL] Metadata fetch failed: $e');
rethrow;
}
if (!_isRequestValid(requestId)) return; // Request cancelled
if (type == 'track') {
@@ -184,7 +199,7 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
Future<void> search(String query) async {
Future<void> search(String query, {String? metadataSource}) async {
// Increment request ID to cancel any pending requests
final requestId = ++_currentRequestId;
@@ -192,7 +207,24 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
final results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
// Use Deezer or Spotify based on settings
final source = metadataSource ?? 'deezer';
// Debug log to show which source is being used
// ignore: avoid_print
print('[Search] Using metadata source: $source for query: "$query"');
Map<String, dynamic> results;
if (source == 'deezer') {
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
// ignore: avoid_print
print('[Search] Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
} else {
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
// ignore: avoid_print
print('[Search] Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
}
if (!_isRequestValid(requestId)) return; // Request cancelled
final trackList = results['tracks'] as List<dynamic>? ?? [];
+2 -1
View File
@@ -38,7 +38,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
if (url.startsWith('http') || url.startsWith('spotify:')) {
await ref.read(trackProvider.notifier).fetchFromUrl(url);
} else {
await ref.read(trackProvider.notifier).search(url);
final settings = ref.read(settingsProvider);
await ref.read(trackProvider.notifier).search(url, metadataSource: settings.metadataSource);
}
}
+4 -2
View File
@@ -81,7 +81,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
if (_lastSearchQuery == query) return;
_lastSearchQuery = query;
await ref.read(trackProvider.notifier).search(query);
final settings = ref.read(settingsProvider);
await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
ref.read(settingsProvider.notifier).setHasSearchedBefore();
}
@@ -112,7 +113,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
await ref.read(trackProvider.notifier).fetchFromUrl(url);
_navigateToDetailIfNeeded();
} else {
await ref.read(trackProvider.notifier).search(url);
final settings = ref.read(settingsProvider);
await ref.read(trackProvider.notifier).search(url, metadataSource: settings.metadataSource);
}
ref.read(settingsProvider.notifier).setHasSearchedBefore();
}
+4 -2
View File
@@ -23,7 +23,8 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
_searchController = TextEditingController(text: widget.query);
if (widget.query.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(trackProvider.notifier).search(widget.query);
final settings = ref.read(settingsProvider);
ref.read(trackProvider.notifier).search(widget.query, metadataSource: settings.metadataSource);
});
}
}
@@ -37,7 +38,8 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
void _search() {
final query = _searchController.text.trim();
if (query.isNotEmpty) {
ref.read(trackProvider.notifier).search(query);
final settings = ref.read(settingsProvider);
ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
}
}
+100 -21
View File
@@ -119,29 +119,35 @@ class OptionsSettingsPage extends ConsumerWidget {
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.key,
title: 'Custom Credentials',
subtitle: settings.spotifyClientId.isNotEmpty
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
: 'Not configured',
onTap: () => _showSpotifyCredentialsDialog(context, ref, settings),
trailing: settings.spotifyClientId.isNotEmpty
? Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurfaceVariant, size: 20)
: Icon(Icons.add, color: Theme.of(context).colorScheme.primary, size: 20),
showDivider: settings.spotifyClientId.isNotEmpty,
_MetadataSourceSelector(
currentSource: settings.metadataSource,
onChanged: (v) => ref.read(settingsProvider.notifier).setMetadataSource(v),
),
if (settings.spotifyClientId.isNotEmpty)
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),
showDivider: false,
if (settings.metadataSource == 'spotify') ...[
SettingsItem(
icon: Icons.key,
title: 'Custom Credentials',
subtitle: settings.spotifyClientId.isNotEmpty
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
: 'Not configured',
onTap: () => _showSpotifyCredentialsDialog(context, ref, settings),
trailing: settings.spotifyClientId.isNotEmpty
? Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurfaceVariant, size: 20)
: Icon(Icons.add, color: Theme.of(context).colorScheme.primary, size: 20),
showDivider: settings.spotifyClientId.isNotEmpty,
),
if (settings.spotifyClientId.isNotEmpty)
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),
showDivider: false,
),
],
],
),
),
@@ -469,3 +475,76 @@ class _ChannelChip extends StatelessWidget {
);
}
}
class _MetadataSourceSelector extends StatelessWidget {
final String currentSource;
final ValueChanged<String> onChanged;
const _MetadataSourceSelector({required this.currentSource, required this.onChanged});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.all(20),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(Icons.search, color: colorScheme.onSurfaceVariant, size: 24),
const SizedBox(width: 16),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Search Source', style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 2),
Text(currentSource == 'deezer' ? 'Deezer (no need developer account)' : 'Spotify (may hit rate limit)',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
])),
]),
const SizedBox(height: 16),
Row(children: [
_SourceChip(label: 'Deezer', isSelected: currentSource == 'deezer', onTap: () => onChanged('deezer')),
const SizedBox(width: 8),
_SourceChip(label: 'Spotify', isSelected: currentSource == 'spotify', onTap: () => onChanged('spotify')),
]),
const SizedBox(height: 12),
Row(children: [
Icon(Icons.info_outline, size: 16, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(child: Text('Spotify URLs are always supported regardless of this setting',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant))),
]),
]),
);
}
}
class _SourceChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _SourceChip({required this.label, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final unselectedColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
: colorScheme.surfaceContainerHigh;
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Center(child: Text(label, style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant))),
),
),
),
);
}
}
+48
View File
@@ -316,4 +316,52 @@ class PlatformBridge {
static Future<void> clearTrackCache() async {
await _channel.invokeMethod('clearTrackCache');
}
// ==================== DEEZER API ====================
/// Search Deezer for tracks and artists (no API key required)
static Future<Map<String, dynamic>> searchDeezerAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
final result = await _channel.invokeMethod('searchDeezerAll', {
'query': query,
'track_limit': trackLimit,
'artist_limit': artistLimit,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Get Deezer metadata by type and ID
static Future<Map<String, dynamic>> getDeezerMetadata(String resourceType, String resourceId) async {
final result = await _channel.invokeMethod('getDeezerMetadata', {
'resource_type': resourceType,
'resource_id': resourceId,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Parse Deezer URL and return type and ID
static Future<Map<String, dynamic>> parseDeezerUrl(String url) async {
final result = await _channel.invokeMethod('parseDeezerUrl', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Search Deezer by ISRC
static Future<Map<String, dynamic>> searchDeezerByISRC(String isrc) async {
final result = await _channel.invokeMethod('searchDeezerByISRC', {'isrc': isrc});
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Convert Spotify track to Deezer and get metadata (for rate limit fallback)
static Future<Map<String, dynamic>> convertSpotifyToDeezer(String resourceType, String spotifyId) async {
final result = await _channel.invokeMethod('convertSpotifyToDeezer', {
'resource_type': resourceType,
'spotify_id': spotifyId,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Get Spotify metadata with automatic Deezer fallback on rate limit
static Future<Map<String, dynamic>> getSpotifyMetadataWithFallback(String url) async {
final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>;
}
}