mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-26 12:52:40 +02:00
feat(download): add automatic lossy conversion
This commit is contained in:
@@ -216,6 +216,22 @@ object NativeDownloadFinalizer {
|
||||
checkCancelled(shouldCancel)
|
||||
runPostProcessing(context, effectiveInput, state, shouldCancel)
|
||||
checkCancelled(shouldCancel)
|
||||
try {
|
||||
finalizeAutoConversion(
|
||||
context,
|
||||
effectiveInput,
|
||||
state,
|
||||
shouldCancel,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// Auto-conversion is best effort: a completed source file
|
||||
// must remain usable if FFmpeg or metadata embedding fails.
|
||||
Log.w(TAG, "Automatic conversion failed; keeping source: ${e.message}")
|
||||
result.put("auto_conversion_warning", e.message ?: "conversion failed")
|
||||
}
|
||||
checkCancelled(shouldCancel)
|
||||
val replayGain = writeReplayGain(context, effectiveInput, state, shouldCancel)
|
||||
if (replayGain != null) result.put("replaygain", replayGain)
|
||||
checkCancelled(shouldCancel)
|
||||
@@ -547,15 +563,22 @@ object NativeDownloadFinalizer {
|
||||
if (requestQuality(input) != "HIGH") return
|
||||
if (!looksLikeM4a(state.filePath, state.fileName)) return
|
||||
|
||||
val autoTarget = NativeFinalizationPolicy.autoConversionTarget(
|
||||
enabled = input.request.optBoolean("auto_convert_downloads", false),
|
||||
format = input.request.optString("auto_convert_format", ""),
|
||||
bitrate = input.request.optString("auto_convert_bitrate", ""),
|
||||
)
|
||||
val tidalHighFormat = input.request.optString("tidal_high_format", "").ifBlank { "mp3_320" }
|
||||
val format = when {
|
||||
val format = autoTarget?.codec ?: when {
|
||||
tidalHighFormat.startsWith("opus") -> "opus"
|
||||
tidalHighFormat.startsWith("aac") || tidalHighFormat.startsWith("m4a") -> "aac"
|
||||
else -> "mp3"
|
||||
}
|
||||
val metadataFormat = if (format == "aac") "m4a" else format
|
||||
val displayFormat = if (format == "aac") "AAC" else format.uppercase(Locale.ROOT)
|
||||
val bitrate = if (tidalHighFormat.contains("_")) {
|
||||
val bitrate = if (autoTarget != null) {
|
||||
"${autoTarget.bitrateKbps}k"
|
||||
} else if (tidalHighFormat.contains("_")) {
|
||||
"${tidalHighFormat.substringAfterLast("_")}k"
|
||||
} else {
|
||||
if (format == "opus") "128k" else "320k"
|
||||
@@ -598,6 +621,111 @@ object NativeDownloadFinalizer {
|
||||
state.quality = "$displayFormat ${bitrate.removeSuffix("k")}kbps"
|
||||
state.bitDepth = null
|
||||
state.sampleRate = null
|
||||
state.bitrateKbps = bitrate.removeSuffix("k").toIntOrNull()
|
||||
state.audioCodec = format
|
||||
}
|
||||
|
||||
private fun finalizeAutoConversion(
|
||||
context: Context,
|
||||
input: FinalizeInput,
|
||||
state: FinalizeState,
|
||||
shouldCancel: () -> Boolean,
|
||||
) {
|
||||
val target = NativeFinalizationPolicy.autoConversionTarget(
|
||||
enabled = input.request.optBoolean("auto_convert_downloads", false),
|
||||
format = input.request.optString("auto_convert_format", ""),
|
||||
bitrate = input.request.optString("auto_convert_bitrate", ""),
|
||||
) ?: return
|
||||
if (
|
||||
NativeFinalizationPolicy.autoConversionAlreadySatisfied(
|
||||
target,
|
||||
state.audioCodec,
|
||||
state.bitrateKbps,
|
||||
)
|
||||
) return
|
||||
|
||||
val localInput = materializeForFFmpeg(context, input, state)
|
||||
val sourceWasSaf = state.filePath.startsWith("content://")
|
||||
val sameLocalExtension = !sourceWasSaf &&
|
||||
normalizeExt(File(localInput).extension) == target.extension
|
||||
val output = if (sameLocalExtension) {
|
||||
buildOutputPath(localInput, target.extension)
|
||||
} else {
|
||||
uniqueAutoConversionOutputPath(localInput, target.extension)
|
||||
}
|
||||
val stagedOutput = stagedConversionPath(output)
|
||||
val bitrate = "${target.bitrateKbps}k"
|
||||
var adoptedOutput = false
|
||||
try {
|
||||
val command = when (target.codec) {
|
||||
"opus" -> "-v error -hide_banner -i ${q(localInput)} -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a ${q(stagedOutput)} -y"
|
||||
"aac" -> "-v error -hide_banner -i ${q(localInput)} -codec:a aac -b:a $bitrate -map 0:a -f mp4 ${q(stagedOutput)} -y"
|
||||
else -> "-v error -hide_banner -i ${q(localInput)} -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 ${q(stagedOutput)} -y"
|
||||
}
|
||||
val conversion = runFFmpeg(command, shouldCancel)
|
||||
if (!conversion.first || !File(stagedOutput).exists()) {
|
||||
throw IllegalStateException("automatic conversion failed: ${conversion.second}")
|
||||
}
|
||||
if (!promoteStagedConversion(stagedOutput, output)) {
|
||||
throw IllegalStateException("failed to promote automatic conversion output")
|
||||
}
|
||||
|
||||
val metadataFormat = if (target.codec == "aac") "m4a" else target.codec
|
||||
embedBasicMetadata(context, output, input, metadataFormat)
|
||||
|
||||
if (sameLocalExtension) {
|
||||
replaceSameFormatLocalOutput(localInput, output)
|
||||
state.filePath = localInput
|
||||
state.fileName = File(localInput).name
|
||||
} else {
|
||||
replaceStatePath(context, input, state, output, deleteOld = true)
|
||||
}
|
||||
adoptedOutput = true
|
||||
} finally {
|
||||
if (!adoptedOutput) {
|
||||
File(stagedOutput).delete()
|
||||
File(output).delete()
|
||||
}
|
||||
if (sourceWasSaf) File(localInput).delete()
|
||||
}
|
||||
|
||||
state.quality = "${if (target.codec == "aac") "AAC" else target.codec.uppercase(Locale.ROOT)} ${target.bitrateKbps}kbps"
|
||||
state.bitDepth = null
|
||||
state.sampleRate = null
|
||||
state.bitrateKbps = target.bitrateKbps
|
||||
state.audioCodec = target.codec
|
||||
}
|
||||
|
||||
private fun replaceSameFormatLocalOutput(inputPath: String, convertedPath: String) {
|
||||
val source = File(inputPath)
|
||||
val converted = File(convertedPath)
|
||||
val backup = File("$inputPath.spotiflac-backup-${System.nanoTime()}")
|
||||
if (!source.renameTo(backup)) {
|
||||
throw IllegalStateException("failed to stage original for same-format conversion")
|
||||
}
|
||||
try {
|
||||
if (!converted.renameTo(source)) {
|
||||
throw IllegalStateException("failed to publish same-format conversion")
|
||||
}
|
||||
backup.delete()
|
||||
} catch (e: Exception) {
|
||||
if (!source.exists()) backup.renameTo(source)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun uniqueAutoConversionOutputPath(inputPath: String, extension: String): String {
|
||||
val source = File(inputPath)
|
||||
val requested = File(source.parentFile, "${source.nameWithoutExtension}$extension")
|
||||
if (!requested.exists()) return requested.absolutePath
|
||||
for (index in 2..Int.MAX_VALUE) {
|
||||
val candidate = File(
|
||||
source.parentFile,
|
||||
"${source.nameWithoutExtension} ($index)$extension",
|
||||
)
|
||||
if (!candidate.exists()) return candidate.absolutePath
|
||||
}
|
||||
throw IllegalStateException("could not allocate automatic conversion output")
|
||||
}
|
||||
|
||||
private fun finalizeContainerConversion(
|
||||
@@ -754,7 +882,14 @@ object NativeDownloadFinalizer {
|
||||
} else {
|
||||
ext
|
||||
}
|
||||
if (fileExt != ".flac" && fileExt != ".m4a" && fileExt != ".mp4") return null
|
||||
if (
|
||||
fileExt != ".flac" &&
|
||||
fileExt != ".m4a" &&
|
||||
fileExt != ".mp4" &&
|
||||
fileExt != ".mp3" &&
|
||||
fileExt != ".opus" &&
|
||||
fileExt != ".ogg"
|
||||
) return null
|
||||
|
||||
val scanPath = if (state.filePath.startsWith("content://")) {
|
||||
SafDownloadHandler.copyContentUriToTemp(context, state.filePath)
|
||||
|
||||
@@ -11,6 +11,47 @@ import kotlin.math.roundToInt
|
||||
* finalizer's I/O-heavy orchestration.
|
||||
*/
|
||||
internal object NativeFinalizationPolicy {
|
||||
data class AutoConversionTarget(
|
||||
val codec: String,
|
||||
val extension: String,
|
||||
val bitrateKbps: Int,
|
||||
)
|
||||
|
||||
fun autoConversionTarget(
|
||||
enabled: Boolean,
|
||||
format: String?,
|
||||
bitrate: String?,
|
||||
): AutoConversionTarget? {
|
||||
if (!enabled) return null
|
||||
val codec = when (format?.trim()?.lowercase(Locale.ROOT)) {
|
||||
"aac", "m4a" -> "aac"
|
||||
"opus" -> "opus"
|
||||
else -> "mp3"
|
||||
}
|
||||
val normalizedBitrate = Regex("(\\d+)")
|
||||
.find(bitrate.orEmpty())
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
?.toIntOrNull()
|
||||
?.takeIf { it in setOf(128, 192, 256, 320) }
|
||||
?: 320
|
||||
val extension = when (codec) {
|
||||
"aac" -> ".m4a"
|
||||
"opus" -> ".opus"
|
||||
else -> ".mp3"
|
||||
}
|
||||
return AutoConversionTarget(codec, extension, normalizedBitrate)
|
||||
}
|
||||
|
||||
fun autoConversionAlreadySatisfied(
|
||||
target: AutoConversionTarget,
|
||||
audioCodec: String?,
|
||||
bitrateKbps: Int?,
|
||||
): Boolean {
|
||||
return normalizeAudioCodec(audioCodec) == target.codec &&
|
||||
bitrateKbps == target.bitrateKbps
|
||||
}
|
||||
|
||||
fun normalizeAudioCodec(codec: String?): String? {
|
||||
val normalized = normalizeOptional(codec)
|
||||
?.lowercase(Locale.ROOT)
|
||||
|
||||
@@ -7,6 +7,41 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NativeFinalizationPolicyTest {
|
||||
@Test
|
||||
fun automaticConversionSettingsAreNormalizedAndComparable() {
|
||||
val target = checkNotNull(
|
||||
NativeFinalizationPolicy.autoConversionTarget(
|
||||
enabled = true,
|
||||
format = "M4A",
|
||||
bitrate = "256 kbps",
|
||||
),
|
||||
)
|
||||
assertEquals("aac", target.codec)
|
||||
assertEquals(".m4a", target.extension)
|
||||
assertEquals(256, target.bitrateKbps)
|
||||
assertTrue(
|
||||
NativeFinalizationPolicy.autoConversionAlreadySatisfied(
|
||||
target,
|
||||
audioCodec = "mp4a",
|
||||
bitrateKbps = 256,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeFinalizationPolicy.autoConversionAlreadySatisfied(
|
||||
target,
|
||||
audioCodec = "aac",
|
||||
bitrateKbps = 128,
|
||||
),
|
||||
)
|
||||
assertNull(
|
||||
NativeFinalizationPolicy.autoConversionTarget(
|
||||
enabled = false,
|
||||
format = "opus",
|
||||
bitrate = "128k",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesSharedCrossPipelineQualityCases() {
|
||||
val stream = checkNotNull(
|
||||
|
||||
@@ -2764,6 +2764,60 @@ abstract class AppLocalizations {
|
||||
/// **'Lossy Format'**
|
||||
String get downloadLossyFormat;
|
||||
|
||||
/// Toggle for automatic post-download audio conversion
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Auto-convert after download'**
|
||||
String get downloadAutoConvert;
|
||||
|
||||
/// Explanation of safe automatic post-download conversion
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.'**
|
||||
String get downloadAutoConvertSubtitle;
|
||||
|
||||
/// Automatic conversion output format setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Output format'**
|
||||
String get downloadAutoConvertFormat;
|
||||
|
||||
/// Automatic conversion format picker explanation
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose the lossy format used for newly completed downloads.'**
|
||||
String get downloadAutoConvertFormatSubtitle;
|
||||
|
||||
/// Automatic conversion bitrate setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Output quality'**
|
||||
String get downloadAutoConvertBitrate;
|
||||
|
||||
/// Automatic conversion bitrate picker explanation
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Higher bitrates preserve more detail but create larger files.'**
|
||||
String get downloadAutoConvertBitrateSubtitle;
|
||||
|
||||
/// MP3 automatic conversion option description
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Best compatibility across players and devices'**
|
||||
String get downloadAutoConvertMp3Subtitle;
|
||||
|
||||
/// M4A AAC automatic conversion option description
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Efficient AAC audio in an M4A container'**
|
||||
String get downloadAutoConvertM4aSubtitle;
|
||||
|
||||
/// Opus automatic conversion option description
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Best efficiency for modern players'**
|
||||
String get downloadAutoConvertOpusSubtitle;
|
||||
|
||||
/// Title of the lossy format picker bottom sheet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -1515,6 +1515,39 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyFormat => 'Verlustbehaftetes Format';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvert => 'Auto-convert after download';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertSubtitle =>
|
||||
'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormat => 'Output format';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormatSubtitle =>
|
||||
'Choose the lossy format used for newly completed downloads.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrate => 'Output quality';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrateSubtitle =>
|
||||
'Higher bitrates preserve more detail but create larger files.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertMp3Subtitle =>
|
||||
'Best compatibility across players and devices';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertM4aSubtitle =>
|
||||
'Efficient AAC audio in an M4A container';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertOpusSubtitle =>
|
||||
'Best efficiency for modern players';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Verlustbehaftetes 320kbps-Format';
|
||||
|
||||
|
||||
@@ -1494,6 +1494,39 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvert => 'Auto-convert after download';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertSubtitle =>
|
||||
'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormat => 'Output format';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormatSubtitle =>
|
||||
'Choose the lossy format used for newly completed downloads.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrate => 'Output quality';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrateSubtitle =>
|
||||
'Higher bitrates preserve more detail but create larger files.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertMp3Subtitle =>
|
||||
'Best compatibility across players and devices';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertM4aSubtitle =>
|
||||
'Efficient AAC audio in an M4A container';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertOpusSubtitle =>
|
||||
'Best efficiency for modern players';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
|
||||
@@ -1494,6 +1494,39 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvert => 'Auto-convert after download';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertSubtitle =>
|
||||
'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormat => 'Output format';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormatSubtitle =>
|
||||
'Choose the lossy format used for newly completed downloads.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrate => 'Output quality';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrateSubtitle =>
|
||||
'Higher bitrates preserve more detail but create larger files.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertMp3Subtitle =>
|
||||
'Best compatibility across players and devices';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertM4aSubtitle =>
|
||||
'Efficient AAC audio in an M4A container';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertOpusSubtitle =>
|
||||
'Best efficiency for modern players';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
|
||||
@@ -1533,6 +1533,39 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyFormat => 'Format avec perte';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvert => 'Auto-convert after download';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertSubtitle =>
|
||||
'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormat => 'Output format';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormatSubtitle =>
|
||||
'Choose the lossy format used for newly completed downloads.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrate => 'Output quality';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrateSubtitle =>
|
||||
'Higher bitrates preserve more detail but create larger files.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertMp3Subtitle =>
|
||||
'Best compatibility across players and devices';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertM4aSubtitle =>
|
||||
'Efficient AAC audio in an M4A container';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertOpusSubtitle =>
|
||||
'Best efficiency for modern players';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Format avec perte à 320 kbps';
|
||||
|
||||
|
||||
@@ -1501,6 +1501,39 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyFormat => 'Format Lossy';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvert => 'Konversi otomatis setelah unduh';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertSubtitle =>
|
||||
'Ubah unduhan yang selesai ke format lossy yang lebih kecil. File asli hanya diganti setelah konversi berhasil.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormat => 'Format keluaran';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormatSubtitle =>
|
||||
'Pilih format lossy untuk unduhan yang baru selesai.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrate => 'Kualitas keluaran';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrateSubtitle =>
|
||||
'Bitrate lebih tinggi menjaga lebih banyak detail, tetapi ukuran file juga lebih besar.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertMp3Subtitle =>
|
||||
'Kompatibilitas terbaik di berbagai pemutar dan perangkat';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertM4aSubtitle =>
|
||||
'Audio AAC yang efisien dalam container M4A';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertOpusSubtitle =>
|
||||
'Efisiensi terbaik untuk pemutar modern';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Format Lossy 320kbps';
|
||||
|
||||
|
||||
@@ -1485,6 +1485,39 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvert => 'Auto-convert after download';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertSubtitle =>
|
||||
'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormat => 'Output format';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormatSubtitle =>
|
||||
'Choose the lossy format used for newly completed downloads.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrate => 'Output quality';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrateSubtitle =>
|
||||
'Higher bitrates preserve more detail but create larger files.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertMp3Subtitle =>
|
||||
'Best compatibility across players and devices';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertM4aSubtitle =>
|
||||
'Efficient AAC audio in an M4A container';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertOpusSubtitle =>
|
||||
'Best efficiency for modern players';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
|
||||
@@ -1464,6 +1464,39 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyFormat => '손실 압축 형식';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvert => 'Auto-convert after download';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertSubtitle =>
|
||||
'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormat => 'Output format';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormatSubtitle =>
|
||||
'Choose the lossy format used for newly completed downloads.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrate => 'Output quality';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrateSubtitle =>
|
||||
'Higher bitrates preserve more detail but create larger files.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertMp3Subtitle =>
|
||||
'Best compatibility across players and devices';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertM4aSubtitle =>
|
||||
'Efficient AAC audio in an M4A container';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertOpusSubtitle =>
|
||||
'Best efficiency for modern players';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => '손실 압축 320kbps 형식';
|
||||
|
||||
|
||||
@@ -1494,6 +1494,39 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvert => 'Auto-convert after download';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertSubtitle =>
|
||||
'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormat => 'Output format';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormatSubtitle =>
|
||||
'Choose the lossy format used for newly completed downloads.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrate => 'Output quality';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrateSubtitle =>
|
||||
'Higher bitrates preserve more detail but create larger files.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertMp3Subtitle =>
|
||||
'Best compatibility across players and devices';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertM4aSubtitle =>
|
||||
'Efficient AAC audio in an M4A container';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertOpusSubtitle =>
|
||||
'Best efficiency for modern players';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
|
||||
@@ -1505,6 +1505,39 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyFormat => 'Формат с потерями';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvert => 'Auto-convert after download';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertSubtitle =>
|
||||
'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormat => 'Output format';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormatSubtitle =>
|
||||
'Choose the lossy format used for newly completed downloads.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrate => 'Output quality';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrateSubtitle =>
|
||||
'Higher bitrates preserve more detail but create larger files.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertMp3Subtitle =>
|
||||
'Best compatibility across players and devices';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertM4aSubtitle =>
|
||||
'Efficient AAC audio in an M4A container';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertOpusSubtitle =>
|
||||
'Best efficiency for modern players';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Формат с потерями 320 кбит/с';
|
||||
|
||||
|
||||
@@ -1507,6 +1507,39 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyFormat => 'Kayıplı Format';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvert => 'Auto-convert after download';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertSubtitle =>
|
||||
'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormat => 'Output format';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormatSubtitle =>
|
||||
'Choose the lossy format used for newly completed downloads.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrate => 'Output quality';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrateSubtitle =>
|
||||
'Higher bitrates preserve more detail but create larger files.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertMp3Subtitle =>
|
||||
'Best compatibility across players and devices';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertM4aSubtitle =>
|
||||
'Efficient AAC audio in an M4A container';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertOpusSubtitle =>
|
||||
'Best efficiency for modern players';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Kayıplı 320kbps Formatı';
|
||||
|
||||
|
||||
@@ -1513,6 +1513,39 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyFormat => 'Формат із втратами';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvert => 'Auto-convert after download';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertSubtitle =>
|
||||
'Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormat => 'Output format';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertFormatSubtitle =>
|
||||
'Choose the lossy format used for newly completed downloads.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrate => 'Output quality';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertBitrateSubtitle =>
|
||||
'Higher bitrates preserve more detail but create larger files.';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertMp3Subtitle =>
|
||||
'Best compatibility across players and devices';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertM4aSubtitle =>
|
||||
'Efficient AAC audio in an M4A container';
|
||||
|
||||
@override
|
||||
String get downloadAutoConvertOpusSubtitle =>
|
||||
'Best efficiency for modern players';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Формат із втратами 320 кбіт/с';
|
||||
|
||||
|
||||
@@ -1991,6 +1991,42 @@
|
||||
"@downloadLossyFormat": {
|
||||
"description": "Setting title to pick output format for lossy downloads"
|
||||
},
|
||||
"downloadAutoConvert": "Auto-convert after download",
|
||||
"@downloadAutoConvert": {
|
||||
"description": "Toggle for automatic post-download audio conversion"
|
||||
},
|
||||
"downloadAutoConvertSubtitle": "Convert completed downloads to a smaller lossy format. The original is replaced only after conversion succeeds.",
|
||||
"@downloadAutoConvertSubtitle": {
|
||||
"description": "Explanation of safe automatic post-download conversion"
|
||||
},
|
||||
"downloadAutoConvertFormat": "Output format",
|
||||
"@downloadAutoConvertFormat": {
|
||||
"description": "Automatic conversion output format setting"
|
||||
},
|
||||
"downloadAutoConvertFormatSubtitle": "Choose the lossy format used for newly completed downloads.",
|
||||
"@downloadAutoConvertFormatSubtitle": {
|
||||
"description": "Automatic conversion format picker explanation"
|
||||
},
|
||||
"downloadAutoConvertBitrate": "Output quality",
|
||||
"@downloadAutoConvertBitrate": {
|
||||
"description": "Automatic conversion bitrate setting"
|
||||
},
|
||||
"downloadAutoConvertBitrateSubtitle": "Higher bitrates preserve more detail but create larger files.",
|
||||
"@downloadAutoConvertBitrateSubtitle": {
|
||||
"description": "Automatic conversion bitrate picker explanation"
|
||||
},
|
||||
"downloadAutoConvertMp3Subtitle": "Best compatibility across players and devices",
|
||||
"@downloadAutoConvertMp3Subtitle": {
|
||||
"description": "MP3 automatic conversion option description"
|
||||
},
|
||||
"downloadAutoConvertM4aSubtitle": "Efficient AAC audio in an M4A container",
|
||||
"@downloadAutoConvertM4aSubtitle": {
|
||||
"description": "M4A AAC automatic conversion option description"
|
||||
},
|
||||
"downloadAutoConvertOpusSubtitle": "Best efficiency for modern players",
|
||||
"@downloadAutoConvertOpusSubtitle": {
|
||||
"description": "Opus automatic conversion option description"
|
||||
},
|
||||
"downloadLossy320Format": "Lossy 320kbps Format",
|
||||
"@downloadLossy320Format": {
|
||||
"description": "Title of the lossy format picker bottom sheet"
|
||||
|
||||
@@ -4253,6 +4253,42 @@
|
||||
"@downloadLossyFormat": {
|
||||
"description": "Setting title to pick output format for lossy downloads"
|
||||
},
|
||||
"downloadAutoConvert": "Konversi otomatis setelah unduh",
|
||||
"@downloadAutoConvert": {
|
||||
"description": "Toggle for automatic post-download audio conversion"
|
||||
},
|
||||
"downloadAutoConvertSubtitle": "Ubah unduhan yang selesai ke format lossy yang lebih kecil. File asli hanya diganti setelah konversi berhasil.",
|
||||
"@downloadAutoConvertSubtitle": {
|
||||
"description": "Explanation of safe automatic post-download conversion"
|
||||
},
|
||||
"downloadAutoConvertFormat": "Format keluaran",
|
||||
"@downloadAutoConvertFormat": {
|
||||
"description": "Automatic conversion output format setting"
|
||||
},
|
||||
"downloadAutoConvertFormatSubtitle": "Pilih format lossy untuk unduhan yang baru selesai.",
|
||||
"@downloadAutoConvertFormatSubtitle": {
|
||||
"description": "Automatic conversion format picker explanation"
|
||||
},
|
||||
"downloadAutoConvertBitrate": "Kualitas keluaran",
|
||||
"@downloadAutoConvertBitrate": {
|
||||
"description": "Automatic conversion bitrate setting"
|
||||
},
|
||||
"downloadAutoConvertBitrateSubtitle": "Bitrate lebih tinggi menjaga lebih banyak detail, tetapi ukuran file juga lebih besar.",
|
||||
"@downloadAutoConvertBitrateSubtitle": {
|
||||
"description": "Automatic conversion bitrate picker explanation"
|
||||
},
|
||||
"downloadAutoConvertMp3Subtitle": "Kompatibilitas terbaik di berbagai pemutar dan perangkat",
|
||||
"@downloadAutoConvertMp3Subtitle": {
|
||||
"description": "MP3 automatic conversion option description"
|
||||
},
|
||||
"downloadAutoConvertM4aSubtitle": "Audio AAC yang efisien dalam container M4A",
|
||||
"@downloadAutoConvertM4aSubtitle": {
|
||||
"description": "M4A AAC automatic conversion option description"
|
||||
},
|
||||
"downloadAutoConvertOpusSubtitle": "Efisiensi terbaik untuk pemutar modern",
|
||||
"@downloadAutoConvertOpusSubtitle": {
|
||||
"description": "Opus automatic conversion option description"
|
||||
},
|
||||
"snackbarAlreadyInLibrary": "\"{trackName}\" sudah ada di perpustakaan Anda",
|
||||
"@snackbarAlreadyInLibrary": {
|
||||
"description": "Snackbar - track already exists in local library",
|
||||
|
||||
@@ -69,6 +69,9 @@ class AppSettings {
|
||||
final String lyricsMode;
|
||||
final String
|
||||
tidalHighFormat; // Legacy key for 320kbps lossy output format: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128'
|
||||
final bool autoConvertDownloads;
|
||||
final String autoConvertFormat; // 'mp3', 'aac' (M4A), or 'opus'
|
||||
final String autoConvertBitrate; // '128k', '192k', '256k', or '320k'
|
||||
final bool
|
||||
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||
final bool autoExportFailedDownloads;
|
||||
@@ -142,7 +145,7 @@ class AppSettings {
|
||||
this.historyViewMode = 'grid',
|
||||
this.historyFilterMode = 'all',
|
||||
this.defaultLibraryView = 'last',
|
||||
this.libraryQualityLabelMode = libraryQualityLabelBitrate,
|
||||
this.libraryQualityLabelMode = AppSettings.libraryQualityLabelBitrate,
|
||||
this.askQualityBeforeDownload = true,
|
||||
this.enableLogging = false,
|
||||
this.useExtensionProviders = true,
|
||||
@@ -160,6 +163,9 @@ class AppSettings {
|
||||
this.locale = 'system',
|
||||
this.lyricsMode = 'embed',
|
||||
this.tidalHighFormat = 'mp3_320',
|
||||
this.autoConvertDownloads = false,
|
||||
this.autoConvertFormat = 'mp3',
|
||||
this.autoConvertBitrate = '320k',
|
||||
this.useAllFilesAccess = false,
|
||||
this.autoExportFailedDownloads = false,
|
||||
this.downloadNetworkMode = 'any',
|
||||
@@ -235,6 +241,9 @@ class AppSettings {
|
||||
String? locale,
|
||||
String? lyricsMode,
|
||||
String? tidalHighFormat,
|
||||
bool? autoConvertDownloads,
|
||||
String? autoConvertFormat,
|
||||
String? autoConvertBitrate,
|
||||
bool? useAllFilesAccess,
|
||||
bool? autoExportFailedDownloads,
|
||||
String? downloadNetworkMode,
|
||||
@@ -323,6 +332,9 @@ class AppSettings {
|
||||
locale: locale ?? this.locale,
|
||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||
autoConvertDownloads: autoConvertDownloads ?? this.autoConvertDownloads,
|
||||
autoConvertFormat: autoConvertFormat ?? this.autoConvertFormat,
|
||||
autoConvertBitrate: autoConvertBitrate ?? this.autoConvertBitrate,
|
||||
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||
autoExportFailedDownloads:
|
||||
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
||||
|
||||
@@ -60,6 +60,9 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
locale: json['locale'] as String? ?? 'system',
|
||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
||||
autoConvertDownloads: json['autoConvertDownloads'] as bool? ?? false,
|
||||
autoConvertFormat: json['autoConvertFormat'] as String? ?? 'mp3',
|
||||
autoConvertBitrate: json['autoConvertBitrate'] as String? ?? '320k',
|
||||
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
||||
autoExportFailedDownloads:
|
||||
json['autoExportFailedDownloads'] as bool? ?? false,
|
||||
@@ -145,6 +148,9 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'locale': instance.locale,
|
||||
'lyricsMode': instance.lyricsMode,
|
||||
'tidalHighFormat': instance.tidalHighFormat,
|
||||
'autoConvertDownloads': instance.autoConvertDownloads,
|
||||
'autoConvertFormat': instance.autoConvertFormat,
|
||||
'autoConvertBitrate': instance.autoConvertBitrate,
|
||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
||||
'downloadNetworkMode': instance.downloadNetworkMode,
|
||||
|
||||
@@ -28,6 +28,7 @@ import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
||||
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
|
||||
import 'package:spotiflac_android/utils/int_utils.dart';
|
||||
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
|
||||
import 'package:spotiflac_android/utils/progress_stream_poller.dart';
|
||||
@@ -556,6 +557,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
embedReplayGain: settings.embedReplayGain,
|
||||
postProcessingEnabled: postProcessingEnabled,
|
||||
tidalHighFormat: settings.tidalHighFormat,
|
||||
autoConvertDownloads: settings.autoConvertDownloads,
|
||||
autoConvertFormat: normalizeAutoConvertFormat(settings.autoConvertFormat),
|
||||
autoConvertBitrate: normalizeAutoConvertBitrate(
|
||||
settings.autoConvertBitrate,
|
||||
),
|
||||
trackNumber: normalizedTrackNumber,
|
||||
playlistPosition: _validPlaylistPosition(item),
|
||||
discNumber: normalizedDiscNumber,
|
||||
|
||||
@@ -23,6 +23,20 @@ class _QualityVariantFileOutcome {
|
||||
});
|
||||
}
|
||||
|
||||
class _AutoConversionOutcome {
|
||||
final String filePath;
|
||||
final String? fileName;
|
||||
final String quality;
|
||||
final bool converted;
|
||||
|
||||
const _AutoConversionOutcome({
|
||||
required this.filePath,
|
||||
required this.fileName,
|
||||
required this.quality,
|
||||
required this.converted,
|
||||
});
|
||||
}
|
||||
|
||||
/// AC-4 repair only applies to MP4 containers; decrypt can also emit raw
|
||||
/// FLAC, which the native MP4 box parser would reject as corrupt.
|
||||
bool _isMp4Container(String path) {
|
||||
@@ -31,6 +45,160 @@ bool _isMp4Container(String path) {
|
||||
}
|
||||
|
||||
extension _DownloadQueueFinalization on DownloadQueueNotifier {
|
||||
Future<_AutoConversionOutcome> _autoConvertDownloadedFile({
|
||||
required String itemId,
|
||||
required String filePath,
|
||||
required String? fileName,
|
||||
required String currentQuality,
|
||||
required AppSettings settings,
|
||||
required Track track,
|
||||
required Map<String, dynamic> result,
|
||||
required String downloadService,
|
||||
required String storageMode,
|
||||
String? downloadTreeUri,
|
||||
String? safRelativeDir,
|
||||
}) async {
|
||||
if (!settings.autoConvertDownloads) {
|
||||
return _AutoConversionOutcome(
|
||||
filePath: filePath,
|
||||
fileName: fileName,
|
||||
quality: currentQuality,
|
||||
converted: false,
|
||||
);
|
||||
}
|
||||
|
||||
final targetFormat = normalizeAutoConvertFormat(settings.autoConvertFormat);
|
||||
final targetBitrate = normalizeAutoConvertBitrate(
|
||||
settings.autoConvertBitrate,
|
||||
);
|
||||
final targetBitrateKbps = autoConvertBitrateKbps(targetBitrate);
|
||||
if (autoConversionAlreadySatisfied(
|
||||
filePath: filePath,
|
||||
fileName: fileName,
|
||||
targetFormat: targetFormat,
|
||||
targetBitrate: targetBitrate,
|
||||
quality: currentQuality,
|
||||
bitrateKbps: readPositiveBitrateKbps(
|
||||
result['bitrate'] ?? result['actual_bitrate'],
|
||||
),
|
||||
)) {
|
||||
return _AutoConversionOutcome(
|
||||
filePath: filePath,
|
||||
fileName: fileName,
|
||||
quality: currentQuality,
|
||||
converted: false,
|
||||
);
|
||||
}
|
||||
|
||||
final baseFileName = (fileName?.trim().isNotEmpty == true
|
||||
? fileName!.trim()
|
||||
: File(filePath).uri.pathSegments.last);
|
||||
final convertedFileName = convertedOutputFileName(
|
||||
originalFileName: baseFileName,
|
||||
targetFormat: targetFormat,
|
||||
);
|
||||
final convertedQuality =
|
||||
'${displayFormatForLossyFormat(targetFormat)} ${targetBitrateKbps}kbps';
|
||||
|
||||
Future<void> embedConvertedMetadata(String convertedPath) async {
|
||||
if (!settings.embedMetadata) return;
|
||||
try {
|
||||
await _embedMetadataToFile(
|
||||
convertedPath,
|
||||
track,
|
||||
format: metadataFormatForLossyFormat(targetFormat),
|
||||
genre: result['genre'] as String?,
|
||||
label: result['label'] as String?,
|
||||
copyright: result['copyright'] as String?,
|
||||
downloadService: downloadService,
|
||||
writeExternalLrc: storageMode != 'saf',
|
||||
);
|
||||
} catch (e) {
|
||||
// The audio conversion itself is still valid. Preserve the converted
|
||||
// file if an optional tag/cover write fails.
|
||||
_log.w('Automatic conversion metadata embed failed: $e');
|
||||
result['auto_conversion_metadata_warning'] = e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
updateItemStatus(itemId, DownloadStatus.finalizing, progress: 0.97);
|
||||
String? convertedPath;
|
||||
String? publishedFileName = convertedFileName;
|
||||
if (storageMode == 'saf' && isContentUri(filePath)) {
|
||||
if (downloadTreeUri == null || downloadTreeUri.isEmpty) {
|
||||
throw StateError('Missing SAF tree for automatic conversion');
|
||||
}
|
||||
convertedPath = await _replaceSafFileVia(
|
||||
uri: filePath,
|
||||
treeUri: downloadTreeUri,
|
||||
relativeDir: safRelativeDir ?? '',
|
||||
avoidOverwrite:
|
||||
convertedFileName.toLowerCase() != baseFileName.toLowerCase(),
|
||||
onPublishedFileName: (value) => publishedFileName = value,
|
||||
op: (tempPath, addCleanup) async {
|
||||
final output = await FFmpegService.convertAudioFormat(
|
||||
inputPath: tempPath,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: targetBitrate,
|
||||
metadata: const {},
|
||||
deleteOriginal: false,
|
||||
);
|
||||
if (output == null) return null;
|
||||
addCleanup(output);
|
||||
await embedConvertedMetadata(output);
|
||||
return (output, convertedFileName);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
convertedPath = await FFmpegService.convertAudioFormat(
|
||||
inputPath: filePath,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: targetBitrate,
|
||||
metadata: const {},
|
||||
deleteOriginal: true,
|
||||
);
|
||||
if (convertedPath != null) {
|
||||
publishedFileName = File(convertedPath).uri.pathSegments.last;
|
||||
await embedConvertedMetadata(convertedPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (convertedPath == null || convertedPath.isEmpty) {
|
||||
throw StateError('FFmpeg returned no automatic conversion output');
|
||||
}
|
||||
|
||||
result['file_path'] = convertedPath;
|
||||
result['file_name'] = publishedFileName;
|
||||
result['audio_codec'] = targetFormat;
|
||||
result['format'] = targetFormat;
|
||||
result['bitrate'] = targetBitrateKbps;
|
||||
result.remove('actual_bit_depth');
|
||||
result.remove('actual_sample_rate');
|
||||
_log.i(
|
||||
'Automatic conversion completed: ${autoConvertFormatLabel(targetFormat)} @ $targetBitrate',
|
||||
);
|
||||
return _AutoConversionOutcome(
|
||||
filePath: convertedPath,
|
||||
fileName: publishedFileName,
|
||||
quality: convertedQuality,
|
||||
converted: true,
|
||||
);
|
||||
} catch (e) {
|
||||
// A successful download remains usable when the optional conversion
|
||||
// fails. Conversion helpers only remove the source after atomic output
|
||||
// promotion, so returning the original path is safe here.
|
||||
result['auto_conversion_warning'] = e.toString();
|
||||
_log.w('Automatic conversion failed; keeping downloaded source: $e');
|
||||
return _AutoConversionOutcome(
|
||||
filePath: filePath,
|
||||
fileName: fileName,
|
||||
quality: currentQuality,
|
||||
converted: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the [DownloadHistoryItem] shared by the native-worker and inline
|
||||
/// completion paths. Fields whose source/derivation legitimately differs
|
||||
/// between the two callers (SAF location, probed vs. raw audio metadata,
|
||||
@@ -709,7 +877,12 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
final tidalHighFormat = settings.tidalHighFormat;
|
||||
final tidalHighFormat = settings.autoConvertDownloads
|
||||
? autoConvertLossySetting(
|
||||
format: settings.autoConvertFormat,
|
||||
bitrate: settings.autoConvertBitrate,
|
||||
)
|
||||
: settings.tidalHighFormat;
|
||||
final format = lossyFormatForSetting(tidalHighFormat);
|
||||
final newExt = lossyExtensionForFormat(format);
|
||||
final displayFormat = displayFormatForLossyFormat(format);
|
||||
@@ -761,6 +934,11 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
|
||||
}
|
||||
result['file_name'] = newFileName;
|
||||
result['_native_actual_quality'] = '$displayFormat $bitrateDisplay';
|
||||
result['audio_codec'] = format;
|
||||
result['format'] = format;
|
||||
result['bitrate'] = int.tryParse(tidalHighFormat.split('_').last);
|
||||
result.remove('actual_bit_depth');
|
||||
result.remove('actual_sample_rate');
|
||||
return newUri;
|
||||
}
|
||||
|
||||
@@ -775,6 +953,11 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
|
||||
}
|
||||
await embedConvertedMetadata(convertedPath);
|
||||
result['_native_actual_quality'] = '$displayFormat $bitrateDisplay';
|
||||
result['audio_codec'] = format;
|
||||
result['format'] = format;
|
||||
result['bitrate'] = int.tryParse(tidalHighFormat.split('_').last);
|
||||
result.remove('actual_bit_depth');
|
||||
result.remove('actual_sample_rate');
|
||||
return convertedPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -1212,6 +1212,30 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
if (postProcessedPath != null && postProcessedPath.isNotEmpty) {
|
||||
filePath = postProcessedPath;
|
||||
}
|
||||
final autoConvertOutcome = await _autoConvertDownloadedFile(
|
||||
itemId: item.id,
|
||||
filePath: filePath,
|
||||
fileName: result['file_name'] as String? ?? context.safFileName,
|
||||
currentQuality: actualQuality,
|
||||
settings: settings,
|
||||
track: trackToDownload,
|
||||
result: result,
|
||||
downloadService: context.item.service,
|
||||
storageMode: context.storageMode,
|
||||
downloadTreeUri: context.downloadTreeUri,
|
||||
safRelativeDir: context.safRelativeDir,
|
||||
);
|
||||
filePath = autoConvertOutcome.filePath;
|
||||
actualQuality = autoConvertOutcome.quality;
|
||||
if (autoConvertOutcome.fileName != null) {
|
||||
result['file_name'] = autoConvertOutcome.fileName;
|
||||
}
|
||||
if (autoConvertOutcome.converted) {
|
||||
actualBitDepth = null;
|
||||
actualSampleRate = null;
|
||||
actualFormat = normalizeAutoConvertFormat(settings.autoConvertFormat);
|
||||
actualBitrate = autoConvertBitrateKbps(settings.autoConvertBitrate);
|
||||
}
|
||||
await _writeNativeWorkerReplayGain(
|
||||
context: context,
|
||||
settings: settings,
|
||||
|
||||
@@ -713,6 +713,27 @@ class _DownloadRun {
|
||||
}
|
||||
}
|
||||
|
||||
final autoConvertInput = filePath;
|
||||
if (!wasExisting && autoConvertInput != null) {
|
||||
final outcome = await n._autoConvertDownloadedFile(
|
||||
itemId: item.id,
|
||||
filePath: autoConvertInput,
|
||||
fileName: finalSafFileName ?? result['file_name'] as String?,
|
||||
currentQuality: actualQuality,
|
||||
settings: settings,
|
||||
track: trackToDownload,
|
||||
result: result,
|
||||
downloadService: item.service,
|
||||
storageMode: effectiveSafMode ? 'saf' : 'app',
|
||||
downloadTreeUri: settings.downloadTreeUri,
|
||||
safRelativeDir: effectiveOutputDir,
|
||||
);
|
||||
filePath = outcome.filePath;
|
||||
finalSafFileName = outcome.fileName;
|
||||
actualQuality = outcome.quality;
|
||||
if (outcome.converted) probedFinalMetadata = null;
|
||||
}
|
||||
|
||||
final variantInput = filePath;
|
||||
if (variantInput != null && item.preserveQualityVariant) {
|
||||
final variantOutcome = await n._finalizeQualityVariantFilename(
|
||||
@@ -911,7 +932,12 @@ class _DownloadRun {
|
||||
}
|
||||
|
||||
Future<void> _convertSafM4aToLossy(String currentFilePath) async {
|
||||
final tidalHighFormat = settings.tidalHighFormat;
|
||||
final tidalHighFormat = settings.autoConvertDownloads
|
||||
? autoConvertLossySetting(
|
||||
format: settings.autoConvertFormat,
|
||||
bitrate: settings.autoConvertBitrate,
|
||||
)
|
||||
: settings.tidalHighFormat;
|
||||
_log.i(
|
||||
'Lossy 320kbps quality (SAF), converting M4A to $tidalHighFormat...',
|
||||
);
|
||||
@@ -972,6 +998,11 @@ class _DownloadRun {
|
||||
? '${tidalHighFormat.split('_').last}kbps'
|
||||
: '320kbps';
|
||||
actualQuality = '$displayFormat $bitrateDisplay';
|
||||
result['audio_codec'] = format;
|
||||
result['format'] = format;
|
||||
result['bitrate'] = int.tryParse(tidalHighFormat.split('_').last);
|
||||
result.remove('actual_bit_depth');
|
||||
result.remove('actual_sample_rate');
|
||||
} else if (convertFailed) {
|
||||
_log.w('M4A to $format conversion failed, keeping M4A file');
|
||||
actualQuality = 'AAC 320kbps';
|
||||
@@ -1115,7 +1146,12 @@ class _DownloadRun {
|
||||
}
|
||||
|
||||
Future<void> _convertLocalM4aToLossy(String currentFilePath) async {
|
||||
final tidalHighFormat = settings.tidalHighFormat;
|
||||
final tidalHighFormat = settings.autoConvertDownloads
|
||||
? autoConvertLossySetting(
|
||||
format: settings.autoConvertFormat,
|
||||
bitrate: settings.autoConvertBitrate,
|
||||
)
|
||||
: settings.tidalHighFormat;
|
||||
_log.i(
|
||||
'Lossy 320kbps quality download, converting M4A to $tidalHighFormat...',
|
||||
);
|
||||
@@ -1138,6 +1174,11 @@ class _DownloadRun {
|
||||
? '${tidalHighFormat.split('_').last}kbps'
|
||||
: '320kbps';
|
||||
actualQuality = '$displayFormat $bitrateDisplay';
|
||||
result['audio_codec'] = format;
|
||||
result['format'] = format;
|
||||
result['bitrate'] = int.tryParse(tidalHighFormat.split('_').last);
|
||||
result.remove('actual_bit_depth');
|
||||
result.remove('actual_sample_rate');
|
||||
_log.i('Successfully converted M4A to $format: $convertedPath');
|
||||
|
||||
_log.i('Embedding metadata to $format...');
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
@@ -162,6 +163,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
libraryQualityLabelMode: _normalizeLibraryQualityLabelMode(
|
||||
loaded.libraryQualityLabelMode,
|
||||
),
|
||||
autoConvertFormat: normalizeAutoConvertFormat(
|
||||
loaded.autoConvertFormat,
|
||||
),
|
||||
autoConvertBitrate: normalizeAutoConvertBitrate(
|
||||
loaded.autoConvertBitrate,
|
||||
),
|
||||
defaultService: loaded.defaultService,
|
||||
searchProvider: loaded.searchProvider,
|
||||
extensionVerificationBrowserMode:
|
||||
@@ -735,6 +742,25 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setAutoConvertDownloads(bool enabled) {
|
||||
state = state.copyWith(autoConvertDownloads: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setAutoConvertFormat(String format) {
|
||||
state = state.copyWith(
|
||||
autoConvertFormat: normalizeAutoConvertFormat(format),
|
||||
);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setAutoConvertBitrate(String bitrate) {
|
||||
state = state.copyWith(
|
||||
autoConvertBitrate: normalizeAutoConvertBitrate(bitrate),
|
||||
);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUseAllFilesAccess(bool enabled) {
|
||||
state = state.copyWith(useAllFilesAccess: enabled);
|
||||
_saveSettings();
|
||||
|
||||
@@ -6,6 +6,8 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/screens/settings/download_fallback_extensions_page.dart';
|
||||
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
|
||||
|
||||
@@ -109,10 +111,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
onTap: () => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAudioQuality(quality.id),
|
||||
showDivider:
|
||||
quality != qualityOptions.last ||
|
||||
(usesTidalCompatibilityOptions &&
|
||||
settings.audioQuality == 'HIGH'),
|
||||
showDivider: true,
|
||||
),
|
||||
if (usesTidalCompatibilityOptions &&
|
||||
settings.audioQuality == 'HIGH')
|
||||
@@ -128,9 +127,46 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
ref,
|
||||
settings.tidalHighFormat,
|
||||
),
|
||||
showDivider: false,
|
||||
showDivider: true,
|
||||
),
|
||||
],
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.auto_fix_high_outlined,
|
||||
title: context.l10n.downloadAutoConvert,
|
||||
subtitle: context.l10n.downloadAutoConvertSubtitle,
|
||||
value: settings.autoConvertDownloads,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAutoConvertDownloads(value),
|
||||
showDivider: settings.autoConvertDownloads,
|
||||
),
|
||||
if (settings.autoConvertDownloads) ...[
|
||||
SettingsItem(
|
||||
icon: Icons.audio_file_outlined,
|
||||
title: context.l10n.downloadAutoConvertFormat,
|
||||
subtitle: autoConvertFormatLabel(
|
||||
settings.autoConvertFormat,
|
||||
),
|
||||
onTap: () => _showAutoConvertFormatPicker(
|
||||
context,
|
||||
ref,
|
||||
settings.autoConvertFormat,
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.speed_outlined,
|
||||
title: context.l10n.downloadAutoConvertBitrate,
|
||||
subtitle: normalizeAutoConvertBitrate(
|
||||
settings.autoConvertBitrate,
|
||||
).replaceAll('k', ' kbps'),
|
||||
onTap: () => _showAutoConvertBitratePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.autoConvertBitrate,
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -497,6 +533,91 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showAutoConvertFormatPicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String current,
|
||||
) {
|
||||
final normalizedCurrent = normalizeAutoConvertFormat(current);
|
||||
showAppBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
title: context.l10n.downloadAutoConvertFormat,
|
||||
subtitle: context.l10n.downloadAutoConvertFormatSubtitle,
|
||||
builder: (sheetContext) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final format in autoConvertFormats)
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
format == 'opus'
|
||||
? Icons.graphic_eq
|
||||
: format == 'aac'
|
||||
? Icons.album_outlined
|
||||
: Icons.audiotrack,
|
||||
),
|
||||
title: Text(autoConvertFormatLabel(format)),
|
||||
subtitle: Text(switch (format) {
|
||||
'aac' => context.l10n.downloadAutoConvertM4aSubtitle,
|
||||
'opus' => context.l10n.downloadAutoConvertOpusSubtitle,
|
||||
_ => context.l10n.downloadAutoConvertMp3Subtitle,
|
||||
}),
|
||||
trailing: normalizedCurrent == format
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: Theme.of(sheetContext).colorScheme.primary,
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAutoConvertFormat(format);
|
||||
Navigator.pop(sheetContext);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAutoConvertBitratePicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String current,
|
||||
) {
|
||||
final normalizedCurrent = normalizeAutoConvertBitrate(current);
|
||||
showAppBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
title: context.l10n.downloadAutoConvertBitrate,
|
||||
subtitle: context.l10n.downloadAutoConvertBitrateSubtitle,
|
||||
builder: (sheetContext) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final bitrate in autoConvertBitrates)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.speed_outlined),
|
||||
title: Text(bitrate.replaceAll('k', ' kbps')),
|
||||
trailing: normalizedCurrent == bitrate
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: Theme.of(sheetContext).colorScheme.primary,
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAutoConvertBitrate(bitrate);
|
||||
Navigator.pop(sheetContext);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showNetworkModePicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
|
||||
@@ -321,6 +321,19 @@ class SettingsSearchCatalog {
|
||||
title: l10n.downloadLossyFormat,
|
||||
keywords: const ['mp3', 'aac', 'opus'],
|
||||
),
|
||||
SettingsSearchEntry(
|
||||
icon: Icons.auto_fix_high_outlined,
|
||||
title: l10n.downloadAutoConvert,
|
||||
subtitle: l10n.downloadAutoConvertSubtitle,
|
||||
keywords: const [
|
||||
'automatic conversion',
|
||||
'mp3',
|
||||
'm4a',
|
||||
'aac',
|
||||
'opus',
|
||||
'bitrate',
|
||||
],
|
||||
),
|
||||
SettingsSearchEntry(
|
||||
icon: Icons.wifi,
|
||||
title: l10n.settingsDownloadNetwork,
|
||||
|
||||
@@ -20,6 +20,9 @@ class DownloadRequestPayload {
|
||||
final bool embedReplayGain;
|
||||
final bool postProcessingEnabled;
|
||||
final String tidalHighFormat;
|
||||
final bool autoConvertDownloads;
|
||||
final String autoConvertFormat;
|
||||
final String autoConvertBitrate;
|
||||
final int trackNumber;
|
||||
final int playlistPosition;
|
||||
final int discNumber;
|
||||
@@ -73,6 +76,9 @@ class DownloadRequestPayload {
|
||||
this.embedReplayGain = false,
|
||||
this.postProcessingEnabled = false,
|
||||
this.tidalHighFormat = 'mp3_320',
|
||||
this.autoConvertDownloads = false,
|
||||
this.autoConvertFormat = 'mp3',
|
||||
this.autoConvertBitrate = '320k',
|
||||
this.trackNumber = 0,
|
||||
this.playlistPosition = 0,
|
||||
this.discNumber = 0,
|
||||
@@ -128,6 +134,9 @@ class DownloadRequestPayload {
|
||||
'embed_replaygain': embedReplayGain,
|
||||
'post_processing_enabled': postProcessingEnabled,
|
||||
'tidal_high_format': tidalHighFormat,
|
||||
'auto_convert_downloads': autoConvertDownloads,
|
||||
'auto_convert_format': autoConvertFormat,
|
||||
'auto_convert_bitrate': autoConvertBitrate,
|
||||
'track_number': trackNumber,
|
||||
'playlist_position': playlistPosition,
|
||||
'disc_number': discNumber,
|
||||
@@ -187,6 +196,9 @@ class DownloadRequestPayload {
|
||||
embedReplayGain: embedReplayGain,
|
||||
postProcessingEnabled: postProcessingEnabled,
|
||||
tidalHighFormat: tidalHighFormat,
|
||||
autoConvertDownloads: autoConvertDownloads,
|
||||
autoConvertFormat: autoConvertFormat,
|
||||
autoConvertBitrate: autoConvertBitrate,
|
||||
trackNumber: trackNumber,
|
||||
playlistPosition: playlistPosition,
|
||||
discNumber: discNumber,
|
||||
|
||||
@@ -285,6 +285,69 @@ String displayFormatForLossyFormat(String format) {
|
||||
return format == 'aac' ? 'AAC' : format.toUpperCase();
|
||||
}
|
||||
|
||||
const List<String> autoConvertFormats = ['mp3', 'aac', 'opus'];
|
||||
const List<String> autoConvertBitrates = ['128k', '192k', '256k', '320k'];
|
||||
|
||||
String normalizeAutoConvertFormat(String value) {
|
||||
final normalized = value.trim().toLowerCase();
|
||||
if (normalized == 'm4a') return 'aac';
|
||||
return autoConvertFormats.contains(normalized) ? normalized : 'mp3';
|
||||
}
|
||||
|
||||
String normalizeAutoConvertBitrate(String value) {
|
||||
final match = RegExp(r'(\d+)').firstMatch(value);
|
||||
final normalized = match == null ? '' : '${match.group(1)}k';
|
||||
return autoConvertBitrates.contains(normalized) ? normalized : '320k';
|
||||
}
|
||||
|
||||
int autoConvertBitrateKbps(String value) {
|
||||
return int.parse(normalizeAutoConvertBitrate(value).replaceAll('k', ''));
|
||||
}
|
||||
|
||||
String autoConvertLossySetting({
|
||||
required String format,
|
||||
required String bitrate,
|
||||
}) {
|
||||
final normalizedFormat = normalizeAutoConvertFormat(format);
|
||||
final normalizedBitrate = autoConvertBitrateKbps(bitrate);
|
||||
return '${normalizedFormat}_$normalizedBitrate';
|
||||
}
|
||||
|
||||
String autoConvertFormatLabel(String format) {
|
||||
return switch (normalizeAutoConvertFormat(format)) {
|
||||
'aac' => 'M4A (AAC)',
|
||||
'opus' => 'Opus',
|
||||
_ => 'MP3',
|
||||
};
|
||||
}
|
||||
|
||||
bool autoConversionAlreadySatisfied({
|
||||
required String? filePath,
|
||||
String? fileName,
|
||||
required String targetFormat,
|
||||
required String targetBitrate,
|
||||
String? quality,
|
||||
int? bitrateKbps,
|
||||
}) {
|
||||
final sourceFormat = audioFormatForPath(filePath, fileName: fileName);
|
||||
final normalizedTarget = normalizeAutoConvertFormat(targetFormat);
|
||||
final matchesFormat = switch (normalizedTarget) {
|
||||
'aac' => sourceFormat == 'AAC' || sourceFormat == 'M4A',
|
||||
'opus' => sourceFormat == 'OPUS',
|
||||
_ => sourceFormat == 'MP3',
|
||||
};
|
||||
if (!matchesFormat) return false;
|
||||
|
||||
final targetKbps = autoConvertBitrateKbps(targetBitrate);
|
||||
if (bitrateKbps != null && bitrateKbps > 0) {
|
||||
return bitrateKbps == targetKbps;
|
||||
}
|
||||
return RegExp(
|
||||
'\\b$targetKbps\\s*kbps\\b',
|
||||
caseSensitive: false,
|
||||
).hasMatch(quality ?? '');
|
||||
}
|
||||
|
||||
String? resolveDisplayQuality({
|
||||
required String? filePath,
|
||||
String? fileName,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
|
||||
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
||||
|
||||
void main() {
|
||||
group('same-format lossless conversion', () {
|
||||
@@ -85,4 +86,38 @@ void main() {
|
||||
expect(first, isNot(other));
|
||||
});
|
||||
});
|
||||
|
||||
group('automatic download conversion settings', () {
|
||||
test('normalizes supported formats and bitrates', () {
|
||||
expect(normalizeAutoConvertFormat('M4A'), 'aac');
|
||||
expect(normalizeAutoConvertFormat('unexpected'), 'mp3');
|
||||
expect(normalizeAutoConvertBitrate('256 kbps'), '256k');
|
||||
expect(normalizeAutoConvertBitrate('999k'), '320k');
|
||||
expect(
|
||||
autoConvertLossySetting(format: 'opus', bitrate: '192k'),
|
||||
'opus_192',
|
||||
);
|
||||
});
|
||||
|
||||
test('skips only an output that already matches format and bitrate', () {
|
||||
expect(
|
||||
autoConversionAlreadySatisfied(
|
||||
filePath: '/music/Track.mp3',
|
||||
targetFormat: 'mp3',
|
||||
targetBitrate: '320k',
|
||||
quality: 'MP3 320kbps',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
autoConversionAlreadySatisfied(
|
||||
filePath: '/music/Track.m4a',
|
||||
targetFormat: 'aac',
|
||||
targetBitrate: '128k',
|
||||
bitrateKbps: 256,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -671,6 +671,9 @@ void main() {
|
||||
expect(settings.deduplicateDownloads, isTrue);
|
||||
expect(settings.allowQualityVariants, isFalse);
|
||||
expect(settings.nativeDownloadWorkerEnabled, isFalse);
|
||||
expect(settings.autoConvertDownloads, isFalse);
|
||||
expect(settings.autoConvertFormat, 'mp3');
|
||||
expect(settings.autoConvertBitrate, '320k');
|
||||
expect(
|
||||
settings.libraryQualityLabelMode,
|
||||
AppSettings.libraryQualityLabelBitrate,
|
||||
@@ -733,6 +736,9 @@ void main() {
|
||||
deduplicateDownloads: false,
|
||||
allowQualityVariants: true,
|
||||
nativeDownloadWorkerEnabled: true,
|
||||
autoConvertDownloads: true,
|
||||
autoConvertFormat: 'opus',
|
||||
autoConvertBitrate: '192k',
|
||||
libraryQualityLabelMode: AppSettings.libraryQualityLabelBitDepth,
|
||||
);
|
||||
|
||||
@@ -760,6 +766,9 @@ void main() {
|
||||
expect(decoded.deduplicateDownloads, isFalse);
|
||||
expect(decoded.allowQualityVariants, isTrue);
|
||||
expect(decoded.nativeDownloadWorkerEnabled, isTrue);
|
||||
expect(decoded.autoConvertDownloads, isTrue);
|
||||
expect(decoded.autoConvertFormat, 'opus');
|
||||
expect(decoded.autoConvertBitrate, '192k');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -808,6 +817,9 @@ void main() {
|
||||
embedReplayGain: true,
|
||||
postProcessingEnabled: true,
|
||||
tidalHighFormat: 'opus_256',
|
||||
autoConvertDownloads: true,
|
||||
autoConvertFormat: 'opus',
|
||||
autoConvertBitrate: '192k',
|
||||
trackNumber: 7,
|
||||
playlistPosition: 3,
|
||||
discNumber: 2,
|
||||
@@ -859,6 +871,9 @@ void main() {
|
||||
'embed_replaygain': true,
|
||||
'post_processing_enabled': true,
|
||||
'tidal_high_format': 'opus_256',
|
||||
'auto_convert_downloads': true,
|
||||
'auto_convert_format': 'opus',
|
||||
'auto_convert_bitrate': '192k',
|
||||
'track_number': 7,
|
||||
'playlist_position': 3,
|
||||
'disc_number': 2,
|
||||
@@ -913,6 +928,9 @@ void main() {
|
||||
expect(updated.filenameFormat, payload.filenameFormat);
|
||||
expect(updated.allowQualityVariant, payload.allowQualityVariant);
|
||||
expect(updated.qualityVariant, payload.qualityVariant);
|
||||
expect(updated.autoConvertDownloads, payload.autoConvertDownloads);
|
||||
expect(updated.autoConvertFormat, payload.autoConvertFormat);
|
||||
expect(updated.autoConvertBitrate, payload.autoConvertBitrate);
|
||||
expect(
|
||||
updated.qualityVariantCollisionOnly,
|
||||
payload.qualityVariantCollisionOnly,
|
||||
|
||||
Reference in New Issue
Block a user