mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-02 06:09:39 +02:00
fix(library): offer re-enrichment for downloaded selections
This commit is contained in:
1 parent
eed56d4cbb
commit
cef57af562
6 files changed
+267
-135
No files matched your search
@@ -7,9 +7,7 @@ import 'package:spotiflac_android/theme/cover_palette.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/batch_track_actions.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/local_track_batch_actions.dart';
|
||||
import 'package:spotiflac_android/models/unified_library_item.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
@@ -19,9 +17,6 @@ import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart';
|
||||
import 'package:spotiflac_android/utils/cover_art_utils.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
import 'package:spotiflac_android/utils/int_utils.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
@@ -662,68 +657,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
|
||||
}
|
||||
|
||||
Future<void> _reEnrichSelected(List<DownloadHistoryItem> allTracks) async {
|
||||
final selected = allTracks
|
||||
.where((track) => selectedIds.contains(track.id))
|
||||
.toList(growable: false);
|
||||
final history = ref.read(downloadHistoryProvider.notifier);
|
||||
await reEnrichLocalTracks(
|
||||
await reEnrichLibraryTracks(
|
||||
context,
|
||||
ref,
|
||||
[
|
||||
for (final track in selected)
|
||||
LocalLibraryItem.fromJson({
|
||||
...track.toJson(),
|
||||
'scannedAt': track.downloadedAt.toIso8601String(),
|
||||
}),
|
||||
],
|
||||
_selectedUnifiedItems(allTracks),
|
||||
isActive: () => mounted,
|
||||
onSelectionHide: () async {
|
||||
setState(() => isSelectionMode = false);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
},
|
||||
onSelectionRestore: () => setState(() => isSelectionMode = true),
|
||||
onComplete: exitSelectionMode,
|
||||
refreshLibrary: () async {
|
||||
// Downloaded albums read history, not the local-scan index. Read back
|
||||
// the saved tags so failed or unselected fields cannot change history.
|
||||
for (final track in selected) {
|
||||
try {
|
||||
await DownloadedEmbeddedCoverResolver.invalidate(track.filePath);
|
||||
final metadata = await PlatformBridge.readDisplayAudioMetadata(
|
||||
track.filePath,
|
||||
);
|
||||
if (metadata['error'] != null) continue;
|
||||
await history.updateMetadataForItem(
|
||||
id: track.id,
|
||||
trackName: metadata['title'] as String? ?? track.trackName,
|
||||
artistName: metadata['artist'] as String? ?? track.artistName,
|
||||
albumName: metadata['album'] as String? ?? track.albumName,
|
||||
albumArtist: metadata['album_artist'] as String?,
|
||||
isrc: metadata['isrc'] as String?,
|
||||
trackNumber: readPositiveInt(metadata['track_number']),
|
||||
totalTracks: readPositiveInt(metadata['total_tracks']),
|
||||
discNumber: readPositiveInt(metadata['disc_number']),
|
||||
totalDiscs: readPositiveInt(metadata['total_discs']),
|
||||
releaseDate: metadata['date'] as String?,
|
||||
genre: metadata['genre'] as String?,
|
||||
composer: metadata['composer'] as String?,
|
||||
label: metadata['label'] as String?,
|
||||
copyright: metadata['copyright'] as String?,
|
||||
explicit: parseExplicitFlag(metadata['explicit']),
|
||||
hasLyrics:
|
||||
metadata.containsKey('hasLyrics') ||
|
||||
metadata.containsKey('lyrics')
|
||||
? metadata['hasLyrics'] == true ||
|
||||
hasUsableLyricsContent(
|
||||
metadata['lyrics']?.toString() ?? '',
|
||||
)
|
||||
: null,
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint('Could not refresh re-enriched download: $error');
|
||||
}
|
||||
}
|
||||
onComplete: () {
|
||||
_onEmbeddedCoverChanged();
|
||||
exitSelectionMode();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import 'package:spotiflac_android/widgets/library_search_results.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
||||
|
||||
@@ -21,64 +21,22 @@ extension _QueueTabBatchActions on _QueueTabState {
|
||||
onComplete: _exitSelectionMode,
|
||||
);
|
||||
|
||||
Future<void> _reEnrichSelectedLocalFromQueue(
|
||||
List<UnifiedLibraryItem> allItems,
|
||||
) => reEnrichLocalTracks(
|
||||
context,
|
||||
ref,
|
||||
_selectedItemsFromAll(allItems)
|
||||
.map((item) => item.localItem)
|
||||
.whereType<LocalLibraryItem>()
|
||||
.toList(growable: false),
|
||||
isActive: () => mounted,
|
||||
onSelectionHide: () async {
|
||||
_setState(() => _isSelectionMode = false);
|
||||
_hideSelectionOverlay();
|
||||
},
|
||||
onSelectionRestore: () => _setState(() => _isSelectionMode = true),
|
||||
onComplete: _exitSelectionMode,
|
||||
);
|
||||
|
||||
/// Share selected tracks via system share sheet
|
||||
Future<void> _shareSelected(List<UnifiedLibraryItem> allItems) async {
|
||||
final itemsById = {for (final item in allItems) item.id: item};
|
||||
final safUris = <String>[];
|
||||
final filesToShare = <XFile>[];
|
||||
|
||||
for (final id in _selectedIds) {
|
||||
final item = itemsById[id];
|
||||
if (item == null) continue;
|
||||
final path = item.filePath;
|
||||
if (isContentUri(path)) {
|
||||
if (await fileExists(path)) safUris.add(path);
|
||||
} else if (await fileExists(path)) {
|
||||
filesToShare.add(XFile(path));
|
||||
}
|
||||
}
|
||||
|
||||
if (safUris.isEmpty && filesToShare.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.selectionShareNoFiles)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (safUris.isNotEmpty) {
|
||||
try {
|
||||
if (safUris.length == 1) {
|
||||
await PlatformBridge.shareContentUri(safUris.first);
|
||||
} else {
|
||||
await PlatformBridge.shareMultipleContentUris(safUris);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (filesToShare.isNotEmpty) {
|
||||
await SharePlus.instance.share(ShareParams(files: filesToShare));
|
||||
}
|
||||
}
|
||||
Future<void> _reEnrichSelectedFromQueue(List<UnifiedLibraryItem> allItems) =>
|
||||
reEnrichLibraryTracks(
|
||||
context,
|
||||
ref,
|
||||
_selectedItemsFromAll(allItems),
|
||||
isActive: () => mounted,
|
||||
onSelectionHide: () async {
|
||||
_setState(() => _isSelectionMode = false);
|
||||
_hideSelectionOverlay();
|
||||
},
|
||||
onSelectionRestore: () => _setState(() => _isSelectionMode = true),
|
||||
onComplete: () {
|
||||
_onEmbeddedCoverChanged();
|
||||
_exitSelectionMode();
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> _showBatchConvertSheet(
|
||||
BuildContext context,
|
||||
|
||||
@@ -49,16 +49,10 @@ extension _QueueTabItemWidgets on _QueueTabState {
|
||||
|
||||
actions.add(
|
||||
SelectionActionButton(
|
||||
icon: localOnlySelection
|
||||
? Icons.auto_fix_high_outlined
|
||||
: Icons.share_outlined,
|
||||
label: localOnlySelection
|
||||
? '${context.l10n.trackReEnrich} ($selectedCount)'
|
||||
: context.l10n.selectionShareCount(selectedCount),
|
||||
icon: Icons.auto_fix_high_outlined,
|
||||
label: '${context.l10n.trackReEnrich} ($selectedCount)',
|
||||
onPressed: selectedCount > 0
|
||||
? () => localOnlySelection
|
||||
? _reEnrichSelectedLocalFromQueue(unifiedItems)
|
||||
: _shareSelected(unifiedItems)
|
||||
? () => _reEnrichSelectedFromQueue(unifiedItems)
|
||||
: null,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
|
||||
@@ -5,16 +5,20 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/re_enrich_result.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/models/unified_library_item.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart';
|
||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/ffmpeg_reenrich.dart';
|
||||
import 'package:spotiflac_android/utils/int_utils.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/re_enrich_review_sheet.dart';
|
||||
@@ -137,6 +141,100 @@ Future<void> queueLocalTracksAsFlac(
|
||||
onComplete();
|
||||
}
|
||||
|
||||
Future<void> reEnrichLibraryTracks(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
List<UnifiedLibraryItem> selected, {
|
||||
required bool Function() isActive,
|
||||
required Future<void> Function() onSelectionHide,
|
||||
required VoidCallback onSelectionRestore,
|
||||
required VoidCallback onComplete,
|
||||
}) async {
|
||||
if (selected.isEmpty) return;
|
||||
final downloads = selected
|
||||
.map((item) => item.historyItem)
|
||||
.whereType<DownloadHistoryItem>()
|
||||
.toList(growable: false);
|
||||
final history = downloads.isEmpty
|
||||
? null
|
||||
: ref.read(downloadHistoryProvider.notifier);
|
||||
await reEnrichLocalTracks(
|
||||
context,
|
||||
ref,
|
||||
[
|
||||
for (final item in selected)
|
||||
if (item.localItem != null)
|
||||
item.localItem!
|
||||
else if (item.historyItem case final track?)
|
||||
LocalLibraryItem.fromJson({
|
||||
...track.toJson(),
|
||||
'scannedAt': track.downloadedAt.toIso8601String(),
|
||||
}),
|
||||
],
|
||||
isActive: isActive,
|
||||
onSelectionHide: onSelectionHide,
|
||||
onSelectionRestore: onSelectionRestore,
|
||||
onComplete: onComplete,
|
||||
refreshLibrary: () async {
|
||||
// Downloaded tracks read history, not the local-scan index. Read back
|
||||
// saved tags so failed or unselected fields cannot change history.
|
||||
for (final track in downloads) {
|
||||
try {
|
||||
await DownloadedEmbeddedCoverResolver.invalidate(track.filePath);
|
||||
final metadata = await PlatformBridge.readDisplayAudioMetadata(
|
||||
track.filePath,
|
||||
);
|
||||
if (metadata['error'] != null) continue;
|
||||
await history!.updateMetadataForItem(
|
||||
id: track.id,
|
||||
trackName: metadata['title'] as String? ?? track.trackName,
|
||||
artistName: metadata['artist'] as String? ?? track.artistName,
|
||||
albumName: metadata['album'] as String? ?? track.albumName,
|
||||
albumArtist: metadata['album_artist'] as String?,
|
||||
isrc: metadata['isrc'] as String?,
|
||||
trackNumber: readPositiveInt(metadata['track_number']),
|
||||
totalTracks: readPositiveInt(metadata['total_tracks']),
|
||||
discNumber: readPositiveInt(metadata['disc_number']),
|
||||
totalDiscs: readPositiveInt(metadata['total_discs']),
|
||||
releaseDate: metadata['date'] as String?,
|
||||
genre: metadata['genre'] as String?,
|
||||
composer: metadata['composer'] as String?,
|
||||
label: metadata['label'] as String?,
|
||||
copyright: metadata['copyright'] as String?,
|
||||
explicit: parseExplicitFlag(metadata['explicit']),
|
||||
hasLyrics:
|
||||
metadata.containsKey('hasLyrics') ||
|
||||
metadata.containsKey('lyrics')
|
||||
? metadata['hasLyrics'] == true ||
|
||||
hasUsableLyricsContent(
|
||||
metadata['lyrics']?.toString() ?? '',
|
||||
)
|
||||
: null,
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint('Could not refresh re-enriched download: $error');
|
||||
}
|
||||
}
|
||||
if (isActive() && selected.any((item) => item.localItem != null)) {
|
||||
await _refreshReEnrichedLocalLibrary(ref);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refreshReEnrichedLocalLibrary(WidgetRef ref) async {
|
||||
final library = ref.read(localLibraryProvider.notifier);
|
||||
try {
|
||||
if (!ref.read(localLibraryProvider).isScanning) {
|
||||
await library.scanAllSources();
|
||||
} else {
|
||||
await library.reloadFromStorage();
|
||||
}
|
||||
} catch (_) {
|
||||
await library.reloadFromStorage();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reEnrichLocalTracks(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
@@ -247,15 +345,7 @@ Future<void> reEnrichLocalTracks(
|
||||
if (refreshLibrary != null) {
|
||||
await refreshLibrary();
|
||||
} else {
|
||||
try {
|
||||
if (!ref.read(localLibraryProvider).isScanning) {
|
||||
await ref.read(localLibraryProvider.notifier).scanAllSources();
|
||||
} else {
|
||||
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
||||
}
|
||||
} catch (_) {
|
||||
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
||||
}
|
||||
await _refreshReEnrichedLocalLibrary(ref);
|
||||
}
|
||||
|
||||
if (!context.mounted || !isActive()) return;
|
||||
|
||||
@@ -8,10 +8,14 @@ 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/models/settings.dart';
|
||||
import 'package:spotiflac_android/models/unified_library_item.dart';
|
||||
import 'package:spotiflac_android/providers/download_history_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/local_track_batch_actions.dart';
|
||||
import 'package:spotiflac_android/theme/app_theme.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
|
||||
@@ -55,12 +59,153 @@ class _History extends DownloadHistoryNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
class _Library extends LocalLibraryNotifier {
|
||||
int refreshes = 0;
|
||||
|
||||
@override
|
||||
LocalLibraryState build() => LocalLibraryState();
|
||||
|
||||
@override
|
||||
Future<void> scanAllSources({bool forceFullScan = false}) async {
|
||||
refreshes++;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
const channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
|
||||
for (final includeLocal in [false, true]) {
|
||||
testWidgets(
|
||||
'Library re-enrich includes downloaded tracks and refreshes each source (mixed: $includeLocal)',
|
||||
(tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final cache = Directory.systemTemp.createTempSync('library_reenrich_');
|
||||
DownloadedEmbeddedCoverResolver.setPersistentCacheDirectoryForTesting(
|
||||
cache,
|
||||
);
|
||||
addTearDown(() async {
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.runAsync(() async {
|
||||
await DownloadedEmbeddedCoverResolver.resetMemoryStateForTesting();
|
||||
DownloadedEmbeddedCoverResolver.setPersistentCacheDirectoryForTesting(
|
||||
null,
|
||||
);
|
||||
await cache.delete(recursive: true);
|
||||
});
|
||||
});
|
||||
final history = _History();
|
||||
final library = _Library();
|
||||
final requests = <Map<String, dynamic>>[];
|
||||
final paths = [
|
||||
'content://library/document/download.flac',
|
||||
if (includeLocal) '/music/local.flac',
|
||||
];
|
||||
var completed = false;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
if (call.method == 'reEnrichFile') {
|
||||
final request = Map<String, dynamic>.from(
|
||||
jsonDecode((call.arguments as Map)['request_json'] as String)
|
||||
as Map,
|
||||
);
|
||||
requests.add(request);
|
||||
return request['preview_only'] == true
|
||||
? {
|
||||
'method': 'preview',
|
||||
'enriched_metadata': {'isrc': 'USABC2600001'},
|
||||
}
|
||||
: {'method': 'native'};
|
||||
}
|
||||
if (call.method == 'readAudioMetadata') {
|
||||
return {
|
||||
'trackName': 'Downloaded',
|
||||
'artistName': 'Artist',
|
||||
'albumName': 'Album',
|
||||
'isrc': 'USABC2600001',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
settingsProvider.overrideWith(_Settings.new),
|
||||
downloadHistoryProvider.overrideWith(() => history),
|
||||
localLibraryProvider.overrideWith(() => library),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Consumer(
|
||||
builder: (context, ref, _) => TextButton(
|
||||
onPressed: () => reEnrichLibraryTracks(
|
||||
context,
|
||||
ref,
|
||||
[
|
||||
UnifiedLibraryItem.fromDownloadHistory(
|
||||
DownloadHistoryItem(
|
||||
id: 'download',
|
||||
trackName: 'Downloaded',
|
||||
artistName: 'Artist',
|
||||
albumName: 'Album',
|
||||
filePath: paths.first,
|
||||
service: 'example-provider',
|
||||
downloadedAt: DateTime(2026),
|
||||
),
|
||||
),
|
||||
if (includeLocal)
|
||||
UnifiedLibraryItem.fromLocalLibrary(
|
||||
LocalLibraryItem(
|
||||
id: 'local',
|
||||
trackName: 'Local',
|
||||
artistName: 'Artist',
|
||||
albumName: 'Album',
|
||||
filePath: paths.last,
|
||||
scannedAt: DateTime(2026),
|
||||
),
|
||||
),
|
||||
],
|
||||
isActive: () => context.mounted,
|
||||
onSelectionHide: () async {},
|
||||
onSelectionRestore: () {},
|
||||
onComplete: () => completed = true,
|
||||
),
|
||||
child: const Text('Start'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Start'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Review changes'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(requests.map((request) => request['file_path']), paths);
|
||||
await tester.tap(find.text('Apply changes'));
|
||||
for (var attempt = 0; attempt < 50 && !completed; attempt++) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.runAsync(() async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
});
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
expect(requests.map((request) => request['file_path']), [
|
||||
...paths,
|
||||
...paths,
|
||||
]);
|
||||
expect(history.updates, {'download': 'USABC2600001'});
|
||||
expect(library.refreshes, includeLocal ? 1 : 0);
|
||||
expect(completed, isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
for (final mornye in [false, true]) {
|
||||
testWidgets(
|
||||
'downloaded album re-enrich restores selection on cancel and updates only selected files ($mornye)',
|
||||
|
||||
Reference in new issue
Block a user