mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
perf(metadata): avoid SAF copies during quality probes
This commit is contained in:
@@ -524,7 +524,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
lookupItems: _lookupItemsWithUpdates([updated]),
|
||||
);
|
||||
await _db.upsert(updated.toJson());
|
||||
_bumpHistoryRevision();
|
||||
// Swiping through older tracks can backfill several quality records in a
|
||||
// short burst. Coalesce their DB-derived Library refreshes just like
|
||||
// download completion writes instead of rebuilding every view per swipe.
|
||||
_scheduleIndexBump();
|
||||
}
|
||||
|
||||
Future<void> updateMetadataForItem({
|
||||
|
||||
@@ -392,7 +392,7 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
|
||||
}
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.readFileMetadata(filePath);
|
||||
final result = await PlatformBridge.readDisplayAudioMetadata(filePath);
|
||||
final error = result['error'];
|
||||
if (error != null) {
|
||||
if (_isPermanentProbeError(error.toString())) {
|
||||
|
||||
@@ -297,7 +297,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
|
||||
_hasLoadedResolvedAudioMetadata = true;
|
||||
|
||||
try {
|
||||
final metadata = await PlatformBridge.readFileMetadata(sourcePath);
|
||||
final metadata = await PlatformBridge.readDisplayAudioMetadata(
|
||||
sourcePath,
|
||||
);
|
||||
if (!mounted ||
|
||||
generation != _metadataLoadGeneration ||
|
||||
sourcePath != cleanFilePath) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/download_request_payload.dart';
|
||||
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('PlatformBridge');
|
||||
@@ -833,6 +834,25 @@ class PlatformBridge {
|
||||
return _invokeMap('readFileMetadata', {'file_path': filePath});
|
||||
}
|
||||
|
||||
/// Reads the tags and quality fields used for automatic Library display.
|
||||
///
|
||||
/// Android's readAudioMetadata implementation can inspect most SAF files
|
||||
/// through /proc/self/fd, avoiding the full-file cache copy required by tag
|
||||
/// editing. Keep the complete reader as a compatibility fallback for
|
||||
/// malformed or incorrectly named files that the lightweight scanner could
|
||||
/// only identify from their filename.
|
||||
static Future<Map<String, dynamic>> readDisplayAudioMetadata(
|
||||
String filePath,
|
||||
) async {
|
||||
final scanned = await readAudioMetadata(filePath);
|
||||
if (scanned != null &&
|
||||
scanned['error'] == null &&
|
||||
scanned['metadataFromFilename'] != true) {
|
||||
return normalizeScannedAudioMetadata(scanned);
|
||||
}
|
||||
return readFileMetadata(filePath);
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> editFileMetadata(
|
||||
String filePath,
|
||||
Map<String, String> metadata,
|
||||
|
||||
@@ -3,6 +3,39 @@ import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
|
||||
/// Audio format/quality helpers shared by the download queue and history
|
||||
/// providers.
|
||||
Map<String, dynamic> normalizeScannedAudioMetadata(
|
||||
Map<String, dynamic> metadata,
|
||||
) {
|
||||
dynamic firstValue(String snakeCase, String camelCase) {
|
||||
final snakeValue = metadata[snakeCase];
|
||||
return snakeValue ?? metadata[camelCase];
|
||||
}
|
||||
|
||||
final normalized = <String, dynamic>{...metadata};
|
||||
final metadataFromFilename = metadata['metadataFromFilename'] == true;
|
||||
|
||||
// readAudioMetadata uses the LibraryScanResult contract. Automatic metadata
|
||||
// probes use the same snake_case keys as readFileMetadata so callers can use
|
||||
// the cheaper SAF file-descriptor path without knowing which backend result
|
||||
// shape was returned.
|
||||
if (!metadataFromFilename) {
|
||||
normalized['title'] = firstValue('title', 'trackName');
|
||||
normalized['artist'] = firstValue('artist', 'artistName');
|
||||
normalized['album'] = firstValue('album', 'albumName');
|
||||
}
|
||||
normalized['album_artist'] = firstValue('album_artist', 'albumArtist');
|
||||
normalized['date'] = firstValue('date', 'releaseDate');
|
||||
normalized['track_number'] = firstValue('track_number', 'trackNumber');
|
||||
normalized['total_tracks'] = firstValue('total_tracks', 'totalTracks');
|
||||
normalized['disc_number'] = firstValue('disc_number', 'discNumber');
|
||||
normalized['total_discs'] = firstValue('total_discs', 'totalDiscs');
|
||||
normalized['bit_depth'] = firstValue('bit_depth', 'bitDepth');
|
||||
normalized['sample_rate'] = firstValue('sample_rate', 'sampleRate');
|
||||
normalized['audio_codec'] =
|
||||
firstValue('audio_codec', 'audioCodec') ?? metadata['format'];
|
||||
return normalized;
|
||||
}
|
||||
|
||||
int? readPositiveBitrateKbps(dynamic value) {
|
||||
final parsed = readPositiveInt(value);
|
||||
if (parsed == null) return null;
|
||||
|
||||
@@ -792,6 +792,53 @@ void main() {
|
||||
});
|
||||
|
||||
group('audio conversion utils', () {
|
||||
test('normalizes lightweight library scan metadata for display probes', () {
|
||||
final normalized = normalizeScannedAudioMetadata({
|
||||
'trackName': 'Song',
|
||||
'artistName': 'Artist',
|
||||
'albumName': 'Album',
|
||||
'albumArtist': 'Album Artist',
|
||||
'releaseDate': '2026',
|
||||
'trackNumber': 2,
|
||||
'totalTracks': 10,
|
||||
'discNumber': 1,
|
||||
'totalDiscs': 2,
|
||||
'bitDepth': 24,
|
||||
'sampleRate': 96000,
|
||||
'bitrate': 1840,
|
||||
'format': 'flac',
|
||||
});
|
||||
|
||||
expect(normalized['title'], 'Song');
|
||||
expect(normalized['artist'], 'Artist');
|
||||
expect(normalized['album'], 'Album');
|
||||
expect(normalized['album_artist'], 'Album Artist');
|
||||
expect(normalized['date'], '2026');
|
||||
expect(normalized['track_number'], 2);
|
||||
expect(normalized['total_tracks'], 10);
|
||||
expect(normalized['disc_number'], 1);
|
||||
expect(normalized['total_discs'], 2);
|
||||
expect(normalized['bit_depth'], 24);
|
||||
expect(normalized['sample_rate'], 96000);
|
||||
expect(normalized['bitrate'], 1840);
|
||||
expect(normalized['audio_codec'], 'flac');
|
||||
});
|
||||
|
||||
test('does not replace stored tags with filename scan fallbacks', () {
|
||||
final normalized = normalizeScannedAudioMetadata({
|
||||
'trackName': 'Filename fallback',
|
||||
'artistName': 'Unknown Artist',
|
||||
'albumName': 'Unknown Album',
|
||||
'metadataFromFilename': true,
|
||||
'format': 'flac',
|
||||
});
|
||||
|
||||
expect(normalized['title'], isNull);
|
||||
expect(normalized['artist'], isNull);
|
||||
expect(normalized['album'], isNull);
|
||||
expect(normalized['audio_codec'], 'flac');
|
||||
});
|
||||
|
||||
test('distinguishes an ALAC codec from its M4A container', () {
|
||||
expect(normalizeAudioFormatValue('ALAC'), 'alac');
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
const backendChannel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
|
||||
tearDown(() async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(backendChannel, null);
|
||||
});
|
||||
|
||||
test('display metadata uses the lightweight scan result directly', () async {
|
||||
final invokedMethods = <String>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(backendChannel, (call) async {
|
||||
invokedMethods.add(call.method);
|
||||
if (call.method == 'readAudioMetadata') {
|
||||
return jsonEncode({
|
||||
'trackName': 'Song',
|
||||
'artistName': 'Artist',
|
||||
'bitDepth': 24,
|
||||
'sampleRate': 96000,
|
||||
'bitrate': 1800,
|
||||
'format': 'flac',
|
||||
});
|
||||
}
|
||||
fail('Unexpected full metadata read: ${call.method}');
|
||||
});
|
||||
|
||||
final result = await PlatformBridge.readDisplayAudioMetadata(
|
||||
'content://downloads/song.flac',
|
||||
);
|
||||
|
||||
expect(invokedMethods, ['readAudioMetadata']);
|
||||
expect(result['title'], 'Song');
|
||||
expect(result['artist'], 'Artist');
|
||||
expect(result['bit_depth'], 24);
|
||||
expect(result['sample_rate'], 96000);
|
||||
expect(result['audio_codec'], 'flac');
|
||||
});
|
||||
|
||||
test(
|
||||
'display metadata falls back when scan only parsed the filename',
|
||||
() async {
|
||||
final invokedMethods = <String>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(backendChannel, (call) async {
|
||||
invokedMethods.add(call.method);
|
||||
if (call.method == 'readAudioMetadata') {
|
||||
return jsonEncode({
|
||||
'trackName': 'Filename fallback',
|
||||
'metadataFromFilename': true,
|
||||
'format': 'flac',
|
||||
});
|
||||
}
|
||||
if (call.method == 'readFileMetadata') {
|
||||
return jsonEncode({
|
||||
'title': 'Embedded title',
|
||||
'audio_codec': 'opus',
|
||||
});
|
||||
}
|
||||
fail('Unexpected method: ${call.method}');
|
||||
});
|
||||
|
||||
final result = await PlatformBridge.readDisplayAudioMetadata(
|
||||
'content://downloads/misnamed.flac',
|
||||
);
|
||||
|
||||
expect(invokedMethods, ['readAudioMetadata', 'readFileMetadata']);
|
||||
expect(result['title'], 'Embedded title');
|
||||
expect(result['audio_codec'], 'opus');
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user