diff --git a/lib/screens/track_metadata_edit_sheet.dart b/lib/screens/track_metadata_edit_sheet.dart index 4b84aedd..e5f5a7b8 100644 --- a/lib/screens/track_metadata_edit_sheet.dart +++ b/lib/screens/track_metadata_edit_sheet.dart @@ -172,7 +172,10 @@ class _EditMetadataSheet extends StatefulWidget { class _EditMetadataSheetState extends State<_EditMetadataSheet> { static const _coverResizeDimensions = [500, 1000, 1500, 2000, 3000]; - static final RegExp _metadataCollapsePattern = RegExp(r'[^a-z0-9]+'); + static final RegExp _metadataCollapsePattern = RegExp( + r'[^\p{L}\p{M}\p{N}]+', + unicode: true, + ); static final RegExp _metadataWhitespacePattern = RegExp(r'\s+'); static final RegExp _spotifyTrackIdPattern = RegExp(r'^[A-Za-z0-9]{22}$'); static final RegExp _deezerTrackIdPattern = RegExp(r'^\d+$'); @@ -599,7 +602,15 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ); final coverPath = '${tempDir.path}${Platform.pathSeparator}cover.jpg'; try { - await PlatformBridge.downloadCoverToFile(coverUrl, coverPath); + final result = await PlatformBridge.downloadCoverToFile( + coverUrl, + coverPath, + ); + if (result['error'] != null || result['success'] == false) { + throw StateError( + result['error']?.toString() ?? 'Cover download failed', + ); + } final file = File(coverPath); if (!await file.exists() || await file.length() <= 0) { await tempDir.delete(recursive: true); @@ -610,7 +621,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { tempDir: tempDir.path, details: await _readCoverDetails(coverPath), ); - } catch (_) { + } catch (e) { + _log.w('Could not download metadata artwork: $e'); await _deleteTempDirectory(tempDir.path); return null; } @@ -1478,31 +1490,62 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { return; } - if (!usesAutomaticProvider) { - final trackId = best['id']?.toString().trim() ?? ''; + var resolvedBest = best; + final matchedProviderId = usesAutomaticProvider + ? resolvedBest['provider_id']?.toString().trim() ?? '' + : selectedProviderId; + if (matchedProviderId.isNotEmpty) { + final trackId = resolvedBest['id']?.toString().trim() ?? ''; if (trackId.isNotEmpty) { try { final details = await PlatformBridge.getProviderMetadata( - selectedProviderId, + matchedProviderId, 'track', trackId, ); - final mergedDetails = {...best}; + final mergedDetails = {...resolvedBest}; for (final entry in _unwrapTrackPayload(details).entries) { final value = entry.value; if (value != null && value.toString().trim().isNotEmpty) { mergedDetails[entry.key] = value; } } - best = mergedDetails; + resolvedBest = mergedDetails; } catch (e) { _log.w( 'Detailed metadata lookup failed for ' - '$selectedProviderId/$trackId: $e', + '$matchedProviderId/$trackId: $e', ); } } + + if (_autoFillFields.contains('cover') && + _metadataCandidateCoverUrl(resolvedBest) == null) { + final albumId = resolvedBest['album_id']?.toString().trim() ?? ''; + if (albumId.isNotEmpty) { + try { + final details = await PlatformBridge.getProviderMetadata( + matchedProviderId, + 'album', + albumId, + ); + final albumData = details['album_info'] ?? details['album']; + final coverUrl = _metadataCandidateCoverUrl( + albumData is Map ? albumData : details, + ); + if (coverUrl != null) { + resolvedBest = {...resolvedBest, 'cover_url': coverUrl}; + } + } catch (e) { + _log.w( + 'Album artwork lookup failed for ' + '$matchedProviderId/$albumId: $e', + ); + } + } + } } + best = resolvedBest; } final selectedBest = best; @@ -1732,6 +1775,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { setState(() => _fetching = true); try { var filledCount = 0; + var coverDownloadFailed = false; for (final key in _autoFillFields) { if (key == 'cover') continue; final value = preview.values[key]; @@ -1777,6 +1821,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } filledCount++; } + } else { + coverDownloadFailed = true; } } @@ -1794,7 +1840,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } }); _showSheetSnackBar( - filledCount > 0 + coverDownloadFailed + ? context.l10n.updateDownloadFailed + : filledCount > 0 ? context.l10n.editMetadataAutoFillDoneFromSource( filledCount, preview.sourceName, diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 71070b61..0791722b 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1065,10 +1065,12 @@ class PlatformBridge { String outputPath, { int maxDimension = 0, }) { - return _invokeMap('downloadCoverToFile', { - 'cover_url': coverUrl, - 'output_path': outputPath, - 'max_dimension': maxDimension, + return _withMediaFileAccess([outputPath], () { + return _invokeMap('downloadCoverToFile', { + 'cover_url': coverUrl, + 'output_path': outputPath, + 'max_dimension': maxDimension, + }); }); } diff --git a/test/track_metadata_cover_search_test.dart b/test/track_metadata_cover_search_test.dart new file mode 100644 index 00000000..015f7310 --- /dev/null +++ b/test/track_metadata_cover_search_test.dart @@ -0,0 +1,211 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/download_history_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/screens/track_metadata_screen.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; + +class _Extensions extends ExtensionNotifier { + @override + ExtensionState build() => const ExtensionState( + isInitialized: true, + extensions: [ + Extension( + id: 'example-provider', + name: 'example-provider', + displayName: 'Example Music', + version: '1.0.0', + description: 'Metadata fixture', + enabled: true, + status: 'loaded', + hasMetadataProvider: true, + ), + ], + ); +} + +Future _settleFileIo(WidgetTester tester) async { + // Real file operations need an event-loop turn between pumped frames. + // pumpAndSettle alone only advances the test's fake clock. + await tester.runAsync(() async { + var idleFrames = 0; + for (var i = 0; i < 200 && idleFrames < 3; i++) { + await tester.pump(const Duration(milliseconds: 50)); + await Future.delayed(const Duration(milliseconds: 25)); + idleFrames = tester.binding.hasScheduledFrame ? 0 : idleFrames + 1; + } + expect(idleFrames, 3, reason: 'Metadata file operations did not settle'); + }); + await tester.pumpAndSettle(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('com.zarz.spotiflac/backend'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + const ffmpegEvents = MethodChannel('flutter.arthenica.com/ffmpeg_kit_event'); + setUp(() { + FlutterSecureStorage.setMockInitialValues({}); + messenger.setMockMethodCallHandler(ffmpegEvents, (_) async => null); + }); + tearDown(() => messenger.setMockMethodCallHandler(ffmpegEvents, null)); + const coverUrl = 'https://example.invalid/artwork/album.jpg'; + final imageBytes = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==', + ); + + for (final scenario in [ + 'track details', + 'album details', + 'selected provider album', + 'unicode', + 'search cover survives detail failure', + 'download failure', + ]) { + testWidgets('cover auto-fill resolves $scenario', (tester) async { + tester.view.physicalSize = const Size(900, 1800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + SharedPreferences.setMockInitialValues({}); + final title = scenario == 'unicode' ? '春の歌' : 'Song'; + final artist = scenario == 'unicode' ? '歌手' : 'Artist'; + final albumLookup = scenario.contains('album'); + final searchHasCover = + scenario == 'unicode' || + scenario == 'search cover survives detail failure'; + final lookups = []; + final downloadedUrls = []; + final candidate = { + 'id': 'song-1', + 'name': title, + 'artists': artist, + 'album_name': 'Album', + 'album_id': 'album-1', + 'provider_id': 'example-provider', + if (searchHasCover) 'cover_url': coverUrl, + }; + messenger.setMockMethodCallHandler(channel, (call) async { + switch (call.method) { + case 'searchTracksWithMetadataProviders': + case 'searchTracksWithMetadataProvider': + expect((call.arguments as Map)['query'], '$title $artist'); + return jsonEncode([candidate]); + case 'getProviderMetadata': + final args = call.arguments as Map; + expect(args['provider_id'], 'example-provider'); + final type = args['resource_type'] as String; + lookups.add(type); + if (scenario == 'search cover survives detail failure') { + throw PlatformException(code: 'lookup_failed'); + } + if (type == 'album') { + expect(args['resource_id'], 'album-1'); + return jsonEncode({ + 'album_info': {'images': coverUrl}, + }); + } + expect(args['resource_id'], 'song-1'); + return jsonEncode({ + 'track': {...candidate, if (!albumLookup) 'cover_url': coverUrl}, + }); + case 'downloadCoverToFile': + final args = call.arguments as Map; + downloadedUrls.add(args['cover_url'] as String); + if (scenario == 'download failure') { + return jsonEncode({'error': 'Artwork request failed'}); + } + await File(args['output_path'] as String).writeAsBytes(imageBytes); + return jsonEncode({'success': true}); + default: + return switch (call.method) { + 'safStat' => jsonEncode({'exists': true, 'size': 100}), + 'readAudioMetadata' => '{}', + 'readFileMetadata' => jsonEncode({'audio_codec': 'flac'}), + 'getLyricsLRCWithSource' => jsonEncode({'lyrics': ''}), + 'extractCoverToFile' => jsonEncode({'error': 'No cover'}), + 'getSafFileModTimes' => '{}', + _ => null, + }; + } + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + await PlatformBridge.clearTrackCache(); + await tester.pumpWidget( + ProviderScope( + overrides: [extensionProvider.overrideWith(_Extensions.new)], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: TrackMetadataScreen( + item: DownloadHistoryItem( + id: 'local-song', + trackName: title, + artistName: artist, + albumName: 'Album', + filePath: 'content://library/document/song.flac', + service: 'example-provider', + downloadedAt: DateTime(2026), + format: 'flac', + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Edit Metadata')); + await tester.tap(find.text('Edit Metadata')); + await _settleFileIo(tester); + await tester.tap(find.text('Auto-fill from online')); + await tester.pumpAndSettle(); + await tester.tap(find.text('None')); + await tester.pump(); + await tester.tap(find.widgetWithText(FilterChip, 'Cover Art')); + await tester.pump(); + if (scenario == 'selected provider album') { + await tester.tap(find.text('Automatic (provider priority)')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Example Music')); + await tester.pumpAndSettle(); + } + await tester.ensureVisible(find.text('Find metadata')); + await tester.tap(find.text('Find metadata')); + await tester.pump(); + if (scenario == 'selected provider album') { + await tester.pump(const Duration(milliseconds: 350)); + await tester.tap(find.widgetWithText(ListTile, title)); + } + await _settleFileIo(tester); + expect(lookups, albumLookup ? ['track', 'album'] : ['track']); + expect(downloadedUrls, contains(coverUrl)); + expect(find.text('No matching metadata found online'), findsNothing); + expect(find.text('Apply selected data'), findsOneWidget); + await tester.ensureVisible(find.text('Apply selected data')); + await tester.tap(find.text('Apply selected data')); + await _settleFileIo(tester); + if (scenario == 'download failure') { + expect(find.text('Download failed'), findsOneWidget); + expect(find.text('Apply selected data'), findsOneWidget); + expect(find.text('No matching metadata found online'), findsNothing); + } else { + expect(find.text('Apply selected data'), findsNothing); + } + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 20)); + }); + }); + } +}