diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index fbde8467..dd27b1f4 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -22,6 +22,7 @@ import 'package:spotiflac_android/services/download_queue_persistence.dart'; import 'package:spotiflac_android/services/download_progress.dart'; import 'package:spotiflac_android/services/download_file_finalizer.dart'; import 'package:spotiflac_android/services/download_container_finalizer.dart'; +import 'package:spotiflac_android/services/download_completion_audio.dart'; import 'package:spotiflac_android/services/download_metadata_resolver.dart'; import 'package:spotiflac_android/services/download_metadata_embedding.dart'; import 'package:spotiflac_android/services/download_saf_file_replacer.dart'; diff --git a/lib/providers/download_queue_provider_single_item.dart b/lib/providers/download_queue_provider_single_item.dart index 94edfe96..2d24b8d6 100644 --- a/lib/providers/download_queue_provider_single_item.dart +++ b/lib/providers/download_queue_provider_single_item.dart @@ -1564,16 +1564,17 @@ class _DownloadRun { if (path != null) { var historyFilePath = path; - final backendBitDepth = result['actual_bit_depth'] as int?; - final backendSampleRate = result['actual_sample_rate'] as int?; - final backendFormat = - normalizeAudioFormatValue( - result['audio_codec']?.toString() ?? result['format']?.toString(), - ) ?? - normalizeAudioFormatValue(audioFormatForPath(path)); - final backendBitrateKbps = readPositiveBitrateKbps( - result['bitrate'] ?? result['actual_bitrate'], + final audio = await resolveDownloadCompletionAudio( + result: result, + filePath: path, + fileName: finalSafFileName, + quality: actualQuality, + metadata: probedFinalMetadata, + readMetadata: PlatformBridge.readFileMetadata, + debug: _log.d, ); + actualQuality = audio.quality; + probedFinalMetadata = audio.metadata; final backendGenre = result['genre'] as String?; final backendLabel = result['label'] as String?; final backendCopyright = result['copyright'] as String?; @@ -1590,82 +1591,6 @@ class _DownloadRun { normalizeOptionalString(copyright) ?? normalizeOptionalString(existingInHistory?.copyright); - int? finalBitDepth = backendBitDepth; - int? finalSampleRate = backendSampleRate; - String? finalFormat = backendFormat; - int? finalBitrateKbps = backendBitrateKbps; - final lowerFilePath = path.toLowerCase(); - final canProbeFinalMetadata = - path.startsWith('content://') || - lowerFilePath.endsWith('.flac') || - lowerFilePath.endsWith('.m4a') || - lowerFilePath.endsWith('.mp4') || - lowerFilePath.endsWith('.aac') || - lowerFilePath.endsWith('.mp3') || - lowerFilePath.endsWith('.opus') || - lowerFilePath.endsWith('.ogg'); - - if (canProbeFinalMetadata) { - try { - final probed = probedFinalMetadata; - final metadata = (probed != null && probed['error'] == null) - ? probed - : await PlatformBridge.readFileMetadata(path); - if (metadata['error'] == null) { - probedFinalMetadata = metadata; - final probedBitDepth = metadata['bit_depth'] is num - ? (metadata['bit_depth'] as num).toInt() - : int.tryParse(metadata['bit_depth']?.toString() ?? ''); - final probedSampleRate = metadata['sample_rate'] is num - ? (metadata['sample_rate'] as num).toInt() - : int.tryParse(metadata['sample_rate']?.toString() ?? ''); - - if (probedBitDepth != null && probedBitDepth > 0) { - finalBitDepth = probedBitDepth; - } - if (probedSampleRate != null && probedSampleRate > 0) { - finalSampleRate = probedSampleRate; - } - final probedFormat = normalizeAudioFormatValue( - metadata['audio_codec']?.toString() ?? - metadata['format']?.toString(), - ); - if (probedFormat != null) { - finalFormat = probedFormat; - } - final probedBitrateKbps = readPositiveBitrateKbps( - metadata['bitrate'] ?? metadata['bit_rate'], - ); - if (probedBitrateKbps != null) { - finalBitrateKbps = probedBitrateKbps; - } - - final resolvedQuality = resolveDisplayQuality( - filePath: path, - fileName: finalSafFileName, - detectedFormat: finalFormat, - bitDepth: finalBitDepth, - sampleRate: finalSampleRate, - bitrateKbps: finalBitrateKbps, - storedQuality: actualQuality, - ); - if (resolvedQuality != null) { - actualQuality = resolvedQuality; - } - } - } catch (e) { - _log.d('Final audio metadata probe failed for $path: $e'); - } - } - - final isLossyOutput = - isLossyAudioFormat(finalFormat) || - lowerFilePath.endsWith('.mp3') || - lowerFilePath.endsWith('.opus') || - lowerFilePath.endsWith('.ogg'); - final historyBitDepth = isLossyOutput ? null : finalBitDepth; - final historySampleRate = isLossyOutput ? null : finalSampleRate; - final historyBitrate = finalBitrateKbps; final lyricsAvailability = await n._resolveFinalLyricsAvailability( filePath: historyFilePath, probedMetadata: probedFinalMetadata, @@ -1674,44 +1599,16 @@ class _DownloadRun { if (isNetworkDownload) { final staging = NetworkDownloadStaging.instance; - if (await staging.read(item.id) == null) { - await staging.save(item.id, { - 'localPath': path, - 'folder': item.networkDownloadFolder, - 'relativeDir': networkRelativeDir, - 'quality': actualQuality, - 'track': trackToDownload.toJson(), - 'externalLrcWritten': externalLrcWritten, - // Persist only metadata, never provider URLs/authorization tokens. - 'result': { - for (final key in [ - 'service', - 'quality', - 'actual_bit_depth', - 'actual_sample_rate', - 'bitrate', - 'actual_bitrate', - 'audio_codec', - 'format', - 'genre', - 'label', - 'copyright', - 'title', - 'artist', - 'album', - 'release_date', - 'track_number', - 'disc_number', - 'total_tracks', - 'total_discs', - 'isrc', - 'composer', - 'explicit', - ]) - if (result[key] != null) key: result[key], - }, - }); - } + await staging.prepareCompletion( + id: item.id, + localPath: path, + folder: item.networkDownloadFolder, + relativeDir: networkRelativeDir, + quality: actualQuality, + track: trackToDownload.toJson(), + externalLrcWritten: externalLrcWritten, + result: result, + ); void checkpoint() { if (n._findItemById(item.id) == null || n._isLocallyCancelled(item.id) || @@ -1771,10 +1668,10 @@ class _DownloadRun { downloadTreeUri: settings.downloadTreeUri, safRelativeDir: effectiveOutputDir, safFileName: finalSafFileName ?? safFileName, - bitDepth: historyBitDepth, - sampleRate: historySampleRate, - bitrate: historyBitrate, - format: finalFormat, + bitDepth: audio.bitDepth, + sampleRate: audio.sampleRate, + bitrate: audio.bitrate, + format: audio.format, genre: effectiveGenre, label: effectiveLabel, copyright: effectiveCopyright, diff --git a/lib/services/download_completion_audio.dart b/lib/services/download_completion_audio.dart new file mode 100644 index 00000000..d4faaac6 --- /dev/null +++ b/lib/services/download_completion_audio.dart @@ -0,0 +1,91 @@ +import 'package:spotiflac_android/utils/audio_format_utils.dart'; +import 'package:spotiflac_android/utils/int_utils.dart'; + +typedef DownloadCompletionAudio = ({ + String quality, + int? bitDepth, + int? sampleRate, + int? bitrate, + String? format, + Map? metadata, +}); + +/// Resolves the audio actually written, reusing metadata read before SAF +/// publication. Failed probes retain the backend values and cached metadata. +Future resolveDownloadCompletionAudio({ + required Map result, + required String filePath, + String? fileName, + required String quality, + Map? metadata, + required Future> Function(String) readMetadata, + required void Function(String) debug, +}) async { + var bitDepth = result['actual_bit_depth'] as int?; + var sampleRate = result['actual_sample_rate'] as int?; + var format = + normalizeAudioFormatValue( + result['audio_codec']?.toString() ?? result['format']?.toString(), + ) ?? + normalizeAudioFormatValue(audioFormatForPath(filePath)); + var bitrate = readPositiveBitrateKbps( + result['bitrate'] ?? result['actual_bitrate'], + ); + final lowerPath = filePath.toLowerCase(); + if (filePath.startsWith('content://') || + const [ + '.flac', + '.m4a', + '.mp4', + '.aac', + '.mp3', + '.opus', + '.ogg', + ].any(lowerPath.endsWith)) { + try { + final probed = metadata != null && metadata['error'] == null + ? metadata + : await readMetadata(filePath); + if (probed['error'] == null) { + metadata = probed; + bitDepth = readPositiveInt(probed['bit_depth']) ?? bitDepth; + sampleRate = readPositiveInt(probed['sample_rate']) ?? sampleRate; + format = + normalizeAudioFormatValue( + probed['audio_codec']?.toString() ?? probed['format']?.toString(), + ) ?? + format; + bitrate = + readPositiveBitrateKbps(probed['bitrate'] ?? probed['bit_rate']) ?? + bitrate; + quality = + resolveDisplayQuality( + filePath: filePath, + fileName: fileName, + detectedFormat: format, + bitDepth: bitDepth, + sampleRate: sampleRate, + bitrateKbps: bitrate, + storedQuality: quality, + ) ?? + quality; + } + } catch (error) { + debug('Final audio metadata probe failed for $filePath: $error'); + } + } + + final isLossy = + isLossyAudioFormat(format) || + lowerPath.endsWith('.mp3') || + lowerPath.endsWith('.opus') || + lowerPath.endsWith('.ogg'); + return ( + quality: quality, + bitDepth: isLossy ? null : bitDepth, + sampleRate: isLossy ? null : sampleRate, + bitrate: bitrate, + format: format, + metadata: metadata, + ); +} diff --git a/lib/services/network_download_staging.dart b/lib/services/network_download_staging.dart index 2ef93515..74411412 100644 --- a/lib/services/network_download_staging.dart +++ b/lib/services/network_download_staging.dart @@ -38,6 +38,56 @@ class NetworkDownloadStaging { return data; } + /// Keep the first finalized output and destination across upload retries. + /// Provider URLs and authorization fields never enter the durable journal. + Future prepareCompletion({ + required String id, + required String localPath, + required String folder, + required String relativeDir, + required String quality, + required Map track, + required bool externalLrcWritten, + required Map result, + }) async { + if (await read(id) != null) return; + await save(id, { + 'localPath': localPath, + 'folder': folder, + 'relativeDir': relativeDir, + 'quality': quality, + 'track': track, + 'externalLrcWritten': externalLrcWritten, + 'result': { + for (final key in const [ + 'service', + 'quality', + 'actual_bit_depth', + 'actual_sample_rate', + 'bitrate', + 'actual_bitrate', + 'audio_codec', + 'format', + 'genre', + 'label', + 'copyright', + 'title', + 'artist', + 'album', + 'release_date', + 'track_number', + 'disc_number', + 'total_tracks', + 'total_discs', + 'isrc', + 'composer', + 'explicit', + ]) + if (result[key] != null) key: result[key], + }, + }); + } + Future save(String id, Map data) async { final dir = await workDirectory(id); final original = data['localPath'] as String; diff --git a/test/download_completion_audio_test.dart b/test/download_completion_audio_test.dart new file mode 100644 index 00000000..e259338c --- /dev/null +++ b/test/download_completion_audio_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/download_completion_audio.dart'; + +void main() { + const backend = { + 'actual_bit_depth': 24, + 'actual_sample_rate': 96000, + 'audio_codec': 'flac', + 'bitrate': 900, + }; + final warnings = []; + setUp(warnings.clear); + + Future resolve({ + String path = '/music/track.flac', + Map result = backend, + Map? cached, + required Future> Function(String) probe, + }) => resolveDownloadCompletionAudio( + result: result, + filePath: path, + fileName: 'track.m4a', + quality: 'requested quality', + metadata: cached, + readMetadata: probe, + debug: warnings.add, + ); + + test('SAF completion reuses measured audio and tag scan metadata', () async { + final metadata = { + 'bit_depth': '16', + 'sample_rate': 44100.0, + 'audio_codec': 'alac', + 'bit_rate': '256000', + 'hasLyrics': true, + 'replaygain_track_gain': '-2.1 dB', + }; + final audio = await resolve( + path: 'content://library/42', + cached: metadata, + probe: (_) async => fail('Cached SAF audio must not be copied again'), + ); + expect(audio.metadata, same(metadata)); + expect(audio.bitDepth, 16); + expect(audio.sampleRate, 44100); + expect(audio.bitrate, 256); + expect(audio.format, 'alac'); + expect(audio.quality, '16-bit/44.1kHz'); + expect(warnings, isEmpty); + }); + + test( + 'failed cache is replaced and lossy specs cannot enter history', + () async { + var reads = 0; + final metadata = { + 'audio_codec': 'opus', + 'bit_depth': 32, + 'sample_rate': 48000, + 'bitrate': 192000, + 'lyrics': '', + }; + final audio = await resolve( + cached: {'error': 'temporary file missing'}, + probe: (path) async { + expect(path, '/music/track.flac'); + reads++; + return metadata; + }, + ); + expect(reads, 1); + expect(audio.metadata, same(metadata)); + expect(audio.bitDepth, isNull); + expect(audio.sampleRate, isNull); + expect(audio.bitrate, 192); + expect(audio.format, 'opus'); + expect(audio.quality, 'OPUS 192kbps'); + }, + ); + + test('invalid measured values retain usable backend specs', () async { + final audio = await resolve( + probe: (_) async => { + 'bit_depth': 0, + 'sample_rate': '-1', + 'bitrate': '0', + 'audio_codec': 'unknown', + }, + ); + expect(audio.bitDepth, 24); + expect(audio.sampleRate, 96000); + expect(audio.bitrate, 900); + expect(audio.format, 'flac'); + expect(audio.quality, '24-bit/96kHz'); + }); + + for (final throws in [false, true]) { + test('probe failure preserves quality and prior cache ($throws)', () async { + final cached = {'error': 'old probe failed'}; + final audio = await resolve( + cached: cached, + probe: (_) async { + if (throws) throw StateError('metadata unavailable'); + return {'error': 'metadata unavailable'}; + }, + ); + expect(audio.metadata, same(cached)); + expect(audio.bitDepth, 24); + expect(audio.sampleRate, 96000); + expect(audio.bitrate, 900); + expect(audio.quality, 'requested quality'); + expect(warnings, throws ? hasLength(1) : isEmpty); + }); + } + + test('unsupported local output skips final audio probing', () async { + final audio = await resolve( + path: '/music/track.wav', + result: {}, + probe: (_) async => fail('Unsupported path must not trigger a probe'), + ); + expect(audio.format, 'wav'); + expect(audio.metadata, isNull); + expect(audio.quality, 'requested quality'); + }); + + test('lossy filename suppresses stale lossless backend specs', () async { + final audio = await resolve( + path: '/music/track.MP3', + probe: (_) async => {'error': 'metadata unavailable'}, + ); + expect(audio.bitDepth, isNull); + expect(audio.sampleRate, isNull); + expect(audio.bitrate, 900); + }); +} diff --git a/test/network_upload_test.dart b/test/network_upload_test.dart index bfe75c30..cc4160a9 100644 --- a/test/network_upload_test.dart +++ b/test/network_upload_test.dart @@ -155,6 +155,73 @@ void main() { progress: (_, _) {}, ); + test('completion journal owns output and filters provider secrets', () async { + final output = await File('${temp.path}/final.flac').writeAsBytes([1, 2]); + await File('${temp.path}/final.lrc').writeAsString('Lyrics'); + await staging.prepareCompletion( + id: 'item', + localPath: output.path, + folder: folder, + relativeDir: 'Artist/Album/', + quality: '24-bit/96kHz', + track: {'id': 'track'}, + externalLrcWritten: true, + result: { + 'service': 'provider', + 'actual_bit_depth': 24, + 'actual_sample_rate': 96000, + 'audio_codec': 'flac', + 'title': 'Track', + 'genre': null, + 'download_url': 'https://example.test/audio?token=secret', + 'authorization': 'Bearer secret', + 'decryption_key': 'secret', + 'extension_context': {'token': 'secret'}, + }, + ); + await output.delete(); + final journal = (await staging.read('item'))!; + expect(journal['localPath'], isNot(output.path)); + expect(journal['folder'], folder); + expect(journal['relativeDir'], 'Artist/Album/'); + expect(journal['quality'], '24-bit/96kHz'); + expect(journal['track'], {'id': 'track'}); + expect(journal['externalLrcWritten'], true); + expect(journal['result'], { + 'service': 'provider', + 'actual_bit_depth': 24, + 'actual_sample_rate': 96000, + 'audio_codec': 'flac', + 'title': 'Track', + }); + expect(jsonEncode(journal), isNot(contains('secret'))); + await publish(); + expect(dav.files['/music/Artist/Album/final.flac'], [1, 2]); + expect(utf8.decode(dav.files['/music/Artist/Album/final.lrc']!), 'Lyrics'); + }); + + test('retry keeps the first journal including its reserved target', () async { + await prepare(dir: 'Original/'); + dav.rejectWrites = true; + await expectLater(publish(), throwsA(isA())); + final original = (await staging.read('item'))!; + expect(original['target'], isNotNull); + await staging.prepareCompletion( + id: 'item', + localPath: '${temp.path}/missing-new-output.flac', + folder: 'network://changed/', + relativeDir: 'Changed/', + quality: 'new quality', + track: {'id': 'changed'}, + externalLrcWritten: false, + result: {'service': 'changed'}, + ); + expect(await staging.read('item'), original); + dav.rejectWrites = false; + await publish(); + expect(dav.files.keys, ['/music/Original/Song #1.flac']); + }); + test( 'write probe creates and removes its own file; permission errors remain errors', () async {