mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-02 16:20:57 +02:00
fix(download): preserve finalization metadata and library labels
This commit is contained in:
@@ -18,8 +18,6 @@ import com.zarz.spotiflac.SafDownloadHandler.normalizeExt
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.applyQualityVariantFilenameLabel
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.displayAudioQuality
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension
|
||||
import gobackend.Gobackend
|
||||
@@ -808,7 +806,11 @@ object NativeDownloadFinalizer {
|
||||
try {
|
||||
val codec = probePrimaryAudioCodec(localInput, shouldCancel)
|
||||
val isAlreadyNativeFlac = codec == "flac" && isNativeFlacFile(localInput)
|
||||
if (!isLosslessAudioCodec(codec)) {
|
||||
if (!NativeFinalizationPolicy.shouldAttemptLosslessContainerConversion(
|
||||
forceContainerConversion,
|
||||
codec,
|
||||
)
|
||||
) {
|
||||
Log.d(TAG, "Preserving native container; audio codec is ${codec.ifBlank { "unknown" }}")
|
||||
// The preserved stream is not FLAC but still carries the
|
||||
// requested .flac name. Rename to the real container so the
|
||||
|
||||
@@ -158,6 +158,22 @@ internal object NativeFinalizationPolicy {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether a stream should enter the lossless container conversion
|
||||
* path. A missing or generic M4A/MP4 codec is inconclusive rather than
|
||||
* proof of lossy audio; an extension capability may explicitly request
|
||||
* that FFmpeg attempt the conversion. Known lossy codecs remain native.
|
||||
*/
|
||||
fun shouldAttemptLosslessContainerConversion(
|
||||
forceConversion: Boolean,
|
||||
probedCodec: String?,
|
||||
): Boolean {
|
||||
if (isLosslessAudioCodec(probedCodec)) return true
|
||||
val normalized = normalizeAudioCodec(probedCodec)
|
||||
val inconclusive = normalized == null || normalized == "m4a"
|
||||
return forceConversion && inconclusive
|
||||
}
|
||||
|
||||
fun displayAudioQuality(
|
||||
filePath: String,
|
||||
fileName: String,
|
||||
|
||||
@@ -101,6 +101,40 @@ class NativeFinalizationPolicyTest {
|
||||
assertFalse(NativeFinalizationPolicy.isLosslessAudioCodec("aac"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forcedContainerConversionAttemptsInconclusiveCodecButPreservesLossy() {
|
||||
assertTrue(
|
||||
NativeFinalizationPolicy.shouldAttemptLosslessContainerConversion(
|
||||
forceConversion = true,
|
||||
probedCodec = null,
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
NativeFinalizationPolicy.shouldAttemptLosslessContainerConversion(
|
||||
forceConversion = true,
|
||||
probedCodec = "m4a",
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
NativeFinalizationPolicy.shouldAttemptLosslessContainerConversion(
|
||||
forceConversion = false,
|
||||
probedCodec = "flac",
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeFinalizationPolicy.shouldAttemptLosslessContainerConversion(
|
||||
forceConversion = true,
|
||||
probedCodec = "aac",
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeFinalizationPolicy.shouldAttemptLosslessContainerConversion(
|
||||
forceConversion = false,
|
||||
probedCodec = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isoBmffAudioUsesM4aExceptForAc4Passthrough() {
|
||||
assertEquals(
|
||||
|
||||
@@ -1114,15 +1114,22 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
|
||||
result['audio_codec']?.toString() ??
|
||||
result['actual_audio_codec']?.toString(),
|
||||
);
|
||||
if (isLossyAudioFormat(resultAudioFormat)) {
|
||||
final requiresContainerConversion =
|
||||
result['requires_container_conversion'] == true ||
|
||||
result['requiresContainerConversion'] == true ||
|
||||
_shouldRequestContainerConversion(
|
||||
context.item.service,
|
||||
context.outputExt,
|
||||
);
|
||||
// M4A/MP4 identifies a container, not necessarily a lossy codec. When an
|
||||
// extension explicitly requests conversion, probe the stream below rather
|
||||
// than treating the container label as proof that its audio is lossy.
|
||||
if (!requiresContainerConversion && isLossyAudioFormat(resultAudioFormat)) {
|
||||
_log.d(
|
||||
'Native-worker output is $resultAudioFormat; preserving native container.',
|
||||
);
|
||||
return filePath;
|
||||
}
|
||||
final requiresContainerConversion =
|
||||
result['requires_container_conversion'] == true ||
|
||||
result['requiresContainerConversion'] == true;
|
||||
final resultOutputExt = _downloadResultOutputExt(
|
||||
result,
|
||||
filePath: filePath,
|
||||
@@ -1180,6 +1187,13 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
void markFinalOutputAsFlac() {
|
||||
result['audio_codec'] = 'flac';
|
||||
result['format'] = 'flac';
|
||||
result['actual_extension'] = '.flac';
|
||||
result['output_extension'] = '.flac';
|
||||
}
|
||||
|
||||
if (context.storageMode == 'saf' && isContentUri(filePath)) {
|
||||
final treeUri = context.downloadTreeUri;
|
||||
if (treeUri == null || treeUri.isEmpty) {
|
||||
@@ -1195,7 +1209,10 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
|
||||
final codec = await FFmpegService.probePrimaryAudioCodec(tempPath);
|
||||
final isAlreadyNativeFlac =
|
||||
codec == 'flac' && await FFmpegService.isNativeFlacFile(tempPath);
|
||||
if (!FFmpegService.isLosslessAudioCodec(codec)) {
|
||||
final shouldAttemptConversion =
|
||||
FFmpegService.isLosslessAudioCodec(codec) ||
|
||||
(requiresContainerConversion && isInconclusiveAudioCodec(codec));
|
||||
if (!shouldAttemptConversion) {
|
||||
_log.d(
|
||||
'Preserving native container; audio codec is ${codec ?? 'unknown'}, '
|
||||
'no FLAC container conversion needed.',
|
||||
@@ -1241,13 +1258,17 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
|
||||
return null;
|
||||
}
|
||||
result['file_name'] = producedFileName;
|
||||
markFinalOutputAsFlac();
|
||||
return newUri;
|
||||
}
|
||||
|
||||
final codec = await FFmpegService.probePrimaryAudioCodec(filePath);
|
||||
final isAlreadyNativeFlac =
|
||||
codec == 'flac' && await FFmpegService.isNativeFlacFile(filePath);
|
||||
if (!FFmpegService.isLosslessAudioCodec(codec)) {
|
||||
final shouldAttemptConversion =
|
||||
FFmpegService.isLosslessAudioCodec(codec) ||
|
||||
(requiresContainerConversion && isInconclusiveAudioCodec(codec));
|
||||
if (!shouldAttemptConversion) {
|
||||
_log.d(
|
||||
'Preserving native container; audio codec is ${codec ?? 'unknown'}, '
|
||||
'no FLAC container conversion needed.',
|
||||
@@ -1265,6 +1286,7 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
|
||||
flacPath = targetPath;
|
||||
}
|
||||
await embedFlacMetadata(flacPath);
|
||||
markFinalOutputAsFlac();
|
||||
return flacPath;
|
||||
}
|
||||
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
|
||||
@@ -1272,6 +1294,7 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
|
||||
return null;
|
||||
}
|
||||
await embedFlacMetadata(flacPath);
|
||||
markFinalOutputAsFlac();
|
||||
return flacPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -691,11 +691,13 @@ class _DownloadRun {
|
||||
result['audio_codec']?.toString() ??
|
||||
result['actual_audio_codec']?.toString(),
|
||||
);
|
||||
final resultIsLossyAudio = isLossyAudioFormat(resultAudioFormat);
|
||||
final resultIsKnownLossyAudio =
|
||||
isLossyAudioFormat(resultAudioFormat) &&
|
||||
!isInconclusiveAudioCodec(resultAudioFormat);
|
||||
final requiresContainerConversion =
|
||||
result['requires_container_conversion'] == true ||
|
||||
result['requiresContainerConversion'] == true ||
|
||||
(!resultIsLossyAudio &&
|
||||
(!resultIsKnownLossyAudio &&
|
||||
n._shouldRequestContainerConversion(actualService, safOutputExt));
|
||||
final preferredOutputExt = n._extensionPreferredOutputExt(actualService);
|
||||
shouldPreserveNativeM4a =
|
||||
@@ -987,6 +989,14 @@ class _DownloadRun {
|
||||
}
|
||||
}
|
||||
|
||||
void _markFinalOutputAsFlac() {
|
||||
result['audio_codec'] = 'flac';
|
||||
result['format'] = 'flac';
|
||||
result['actual_extension'] = '.flac';
|
||||
result['output_extension'] = '.flac';
|
||||
resultOutputExt = '.flac';
|
||||
}
|
||||
|
||||
Future<bool> _publishDeferredSafOutputOnce() async {
|
||||
final localPath = filePath;
|
||||
if (localPath == null || isContentUri(localPath)) return true;
|
||||
@@ -1252,7 +1262,10 @@ class _DownloadRun {
|
||||
final codec = await FFmpegService.probePrimaryAudioCodec(tempPath);
|
||||
final isAlreadyNativeFlac =
|
||||
codec == 'flac' && await FFmpegService.isNativeFlacFile(tempPath);
|
||||
if (!FFmpegService.isLosslessAudioCodec(codec)) {
|
||||
final shouldAttemptConversion =
|
||||
FFmpegService.isLosslessAudioCodec(codec) ||
|
||||
isInconclusiveAudioCodec(codec);
|
||||
if (!shouldAttemptConversion) {
|
||||
_log.d(
|
||||
'Preserving native container; audio codec is ${codec ?? 'unknown'}, '
|
||||
'no FLAC container conversion needed.',
|
||||
@@ -1309,6 +1322,7 @@ class _DownloadRun {
|
||||
if (newUri != null) {
|
||||
filePath = newUri;
|
||||
finalSafFileName = producedFileName;
|
||||
_markFinalOutputAsFlac();
|
||||
} else if (branch == 'nativeFlac') {
|
||||
_log.w('Failed to write native FLAC to SAF');
|
||||
} else if (branch == 'convert') {
|
||||
@@ -1437,7 +1451,10 @@ class _DownloadRun {
|
||||
final isAlreadyNativeFlac =
|
||||
codec == 'flac' &&
|
||||
await FFmpegService.isNativeFlacFile(currentFilePath);
|
||||
if (!FFmpegService.isLosslessAudioCodec(codec)) {
|
||||
final shouldAttemptConversion =
|
||||
FFmpegService.isLosslessAudioCodec(codec) ||
|
||||
isInconclusiveAudioCodec(codec);
|
||||
if (!shouldAttemptConversion) {
|
||||
_log.d(
|
||||
'Preserving native container; audio codec is ${codec ?? 'unknown'}, '
|
||||
'no FLAC container conversion needed.',
|
||||
@@ -1462,6 +1479,7 @@ class _DownloadRun {
|
||||
}
|
||||
|
||||
await _embedFinalMetadata(flacPath, format: 'flac');
|
||||
_markFinalOutputAsFlac();
|
||||
} else {
|
||||
n.updateItemStatus(
|
||||
item.id,
|
||||
@@ -1474,6 +1492,7 @@ class _DownloadRun {
|
||||
|
||||
if (flacPath != null) {
|
||||
filePath = flacPath;
|
||||
_markFinalOutputAsFlac();
|
||||
_log.d('Converted to FLAC: $flacPath');
|
||||
|
||||
_log.d('Embedding metadata and cover to converted FLAC...');
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
||||
import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart';
|
||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
@@ -31,6 +33,37 @@ bool shouldRetainQueueLibraryPageSnapshot({
|
||||
required bool activeDownloadFallbackAvailable,
|
||||
}) => currentIsEmpty && cachedHasContent && activeDownloadFallbackAvailable;
|
||||
|
||||
/// Keeps a completed queue card visible until the matching Library row has
|
||||
/// actually landed. A completed item with an in-memory History row must not
|
||||
/// expire merely because the rest of its album batch was cancelled: the
|
||||
/// paged Library query can still be one refresh behind that persisted row.
|
||||
bool shouldRetainCompletionBridge({
|
||||
required bool isRequeued,
|
||||
required bool hasActiveDownloads,
|
||||
required bool libraryRowLanded,
|
||||
required bool hasHistoryItem,
|
||||
required bool expired,
|
||||
}) {
|
||||
if (isRequeued || libraryRowLanded) return false;
|
||||
if (hasActiveDownloads || hasHistoryItem) return true;
|
||||
return !expired;
|
||||
}
|
||||
|
||||
/// Builds a mode-aware label while a just-completed item is waiting for its
|
||||
/// History row. This avoids pinning the card to the track's old quality text
|
||||
/// when the user changes the Library label setting during an album download.
|
||||
String? buildCompletionBridgeFallbackQualityLabel({
|
||||
required String mode,
|
||||
required String? completedItemFilePath,
|
||||
required String? storedQuality,
|
||||
}) {
|
||||
return buildLibraryAudioQualityLabel(
|
||||
mode: mode,
|
||||
format: audioFormatForPath(completedItemFilePath),
|
||||
storedQuality: storedQuality,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns distinct final-path candidates for a just-completed download.
|
||||
/// History is authoritative after conversion/SAF publication, while the
|
||||
/// completed queue item remains a safe fallback if the matched history row is
|
||||
|
||||
@@ -121,7 +121,11 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
|
||||
: UnifiedLibraryItem.fromDownloadHistory(historyItem);
|
||||
final quality =
|
||||
unifiedItem?.qualityForMode(_libraryQualityLabelMode) ??
|
||||
track.audioQuality;
|
||||
buildCompletionBridgeFallbackQualityLabel(
|
||||
mode: _libraryQualityLabelMode,
|
||||
completedItemFilePath: item.filePath,
|
||||
storedQuality: track.audioQuality,
|
||||
);
|
||||
final playablePath = resolveCompletionBridgePlayablePath(
|
||||
historyFilePath: historyItem?.filePath,
|
||||
completedItemFilePath: item.filePath,
|
||||
@@ -258,7 +262,11 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
|
||||
: UnifiedLibraryItem.fromDownloadHistory(historyItem);
|
||||
final quality =
|
||||
unifiedItem?.qualityForMode(_libraryQualityLabelMode) ??
|
||||
track.audioQuality;
|
||||
buildCompletionBridgeFallbackQualityLabel(
|
||||
mode: _libraryQualityLabelMode,
|
||||
completedItemFilePath: item.filePath,
|
||||
storedQuality: track.audioQuality,
|
||||
);
|
||||
final playablePath = resolveCompletionBridgePlayablePath(
|
||||
historyFilePath: historyItem?.filePath,
|
||||
completedItemFilePath: item.filePath,
|
||||
|
||||
@@ -81,23 +81,26 @@ extension _QueueTabFilterWidgets on _QueueTabState {
|
||||
final pending = <String>[];
|
||||
final hasActiveDownloads = activeDownloadIds.isNotEmpty;
|
||||
_completionBridge.forEach((id, _) {
|
||||
final historyId = bridgeHistoryById[id]?.id ?? id;
|
||||
final historyItem = bridgeHistoryById[id];
|
||||
final historyId = historyItem?.id ?? id;
|
||||
final landed = libIdSet.contains('dl_$historyId');
|
||||
final addedAt = _completionBridgeAt[id];
|
||||
final expired =
|
||||
addedAt == null || now.difference(addedAt).inSeconds >= 6;
|
||||
if (activeDownloadIds.contains(id)) {
|
||||
// Re-queued (retry): the live row takes over from the bridge.
|
||||
stale.add(id);
|
||||
} else if (hasActiveDownloads) {
|
||||
// Keep just-completed tracks pinned in the lead zone while the
|
||||
// rest of the batch is still downloading, so they don't jump
|
||||
// below the remaining queue the moment they finish.
|
||||
if (shouldRetainCompletionBridge(
|
||||
isRequeued: activeDownloadIds.contains(id),
|
||||
hasActiveDownloads: hasActiveDownloads,
|
||||
libraryRowLanded: landed,
|
||||
hasHistoryItem: historyItem != null,
|
||||
expired: expired,
|
||||
)) {
|
||||
// Keep completed tracks pinned while their batch is active or their
|
||||
// persisted History row is still waiting for the paged Library.
|
||||
pending.add(id);
|
||||
} else if (landed || expired) {
|
||||
stale.add(id);
|
||||
} else {
|
||||
pending.add(id);
|
||||
// Re-queued items are represented by the live row; landed items by
|
||||
// the normal Library row; unpersisted bridges retain a short grace.
|
||||
stale.add(id);
|
||||
}
|
||||
});
|
||||
bridgeIds = pending;
|
||||
|
||||
@@ -69,6 +69,10 @@ String? audioFormatForPath(String? filePath, {String? fileName}) {
|
||||
final candidates = <String>[?filePath, ?fileName];
|
||||
for (final candidate in candidates) {
|
||||
final lower = candidate.trim().toLowerCase();
|
||||
if (lower.endsWith('.flac')) return 'FLAC';
|
||||
if (lower.endsWith('.alac')) return 'ALAC';
|
||||
if (lower.endsWith('.wav')) return 'WAV';
|
||||
if (lower.endsWith('.aiff') || lower.endsWith('.aif')) return 'AIFF';
|
||||
if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return 'OPUS';
|
||||
if (lower.endsWith('.mp3')) return 'MP3';
|
||||
if (lower.endsWith('.aac')) return 'AAC';
|
||||
@@ -153,6 +157,17 @@ bool isLossyAudioFormat(String? value) {
|
||||
}.contains(normalizeAudioFormatValue(value));
|
||||
}
|
||||
|
||||
/// Whether a reported codec value only identifies the ISO-BMFF container.
|
||||
/// `m4a`/`mp4` cannot tell callers whether the audio stream is AAC, ALAC,
|
||||
/// FLAC, or another codec, so an explicit conversion request must probe or
|
||||
/// attempt conversion instead of classifying it as known-lossy audio.
|
||||
bool isInconclusiveAudioCodec(String? value) {
|
||||
final raw = normalizeOptionalString(value);
|
||||
if (raw == null) return true;
|
||||
final normalized = raw.toLowerCase().replaceAll('-', '_');
|
||||
return normalized == 'm4a' || normalized == 'mp4';
|
||||
}
|
||||
|
||||
/// Returns a provider-independent quality label suitable for a filename.
|
||||
///
|
||||
/// Requested labels such as LOSSLESS and HI_RES are intentionally ignored:
|
||||
|
||||
@@ -1305,6 +1305,14 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('treats generic M4A container reports as inconclusive codecs', () {
|
||||
expect(isInconclusiveAudioCodec(null), isTrue);
|
||||
expect(isInconclusiveAudioCodec('m4a'), isTrue);
|
||||
expect(isInconclusiveAudioCodec('MP4'), isTrue);
|
||||
expect(isInconclusiveAudioCodec('flac'), isFalse);
|
||||
expect(isInconclusiveAudioCodec('aac'), isFalse);
|
||||
});
|
||||
|
||||
test('uses m4a for ISO-BMFF audio except AC-4 passthrough', () {
|
||||
expect(isoBmffAudioExtensionForCodec('opus'), '.m4a');
|
||||
expect(isoBmffAudioExtensionForCodec('ec-3'), '.m4a');
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/screens/queue_library_refresh_policy.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
|
||||
@@ -108,6 +109,82 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'completion bridge survives partial album cancellation until landing',
|
||||
() {
|
||||
expect(
|
||||
shouldRetainCompletionBridge(
|
||||
isRequeued: false,
|
||||
hasActiveDownloads: false,
|
||||
libraryRowLanded: false,
|
||||
hasHistoryItem: true,
|
||||
expired: true,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
shouldRetainCompletionBridge(
|
||||
isRequeued: false,
|
||||
hasActiveDownloads: false,
|
||||
libraryRowLanded: true,
|
||||
hasHistoryItem: true,
|
||||
expired: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('unpersisted completion bridge retains only its short grace period', () {
|
||||
expect(
|
||||
shouldRetainCompletionBridge(
|
||||
isRequeued: false,
|
||||
hasActiveDownloads: false,
|
||||
libraryRowLanded: false,
|
||||
hasHistoryItem: false,
|
||||
expired: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
shouldRetainCompletionBridge(
|
||||
isRequeued: false,
|
||||
hasActiveDownloads: false,
|
||||
libraryRowLanded: false,
|
||||
hasHistoryItem: false,
|
||||
expired: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('completion bridge fallback follows the current label mode', () {
|
||||
expect(
|
||||
buildCompletionBridgeFallbackQualityLabel(
|
||||
mode: AppSettings.libraryQualityLabelFileFormat,
|
||||
completedItemFilePath: '/music/completed.flac',
|
||||
storedQuality: '24-bit/96kHz',
|
||||
),
|
||||
'FLAC',
|
||||
);
|
||||
expect(
|
||||
buildCompletionBridgeFallbackQualityLabel(
|
||||
mode: AppSettings.libraryQualityLabelBitDepthOnly,
|
||||
completedItemFilePath: '/music/completed.flac',
|
||||
storedQuality: '24-bit/96kHz',
|
||||
),
|
||||
'24-bit',
|
||||
);
|
||||
expect(
|
||||
buildCompletionBridgeFallbackQualityLabel(
|
||||
mode: AppSettings.libraryQualityLabelBitrate,
|
||||
completedItemFilePath: '/music/completed.flac',
|
||||
storedQuality: '24-bit/96kHz',
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'completion probe falls back from stale history to completed path',
|
||||
() async {
|
||||
|
||||
Reference in New Issue
Block a user