refactor(downloads): extract completion audio and upload staging

This commit is contained in:
zarzet committed 2026-10-06 13:09:54 +07:00
1 parent 7b9405aaf6
commit 2df72112e4
6 files changed
+369 -127

No files matched your search

@@ -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';
@@ -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,
@@ -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<String, dynamic>? metadata,
});
/// Resolves the audio actually written, reusing metadata read before SAF
/// publication. Failed probes retain the backend values and cached metadata.
Future<DownloadCompletionAudio> resolveDownloadCompletionAudio({
required Map<String, dynamic> result,
required String filePath,
String? fileName,
required String quality,
Map<String, dynamic>? metadata,
required Future<Map<String, dynamic>> 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,
);
}
@@ -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<void> prepareCompletion({
required String id,
required String localPath,
required String folder,
required String relativeDir,
required String quality,
required Map<String, dynamic> track,
required bool externalLrcWritten,
required Map<String, dynamic> 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<void> save(String id, Map<String, dynamic> data) async {
final dir = await workDirectory(id);
final original = data['localPath'] as String;
+136
View File
@@ -0,0 +1,136 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/download_completion_audio.dart';
void main() {
const backend = <String, dynamic>{
'actual_bit_depth': 24,
'actual_sample_rate': 96000,
'audio_codec': 'flac',
'bitrate': 900,
};
final warnings = <String>[];
setUp(warnings.clear);
Future<DownloadCompletionAudio> resolve({
String path = '/music/track.flac',
Map<String, dynamic> result = backend,
Map<String, dynamic>? cached,
required Future<Map<String, dynamic>> 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 = <String, dynamic>{
'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 = <String, dynamic>{
'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 = <String, dynamic>{'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);
});
}
+67
View File
@@ -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<HttpException>()));
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 {