From 80d9d87870450fca55fd975dafb05d5fa59d8c28 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 28 Jul 2026 01:50:41 +0700 Subject: [PATCH] perf(metadata): avoid SAF copies during quality probes --- lib/providers/download_history_provider.dart | 5 +- ...download_history_provider_maintenance.dart | 2 +- lib/screens/track_metadata_screen.dart | 4 +- lib/services/platform_bridge.dart | 20 +++++ lib/utils/audio_format_utils.dart | 33 ++++++++ test/models_and_utils_test.dart | 47 +++++++++++ test/platform_bridge_metadata_test.dart | 78 +++++++++++++++++++ 7 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 test/platform_bridge_metadata_test.dart diff --git a/lib/providers/download_history_provider.dart b/lib/providers/download_history_provider.dart index 5e8806d3..9ab82121 100644 --- a/lib/providers/download_history_provider.dart +++ b/lib/providers/download_history_provider.dart @@ -524,7 +524,10 @@ class DownloadHistoryNotifier extends Notifier { 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 updateMetadataForItem({ diff --git a/lib/providers/download_history_provider_maintenance.dart b/lib/providers/download_history_provider_maintenance.dart index 7862366c..e17157a2 100644 --- a/lib/providers/download_history_provider_maintenance.dart +++ b/lib/providers/download_history_provider_maintenance.dart @@ -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())) { diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 9b4a1df1..24041527 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -297,7 +297,9 @@ class _TrackMetadataScreenState extends ConsumerState _hasLoadedResolvedAudioMetadata = true; try { - final metadata = await PlatformBridge.readFileMetadata(sourcePath); + final metadata = await PlatformBridge.readDisplayAudioMetadata( + sourcePath, + ); if (!mounted || generation != _metadataLoadGeneration || sourcePath != cleanFilePath) { diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index a4289adb..f0f17504 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -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> 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> editFileMetadata( String filePath, Map metadata, diff --git a/lib/utils/audio_format_utils.dart b/lib/utils/audio_format_utils.dart index 7bf6ca9c..6e1373c9 100644 --- a/lib/utils/audio_format_utils.dart +++ b/lib/utils/audio_format_utils.dart @@ -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 normalizeScannedAudioMetadata( + Map metadata, +) { + dynamic firstValue(String snakeCase, String camelCase) { + final snakeValue = metadata[snakeCase]; + return snakeValue ?? metadata[camelCase]; + } + + final normalized = {...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; diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 09689e62..d6dfa09a 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -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( diff --git a/test/platform_bridge_metadata_test.dart b/test/platform_bridge_metadata_test.dart new file mode 100644 index 00000000..84be721b --- /dev/null +++ b/test/platform_bridge_metadata_test.dart @@ -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 = []; + 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 = []; + 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'); + }, + ); +}