refactor(android): extract native finalization policies

This commit is contained in:
zarzet
2026-07-26 01:14:14 +07:00
parent 20081b14bd
commit a5f2760023
8 changed files with 672 additions and 202 deletions
@@ -1202,22 +1202,14 @@ class DownloadService : Service() {
statuses: Map<String, String>,
requestKeys: Map<String, String>
): JSONArray {
val blockedKeys = mutableSetOf<String>()
val expectedCompletedByKey = mutableMapOf<String, Int>()
for ((itemId, key) in requestKeys) {
when (statuses[itemId]) {
"completed" -> expectedCompletedByKey[key] = (expectedCompletedByKey[key] ?: 0) + 1
"failed", "skipped", "queued", "downloading", "finalizing" -> blockedKeys.add(key)
}
}
val entriesByKey = entries.groupBy { it.optString("album_key", "") }
val eligibleIndexes = NativeReplayGainPolicy.eligibleEntryIndexes(
entryAlbumKeys = entries.map { it.optString("album_key", "") },
statuses = statuses,
requestAlbumKeys = requestKeys,
)
val eligible = JSONArray()
for ((key, group) in entriesByKey) {
if (key.isBlank() || blockedKeys.contains(key) || group.size <= 1) continue
val expected = expectedCompletedByKey[key] ?: continue
if (group.size != expected) continue
for (entry in group) eligible.put(entry)
for (index in eligibleIndexes) {
eligible.put(entries[index])
}
return eligible
}
@@ -1234,9 +1226,7 @@ class DownloadService : Service() {
}
private fun hasPendingNativeAlbumReplayGainWork(statuses: Map<String, String>): Boolean {
return statuses.values.any {
it == "queued" || it == "downloading" || it == "finalizing"
}
return NativeReplayGainPolicy.hasPendingWork(statuses)
}
private fun writeNativeReplayGainJournal() {
@@ -15,6 +15,13 @@ import com.antonkarpenko.ffmpegkit.LogRedirectionStrategy
import com.antonkarpenko.ffmpegkit.ReturnCode
import com.zarz.spotiflac.SafDownloadHandler.mimeTypeForExt
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
import org.json.JSONObject
import java.io.File
@@ -26,7 +33,6 @@ import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.pow
import kotlin.math.roundToInt
object NativeDownloadFinalizer {
private const val TAG = "NativeFinalizer"
@@ -894,67 +900,14 @@ object NativeDownloadFinalizer {
lowerName.endsWith(".ogg")
}
private fun displayAudioQuality(
filePath: String,
fileName: String,
bitDepth: Int?,
sampleRate: Int?,
bitrateKbps: Int?,
audioCodec: String? = null,
storedQuality: String?,
): String? {
val format = audioFormatForCodec(audioCodec) ?: audioFormatForPath(filePath, fileName)
if (format == "OPUS" ||
format == "MP3" ||
format == "AAC" ||
format == "EAC3" ||
format == "AC3" ||
format == "AC4" ||
(format == "M4A" && (bitDepth == null || bitDepth <= 0))
) {
return if (bitrateKbps != null && bitrateKbps >= 16) {
"$format ${bitrateKbps}kbps"
} else {
nonPlaceholderQuality(storedQuality) ?: format
}
}
if (bitDepth != null && bitDepth > 0 && sampleRate != null && sampleRate > 0) {
val khz = sampleRate / 1000.0
val precision = if (sampleRate % 1000 == 0) 0 else 1
val sampleRateLabel = "%.${precision}f".format(Locale.US, khz)
return "$bitDepth-bit/${sampleRateLabel}kHz"
}
return nonPlaceholderQuality(storedQuality) ?: normalizeOptional(storedQuality)
}
private fun qualityVariantFilenameLabel(state: FinalizeState): String? {
val measuredQuality = state.quality
if (isLossyAudioCodec(state.audioCodec)) {
val bitrate = state.bitrateKbps ?: Regex(
"\\b(\\d+)\\s*kbps\\b",
RegexOption.IGNORE_CASE,
).find(measuredQuality)?.groupValues?.getOrNull(1)?.toIntOrNull()
return bitrate?.takeIf { it >= 16 }?.let { "${it}kbps" }
}
var bitDepth = state.bitDepth
var sampleRate = state.sampleRate
if (bitDepth == null || sampleRate == null) {
val match = Regex(
"\\b(\\d+)\\s*(?:-|\\s)?bit\\s*[/_-]\\s*(\\d+(?:\\.\\d+)?)\\s*k?hz\\b",
RegexOption.IGNORE_CASE,
).find(measuredQuality)
bitDepth = bitDepth ?: match?.groupValues?.getOrNull(1)?.toIntOrNull()
sampleRate = sampleRate ?: match?.groupValues?.getOrNull(2)?.toDoubleOrNull()?.let { rate ->
if (rate < 1000) (rate * 1000).roundToInt() else rate.roundToInt()
}
}
if (bitDepth == null || bitDepth <= 0 || sampleRate == null || sampleRate <= 0) return null
val khz = sampleRate / 1000.0
val precision = if (sampleRate % 1000 == 0) 0 else 1
val sampleRateLabel = "%.${precision}f".format(Locale.US, khz)
return "${bitDepth}bit-${sampleRateLabel}kHz"
return NativeFinalizationPolicy.qualityVariantFilenameLabel(
measuredQuality = state.quality,
bitDepth = state.bitDepth,
sampleRate = state.sampleRate,
bitrateKbps = state.bitrateKbps,
audioCodec = state.audioCodec,
)
}
private fun finalizeQualityVariantFilename(
@@ -1019,22 +972,6 @@ object NativeDownloadFinalizer {
}
}
private fun applyQualityVariantFilenameLabel(
fileName: String,
stagingLabel: String,
qualityLabel: String,
): String {
if (stagingLabel.isNotEmpty() && fileName.contains(stagingLabel)) {
return fileName.replace(stagingLabel, qualityLabel)
}
if (fileName.contains(qualityLabel)) return fileName
val dotIndex = fileName.lastIndexOf('.')
val hasExtension = dotIndex > 0
val stem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
return "$stem - $qualityLabel$extension"
}
private fun uniqueLocalFile(parent: File?, preferredName: String): File {
val directory = parent ?: return File(preferredName)
var candidate = File(directory, preferredName)
@@ -1051,78 +988,6 @@ object NativeDownloadFinalizer {
return candidate
}
private fun audioFormatForCodec(codec: String?): String? {
return when (normalizeAudioCodec(codec)) {
"flac" -> "FLAC"
"alac" -> "ALAC"
"aac" -> "AAC"
"eac3" -> "EAC3"
"ac3" -> "AC3"
"ac4" -> "AC4"
"mp3" -> "MP3"
"opus" -> "OPUS"
else -> null
}
}
private fun isLossyAudioCodec(codec: String?): Boolean {
return when (normalizeAudioCodec(codec)) {
"aac", "eac3", "ac3", "ac4", "mp3", "opus", "m4a" -> true
else -> false
}
}
private fun normalizeAudioCodec(codec: String?): String? {
val normalized = normalizeOptional(codec)
?.lowercase(Locale.ROOT)
?.replace('-', '_')
?: return null
return when (normalized) {
"mp4a" -> "aac"
"ec_3" -> "eac3"
"ac_3" -> "ac3"
"ac_4" -> "ac4"
"mp4" -> "m4a"
"ogg" -> "opus"
else -> normalized
}
}
private fun audioFormatForPath(filePath: String, fileName: String): String? {
for (candidate in listOf(filePath, fileName)) {
val lower = candidate.trim().lowercase(Locale.ROOT)
when {
lower.endsWith(".opus") || lower.endsWith(".ogg") -> return "OPUS"
lower.endsWith(".mp3") -> return "MP3"
lower.endsWith(".aac") -> return "AAC"
lower.endsWith(".m4a") || lower.endsWith(".mp4") -> return "M4A"
}
}
return null
}
private fun nonPlaceholderQuality(quality: String?): String? {
val normalized = normalizeOptional(quality) ?: return null
val bitrateMatch = Regex("\\b(\\d+)\\s*kbps\\b", RegexOption.IGNORE_CASE).find(normalized)
if (bitrateMatch != null) {
val bitrate = bitrateMatch.groupValues.getOrNull(1)?.toIntOrNull()
if (bitrate != null && bitrate < 16) return null
}
val key = normalized.lowercase(Locale.ROOT).replace(Regex("[^a-z0-9]+"), "_").trim('_')
val placeholders = setOf(
"best",
"lossless",
"hi_res",
"hires",
"hi_res_lossless",
"hires_lossless",
"high",
"cd",
"flac_best_available",
)
return if (placeholders.contains(key)) null else normalized
}
private fun writeExternalLrc(context: Context, input: FinalizeInput, state: FinalizeState) {
if (!input.request.optBoolean("embed_metadata", false) || !input.request.optBoolean("embed_lyrics", false)) return
val lyricsMode = input.request.optString("lyrics_mode", "")
@@ -1533,11 +1398,6 @@ object NativeDownloadFinalizer {
return "image/jpeg"
}
private fun formatIndexTag(number: Int, total: Int): String {
if (number <= 0) return "0"
return if (total > 0) "$number/$total" else number.toString()
}
private fun downloadCoverForMetadata(context: Context, input: FinalizeInput): File? {
val coverUrl = metadataCoverUrl(input).ifBlank { resultString(input, "cover_url") }
if (coverUrl.isBlank()) return null
@@ -1815,22 +1675,6 @@ object NativeDownloadFinalizer {
}
}
private fun isLosslessAudioCodec(codec: String): Boolean {
val normalized = codec.trim().lowercase(Locale.ROOT).replace('-', '_')
if (normalized.isBlank()) return false
if (normalized.startsWith("pcm_")) return true
return normalized in setOf(
"alac",
"flac",
"wavpack",
"ape",
"tta",
"mlp",
"truehd",
"shorten"
)
}
private fun requestedDecryptionOutputExt(input: FinalizeInput): String {
val descriptor = input.result.optJSONObject("decryption")
return normalizeExt(
@@ -1982,20 +1826,6 @@ object NativeDownloadFinalizer {
}
}
private fun resolvePreferredDecryptionExtension(inputPath: String, requested: String): String {
val req = normalizeExt(requested)
if (req.isNotBlank()) return req
val lower = inputPath.lowercase(Locale.ROOT)
return when {
lower.endsWith(".m4a") -> ".flac"
lower.endsWith(".flac") -> ".flac"
lower.endsWith(".mp3") -> ".mp3"
lower.endsWith(".opus") -> ".opus"
lower.endsWith(".mp4") -> ".mp4"
else -> ".flac"
}
}
private fun decryptionKeyCandidates(raw: String): List<String> {
val candidates = linkedSetOf<String>()
fun add(value: String) {
@@ -0,0 +1,241 @@
package com.zarz.spotiflac
import java.util.Locale
import kotlin.math.roundToInt
/**
* Pure finalization decisions shared by the Android background pipeline.
*
* Keeping format and naming policy free of Android/FFmpeg dependencies lets
* local JVM tests cover the branches that otherwise live inside the native
* finalizer's I/O-heavy orchestration.
*/
internal object NativeFinalizationPolicy {
fun normalizeAudioCodec(codec: String?): String? {
val normalized = normalizeOptional(codec)
?.lowercase(Locale.ROOT)
?.replace('-', '_')
?: return null
return when (normalized) {
"mp4a" -> "aac"
"ec_3" -> "eac3"
"ac_3" -> "ac3"
"ac_4" -> "ac4"
"mp4" -> "m4a"
"ogg" -> "opus"
else -> normalized
}
}
fun audioFormatForCodec(codec: String?): String? {
return when (normalizeAudioCodec(codec)) {
"flac" -> "FLAC"
"alac" -> "ALAC"
"aac" -> "AAC"
"eac3" -> "EAC3"
"ac3" -> "AC3"
"ac4" -> "AC4"
"mp3" -> "MP3"
"opus" -> "OPUS"
else -> null
}
}
fun isLossyAudioCodec(codec: String?): Boolean {
return when (normalizeAudioCodec(codec)) {
"aac", "eac3", "ac3", "ac4", "mp3", "opus", "m4a" -> true
else -> false
}
}
fun isLosslessAudioCodec(codec: String?): Boolean {
val normalized = normalizeAudioCodec(codec) ?: return false
if (normalized.startsWith("pcm_")) return true
return normalized in setOf(
"alac",
"flac",
"wavpack",
"ape",
"tta",
"mlp",
"truehd",
"shorten",
)
}
fun displayAudioQuality(
filePath: String,
fileName: String,
bitDepth: Int?,
sampleRate: Int?,
bitrateKbps: Int?,
audioCodec: String? = null,
storedQuality: String?,
): String? {
val format = audioFormatForCodec(audioCodec)
?: audioFormatForPath(filePath, fileName)
if (
format == "OPUS" ||
format == "MP3" ||
format == "AAC" ||
format == "EAC3" ||
format == "AC3" ||
format == "AC4" ||
(format == "M4A" && (bitDepth == null || bitDepth <= 0))
) {
return if (bitrateKbps != null && bitrateKbps >= 16) {
"$format ${bitrateKbps}kbps"
} else {
nonPlaceholderQuality(storedQuality) ?: format
}
}
if (bitDepth != null && bitDepth > 0 && sampleRate != null && sampleRate > 0) {
return "$bitDepth-bit/${sampleRateLabel(sampleRate)}kHz"
}
return nonPlaceholderQuality(storedQuality) ?: normalizeOptional(storedQuality)
}
fun qualityVariantFilenameLabel(
measuredQuality: String,
bitDepth: Int?,
sampleRate: Int?,
bitrateKbps: Int?,
audioCodec: String?,
): String? {
if (isLossyAudioCodec(audioCodec)) {
val bitrate = bitrateKbps ?: Regex(
"\\b(\\d+)\\s*kbps\\b",
RegexOption.IGNORE_CASE,
).find(measuredQuality)?.groupValues?.getOrNull(1)?.toIntOrNull()
return bitrate?.takeIf { it >= 16 }?.let { "${it}kbps" }
}
var resolvedBitDepth = bitDepth
var resolvedSampleRate = sampleRate
if (resolvedBitDepth == null || resolvedSampleRate == null) {
val match = Regex(
"\\b(\\d+)\\s*(?:-|\\s)?bit\\s*[/_-]\\s*(\\d+(?:\\.\\d+)?)\\s*k?hz\\b",
RegexOption.IGNORE_CASE,
).find(measuredQuality)
resolvedBitDepth =
resolvedBitDepth ?: match?.groupValues?.getOrNull(1)?.toIntOrNull()
resolvedSampleRate =
resolvedSampleRate
?: match?.groupValues?.getOrNull(2)?.toDoubleOrNull()?.let { rate ->
if (rate < 1000) {
(rate * 1000).roundToInt()
} else {
rate.roundToInt()
}
}
}
if (
resolvedBitDepth == null ||
resolvedBitDepth <= 0 ||
resolvedSampleRate == null ||
resolvedSampleRate <= 0
) {
return null
}
return "${resolvedBitDepth}bit-${sampleRateLabel(resolvedSampleRate)}kHz"
}
fun applyQualityVariantFilenameLabel(
fileName: String,
stagingLabel: String,
qualityLabel: String,
): String {
if (stagingLabel.isNotEmpty() && fileName.contains(stagingLabel)) {
return fileName.replace(stagingLabel, qualityLabel)
}
if (fileName.contains(qualityLabel)) return fileName
val dotIndex = fileName.lastIndexOf('.')
val hasExtension = dotIndex > 0
val stem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
return "$stem - $qualityLabel$extension"
}
fun resolvePreferredDecryptionExtension(
inputPath: String,
requested: String,
): String {
val normalizedRequest = normalizeExtension(requested)
if (normalizedRequest.isNotBlank()) return normalizedRequest
val lower = inputPath.lowercase(Locale.ROOT)
return when {
lower.endsWith(".m4a") -> ".flac"
lower.endsWith(".flac") -> ".flac"
lower.endsWith(".mp3") -> ".mp3"
lower.endsWith(".opus") -> ".opus"
lower.endsWith(".mp4") -> ".mp4"
else -> ".flac"
}
}
fun formatIndexTag(number: Int, total: Int): String {
if (number <= 0) return "0"
return if (total > 0) "$number/$total" else number.toString()
}
private fun audioFormatForPath(filePath: String, fileName: String): String? {
for (candidate in listOf(filePath, fileName)) {
val lower = candidate.trim().lowercase(Locale.ROOT)
when {
lower.endsWith(".opus") || lower.endsWith(".ogg") -> return "OPUS"
lower.endsWith(".mp3") -> return "MP3"
lower.endsWith(".aac") -> return "AAC"
lower.endsWith(".m4a") || lower.endsWith(".mp4") -> return "M4A"
}
}
return null
}
private fun nonPlaceholderQuality(quality: String?): String? {
val normalized = normalizeOptional(quality) ?: return null
val bitrateMatch =
Regex("\\b(\\d+)\\s*kbps\\b", RegexOption.IGNORE_CASE).find(normalized)
if (bitrateMatch != null) {
val bitrate = bitrateMatch.groupValues.getOrNull(1)?.toIntOrNull()
if (bitrate != null && bitrate < 16) return null
}
val key = normalized
.lowercase(Locale.ROOT)
.replace(Regex("[^a-z0-9]+"), "_")
.trim('_')
val placeholders = setOf(
"best",
"lossless",
"hi_res",
"hires",
"hi_res_lossless",
"hires_lossless",
"high",
"cd",
"flac_best_available",
)
return if (placeholders.contains(key)) null else normalized
}
private fun sampleRateLabel(sampleRate: Int): String {
val khz = sampleRate / 1000.0
val precision = if (sampleRate % 1000 == 0) 0 else 1
return "%.${precision}f".format(Locale.US, khz)
}
private fun normalizeOptional(value: String?): String? {
val trimmed = value?.trim().orEmpty()
if (trimmed.isEmpty() || trimmed.equals("null", ignoreCase = true)) {
return null
}
return trimmed
}
private fun normalizeExtension(extension: String?): String {
val trimmed = extension?.trim().orEmpty()
if (trimmed.isEmpty()) return ""
val lower = trimmed.lowercase(Locale.ROOT)
return if (lower.startsWith(".")) lower else ".$lower"
}
}
@@ -0,0 +1,53 @@
package com.zarz.spotiflac
/**
* Pure album ReplayGain completion policy used by DownloadService.
*/
internal object NativeReplayGainPolicy {
private val blockingStatuses = setOf(
"failed",
"skipped",
"queued",
"downloading",
"finalizing",
)
private val pendingStatuses = setOf("queued", "downloading", "finalizing")
fun eligibleEntryIndexes(
entryAlbumKeys: List<String>,
statuses: Map<String, String>,
requestAlbumKeys: Map<String, String>,
): List<Int> {
val blockedKeys = mutableSetOf<String>()
val expectedCompletedByKey = mutableMapOf<String, Int>()
for ((itemId, albumKey) in requestAlbumKeys) {
when (statuses[itemId]) {
"completed" -> {
expectedCompletedByKey[albumKey] =
(expectedCompletedByKey[albumKey] ?: 0) + 1
}
in blockingStatuses -> blockedKeys.add(albumKey)
}
}
val indexesByKey = entryAlbumKeys.indices.groupBy { entryAlbumKeys[it] }
val eligible = mutableListOf<Int>()
for ((albumKey, indexes) in indexesByKey) {
if (
albumKey.isBlank() ||
albumKey in blockedKeys ||
indexes.size <= 1
) {
continue
}
val expected = expectedCompletedByKey[albumKey] ?: continue
if (indexes.size == expected) {
eligible.addAll(indexes)
}
}
return eligible
}
fun hasPendingWork(statuses: Map<String, String>): Boolean =
statuses.values.any { it in pendingStatuses }
}
@@ -0,0 +1,173 @@
package com.zarz.spotiflac
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeFinalizationPolicyTest {
@Test
fun matchesSharedCrossPipelineQualityCases() {
val stream = checkNotNull(
javaClass.getResourceAsStream("/finalization_quality_cases.tsv"),
)
stream.bufferedReader().useLines { lines ->
for (line in lines) {
if (line.isBlank() || line.startsWith("#")) continue
val fields = line.split('\t')
assertEquals("invalid shared fixture: $line", 6, fields.size)
val actual = NativeFinalizationPolicy.qualityVariantFilenameLabel(
measuredQuality = fields[4],
bitDepth = fields[1].toIntOrNull(),
sampleRate = fields[2].toIntOrNull(),
bitrateKbps = fields[3].toIntOrNull(),
audioCodec = fields[0],
)
val expected = fields[5].takeUnless { it == "<null>" }
assertEquals("shared fixture: $line", expected, actual)
}
}
}
@Test
fun codecAliasesDriveLossyAndLosslessDecisions() {
assertEquals("aac", NativeFinalizationPolicy.normalizeAudioCodec("mp4a"))
assertEquals("eac3", NativeFinalizationPolicy.normalizeAudioCodec("ec-3"))
assertEquals("opus", NativeFinalizationPolicy.normalizeAudioCodec("ogg"))
assertTrue(NativeFinalizationPolicy.isLossyAudioCodec("ac-4"))
assertTrue(NativeFinalizationPolicy.isLosslessAudioCodec("pcm_s24le"))
assertTrue(NativeFinalizationPolicy.isLosslessAudioCodec("ALAC"))
assertFalse(NativeFinalizationPolicy.isLosslessAudioCodec("aac"))
}
@Test
fun displayQualityUsesMeasuredLosslessSpecifications() {
assertEquals(
"24-bit/96kHz",
NativeFinalizationPolicy.displayAudioQuality(
filePath = "/music/track.flac",
fileName = "track.flac",
bitDepth = 24,
sampleRate = 96000,
bitrateKbps = null,
audioCodec = "flac",
storedQuality = "HI_RES",
),
)
assertEquals(
"24-bit/44.1kHz",
NativeFinalizationPolicy.displayAudioQuality(
filePath = "/music/track.flac",
fileName = "track.flac",
bitDepth = 24,
sampleRate = 44100,
bitrateKbps = null,
audioCodec = "flac",
storedQuality = "LOSSLESS",
),
)
}
@Test
fun displayQualityPreservesUsefulLossyLabels() {
assertEquals(
"OPUS 192kbps",
NativeFinalizationPolicy.displayAudioQuality(
filePath = "/music/track.ogg",
fileName = "track.ogg",
bitDepth = null,
sampleRate = 48000,
bitrateKbps = 192,
audioCodec = "opus",
storedQuality = "HIGH",
),
)
assertEquals(
"AAC",
NativeFinalizationPolicy.displayAudioQuality(
filePath = "/music/track.m4a",
fileName = "track.m4a",
bitDepth = null,
sampleRate = 48000,
bitrateKbps = null,
audioCodec = "aac",
storedQuality = "LOSSLESS",
),
)
}
@Test
fun qualityVariantLabelRejectsUnmeasuredPlaceholders() {
assertNull(
NativeFinalizationPolicy.qualityVariantFilenameLabel(
measuredQuality = "LOSSLESS",
bitDepth = null,
sampleRate = null,
bitrateKbps = null,
audioCodec = "flac",
),
)
assertEquals(
"24bit-96kHz",
NativeFinalizationPolicy.qualityVariantFilenameLabel(
measuredQuality = "24-bit/96kHz",
bitDepth = null,
sampleRate = null,
bitrateKbps = null,
audioCodec = "flac",
),
)
assertEquals(
"320kbps",
NativeFinalizationPolicy.qualityVariantFilenameLabel(
measuredQuality = "MP3 320kbps",
bitDepth = null,
sampleRate = null,
bitrateKbps = null,
audioCodec = "mp3",
),
)
}
@Test
fun finalQualityLabelReplacesOnlyTheStagingToken() {
assertEquals(
"Artist - Track - 24bit-96kHz.flac",
NativeFinalizationPolicy.applyQualityVariantFilenameLabel(
fileName = "Artist - Track - pending.flac",
stagingLabel = "pending",
qualityLabel = "24bit-96kHz",
),
)
assertEquals(
"Artist - Track - 24bit-96kHz.flac",
NativeFinalizationPolicy.applyQualityVariantFilenameLabel(
fileName = "Artist - Track.flac",
stagingLabel = "",
qualityLabel = "24bit-96kHz",
),
)
}
@Test
fun decryptionExtensionAndIndexTagsHaveStableFallbacks() {
assertEquals(
".m4a",
NativeFinalizationPolicy.resolvePreferredDecryptionExtension(
inputPath = "/music/encrypted.bin",
requested = "M4A",
),
)
assertEquals(
".flac",
NativeFinalizationPolicy.resolvePreferredDecryptionExtension(
inputPath = "/music/encrypted.m4a",
requested = "",
),
)
assertEquals("3/12", NativeFinalizationPolicy.formatIndexTag(3, 12))
assertEquals("3", NativeFinalizationPolicy.formatIndexTag(3, 0))
assertEquals("0", NativeFinalizationPolicy.formatIndexTag(0, 12))
}
}
@@ -0,0 +1,91 @@
package com.zarz.spotiflac
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeReplayGainPolicyTest {
@Test
fun completeAlbumRequiresEveryCompletedRequestEntry() {
val eligible = NativeReplayGainPolicy.eligibleEntryIndexes(
entryAlbumKeys = listOf("album:a", "album:a"),
statuses = mapOf("one" to "completed", "two" to "completed"),
requestAlbumKeys = mapOf("one" to "album:a", "two" to "album:a"),
)
assertEquals(listOf(0, 1), eligible)
}
@Test
fun incompleteOrFailedAlbumIsBlocked() {
assertTrue(
NativeReplayGainPolicy.eligibleEntryIndexes(
entryAlbumKeys = listOf("album:a"),
statuses = mapOf("one" to "completed", "two" to "downloading"),
requestAlbumKeys = mapOf("one" to "album:a", "two" to "album:a"),
).isEmpty(),
)
assertTrue(
NativeReplayGainPolicy.eligibleEntryIndexes(
entryAlbumKeys = listOf("album:a", "album:a"),
statuses = mapOf("one" to "completed", "two" to "failed"),
requestAlbumKeys = mapOf("one" to "album:a", "two" to "album:a"),
).isEmpty(),
)
}
@Test
fun singleTrackAndBlankAlbumKeysAreNeverWrittenAsAlbumGain() {
val eligible = NativeReplayGainPolicy.eligibleEntryIndexes(
entryAlbumKeys = listOf("album:single", "", ""),
statuses = mapOf(
"single" to "completed",
"blank-one" to "completed",
"blank-two" to "completed",
),
requestAlbumKeys = mapOf(
"single" to "album:single",
"blank-one" to "",
"blank-two" to "",
),
)
assertTrue(eligible.isEmpty())
}
@Test
fun albumsAreEvaluatedIndependently() {
val eligible = NativeReplayGainPolicy.eligibleEntryIndexes(
entryAlbumKeys = listOf("album:a", "album:a", "album:b", "album:b"),
statuses = mapOf(
"a1" to "completed",
"a2" to "completed",
"b1" to "completed",
"b2" to "queued",
),
requestAlbumKeys = mapOf(
"a1" to "album:a",
"a2" to "album:a",
"b1" to "album:b",
"b2" to "album:b",
),
)
assertEquals(listOf(0, 1), eligible)
}
@Test
fun pendingWorkOnlyIncludesRunnableStatuses() {
assertTrue(
NativeReplayGainPolicy.hasPendingWork(
mapOf("one" to "completed", "two" to "finalizing"),
),
)
assertFalse(
NativeReplayGainPolicy.hasPendingWork(
mapOf("one" to "completed", "two" to "failed"),
),
)
}
}
@@ -0,0 +1,6 @@
# codec bit_depth sample_rate bitrate_kbps measured_quality expected_label
flac 24 96000 LOSSLESS 24bit-96kHz
flac LOSSLESS <null>
flac 24-bit/44.1kHz 24bit-44.1kHz
mp3 320 HIGH 320kbps
opus OPUS 192kbps 192kbps
1 # codec bit_depth sample_rate bitrate_kbps measured_quality expected_label
2 flac 24 96000 LOSSLESS 24bit-96kHz
3 flac LOSSLESS <null>
4 flac 24-bit/44.1kHz 24bit-44.1kHz
5 mp3 320 HIGH 320kbps
6 opus OPUS 192kbps 192kbps
+86
View File
@@ -24,6 +24,9 @@ void main() {
'android/app/src/main/kotlin/com/zarz/spotiflac/'
'NativeDownloadFinalizer.kt',
).readAsStringSync();
final historyDatabaseSource = File(
'lib/services/history_database.dart',
).readAsStringSync();
int kotlinConstant(String name) {
final match = RegExp(
@@ -46,6 +49,89 @@ void main() {
HistoryDatabase.schemaVersion,
);
});
Set<String> historyTableColumns(String source) {
final match = RegExp(
r'CREATE TABLE(?: IF NOT EXISTS)? history\s*\(([\s\S]*?)\n\s*\)',
).firstMatch(source);
expect(match, isNotNull, reason: 'Missing history CREATE TABLE');
return match!
.group(1)!
.split(',')
.map((definition) => definition.trim().split(RegExp(r'\s+')).first)
.where((column) => column.isNotEmpty)
.toSet();
}
Map<String, String> historyIndexes(String source) {
final indexes = <String, String>{};
final pattern = RegExp(
r'CREATE INDEX(?: IF NOT EXISTS)?\s+(\w+)\s+'
r'ON history\s*\(([^)]+)\)',
);
for (final match in pattern.allMatches(source)) {
indexes[match.group(1)!] = match
.group(2)!
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
}
return indexes;
}
test('uses the same history columns in Dart and native writers', () {
final dartColumns = historyTableColumns(historyDatabaseSource);
final nativeColumns = historyTableColumns(finalizerSource);
expect(nativeColumns, dartColumns);
final requiredBlock = RegExp(
r'requiredHistoryColumns\s*=\s*setOf\(([\s\S]*?)\n\s*\)',
).firstMatch(finalizerSource);
expect(requiredBlock, isNotNull);
final requiredColumns = RegExp(
r'"([a-z0-9_]+)"',
).allMatches(requiredBlock!.group(1)!).map((m) => m.group(1)!).toSet();
expect(requiredColumns, dartColumns);
final buildHistoryRow = RegExp(
r'private fun buildHistoryRow\([\s\S]*?return values',
).firstMatch(finalizerSource);
expect(buildHistoryRow, isNotNull);
final nativeWrittenColumns = RegExp(
r'values\.put\("([a-z0-9_]+)"',
).allMatches(buildHistoryRow!.group(0)!).map((m) => m.group(1)!).toSet();
expect(
dartColumns,
containsAll(nativeWrittenColumns),
reason: 'Native finalizer writes a column missing from Dart schema',
);
});
test('uses the same history indexes in Dart and native writers', () {
expect(
historyIndexes(finalizerSource),
historyIndexes(historyDatabaseSource),
);
});
test('matches shared Dart/native finalization quality cases', () {
final fixture = File(
'android/app/src/test/resources/finalization_quality_cases.tsv',
).readAsLinesSync();
for (final line in fixture) {
if (line.isEmpty || line.startsWith('#')) continue;
final fields = line.split('\t');
expect(fields, hasLength(6), reason: 'Invalid shared fixture: $line');
final actual = buildQualityVariantFilenameLabel(
detectedFormat: fields[0],
bitDepth: int.tryParse(fields[1]),
sampleRate: int.tryParse(fields[2]),
bitrateKbps: int.tryParse(fields[3]),
measuredQuality: fields[4],
);
final expected = fields[5] == '<null>' ? null : fields[5];
expect(actual, expected, reason: 'Shared fixture: $line');
}
});
});
group('quality variant filenames', () {