mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 12:52:13 +02:00
fix(metadata): restore cover artwork autofill
This commit is contained in:
@@ -172,7 +172,10 @@ class _EditMetadataSheet extends StatefulWidget {
|
||||
|
||||
class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
static const _coverResizeDimensions = <int>[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 = <String, dynamic>{...best};
|
||||
final mergedDetails = <String, dynamic>{...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<String, dynamic> ? 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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> _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<void>.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 = <String>[];
|
||||
final downloadedUrls = <String>[];
|
||||
final candidate = <String, Object>{
|
||||
'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<void>.delayed(const Duration(milliseconds: 20));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user