diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index c5465806..52ece19e 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -128,6 +128,7 @@ func (m *extensionManager) SearchTracksWithMetadataProvidersForItemID(query stri tracks := make([]ExtTrackMetadata, 0, limit) seenTracks := make(map[string]struct{}) + var verificationErr error for _, providerID := range orderedProviderIDs { if isDownloadCancelled(itemID) { return nil, ErrDownloadCancelled @@ -150,6 +151,13 @@ func (m *extensionManager) SearchTracksWithMetadataProvidersForItemID(query stri if errors.Is(err, ErrDownloadCancelled) { return nil, ErrDownloadCancelled } + if verificationErr == nil && strings.EqualFold(classifyDownloadErrorType(err.Error()), "verification_required") { + verificationErr = fmt.Errorf( + "verification_required: extension '%s' needs verification: %w", + providerID, + err, + ) + } GoLog("[MetadataSearch] Search error from %s: %v\n", providerID, err) continue } @@ -169,6 +177,9 @@ func (m *extensionManager) SearchTracksWithMetadataProvidersForItemID(query stri } } } + if len(tracks) == 0 && verificationErr != nil { + return nil, verificationErr + } return tracks, nil } diff --git a/lib/screens/track_metadata_edit_sheet.dart b/lib/screens/track_metadata_edit_sheet.dart index 154b375c..f25e6743 100644 --- a/lib/screens/track_metadata_edit_sheet.dart +++ b/lib/screens/track_metadata_edit_sheet.dart @@ -991,6 +991,70 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { return false; } + Future>> _searchAutoFillCandidates({ + required String query, + required bool usesAutomaticProvider, + required Extension? selectedProvider, + required ExtensionState extensionState, + bool allowVerificationRetry = true, + }) async { + try { + if (usesAutomaticProvider) { + return await PlatformBridge.searchTracksWithMetadataProviders( + query, + limit: 5, + ); + } + + final provider = selectedProvider!; + if (provider.hasCustomSearch) { + final trackFilter = provider.searchBehavior?.filterIdForKind('track'); + return await PlatformBridge.customSearchWithExtension( + provider.id, + query, + options: {'limit': 5, 'filter': ?trackFilter}, + ); + } + return await PlatformBridge.searchTracksWithMetadataProvider( + provider.id, + query, + limit: 5, + ); + } catch (error) { + if (!allowVerificationRetry || !isExtensionVerificationRequired(error)) { + rethrow; + } + + final extensionId = usesAutomaticProvider + ? extensionIdFromVerificationError( + error, + extensionState.extensions.map((extension) => extension.id), + ) + : selectedProvider?.id; + if (extensionId == null || extensionId.isEmpty) rethrow; + + _log.i( + 'Metadata autofill requires verification; waiting for $extensionId', + ); + final verified = await openVerificationAndAwaitGrant( + extensionId, + browserMode: ProviderScope.containerOf( + context, + listen: false, + ).read(settingsProvider).extensionVerificationBrowserMode, + ); + if (!verified || !mounted) rethrow; + + return _searchAutoFillCandidates( + query: query, + usesAutomaticProvider: usesAutomaticProvider, + selectedProvider: selectedProvider, + extensionState: extensionState, + allowVerificationRetry: false, + ); + } + } + Future _fetchAutoFillPreview() async { if (_autoFillFields.isEmpty) { _showSheetSnackBar(context.l10n.editMetadataAutoFillNoneSelected); @@ -1067,28 +1131,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { if (needsTrackLookup && best == null) { final query = queryParts.join(' '); - final List> results; - if (usesAutomaticProvider) { - results = await PlatformBridge.searchTracksWithMetadataProviders( - query, - limit: 5, - ); - } else if (selectedProvider!.hasCustomSearch) { - final trackFilter = selectedProvider.searchBehavior?.filterIdForKind( - 'track', - ); - results = await PlatformBridge.customSearchWithExtension( - selectedProvider.id, - query, - options: {'limit': 5, 'filter': ?trackFilter}, - ); - } else { - results = await PlatformBridge.searchTracksWithMetadataProvider( - selectedProvider.id, - query, - limit: 5, - ); - } + final results = await _searchAutoFillCandidates( + query: query, + usesAutomaticProvider: usesAutomaticProvider, + selectedProvider: selectedProvider, + extensionState: extensionState, + ); if (!mounted) return; diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index a7adebf4..df59dcd1 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -30,6 +30,7 @@ import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; +import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/user_facing_error.dart'; diff --git a/lib/utils/extension_auth_launcher.dart b/lib/utils/extension_auth_launcher.dart index 8583ba85..8d07503f 100644 --- a/lib/utils/extension_auth_launcher.dart +++ b/lib/utils/extension_auth_launcher.dart @@ -25,6 +25,37 @@ bool isExtensionVerificationRequired(Object error) { _containsHttpStatusCode(message, '428'); } +String? extensionIdFromVerificationError( + Object error, + Iterable knownExtensionIds, +) { + final idsByNormalized = { + for (final id in knownExtensionIds) + if (id.trim().isNotEmpty) id.trim().toLowerCase(): id.trim(), + }; + if (idsByNormalized.isEmpty) return null; + + final message = error.toString(); + final canonicalMatch = RegExp( + r'''extension\s+['"]([^'"]+)['"]''', + caseSensitive: false, + ).firstMatch(message); + final canonicalId = canonicalMatch?.group(1)?.trim().toLowerCase(); + if (canonicalId != null && idsByNormalized.containsKey(canonicalId)) { + return idsByNormalized[canonicalId]; + } + + final lowerMessage = message.toLowerCase(); + final orderedIds = idsByNormalized.keys.toList() + ..sort((a, b) => b.length.compareTo(a.length)); + for (final normalizedId in orderedIds) { + if (lowerMessage.contains(normalizedId)) { + return idsByNormalized[normalizedId]; + } + } + return null; +} + bool _containsHttpStatusCode(String message, String code) { return message.contains('http $code') || message.contains('http status $code') || diff --git a/test/extension_auth_launcher_test.dart b/test/extension_auth_launcher_test.dart new file mode 100644 index 00000000..b16e9ca8 --- /dev/null +++ b/test/extension_auth_launcher_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; + +void main() { + test('extracts the extension that raised a verification challenge', () { + expect( + extensionIdFromVerificationError( + "verification_required: extension 'tidal-web' needs verification", + const ['amazon-web', 'tidal-web'], + ), + 'tidal-web', + ); + }); + + test('prefers the longest known extension id in legacy errors', () { + expect( + extensionIdFromVerificationError( + 'qobuz-web verification_required', + const ['qobuz', 'qobuz-web'], + ), + 'qobuz-web', + ); + }); +}