mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 06:28:27 +02:00
refactor(downloads): extract completion audio and upload staging
This commit is contained in:
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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user