diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt index fa1813ad..645d743c 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt @@ -1202,22 +1202,14 @@ class DownloadService : Service() { statuses: Map, requestKeys: Map ): JSONArray { - val blockedKeys = mutableSetOf() - val expectedCompletedByKey = mutableMapOf() - 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): Boolean { - return statuses.values.any { - it == "queued" || it == "downloading" || it == "finalizing" - } + return NativeReplayGainPolicy.hasPendingWork(statuses) } private fun writeNativeReplayGainJournal() { diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt index 7ea14452..094b795c 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -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 { val candidates = linkedSetOf() fun add(value: String) { diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt new file mode 100644 index 00000000..d37391bd --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt @@ -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" + } +} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeReplayGainPolicy.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeReplayGainPolicy.kt new file mode 100644 index 00000000..bc13ce4e --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeReplayGainPolicy.kt @@ -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, + statuses: Map, + requestAlbumKeys: Map, + ): List { + val blockedKeys = mutableSetOf() + val expectedCompletedByKey = mutableMapOf() + 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() + 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): Boolean = + statuses.values.any { it in pendingStatuses } +} diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt new file mode 100644 index 00000000..32609825 --- /dev/null +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt @@ -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 == "" } + 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)) + } +} diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeReplayGainPolicyTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeReplayGainPolicyTest.kt new file mode 100644 index 00000000..03de3035 --- /dev/null +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeReplayGainPolicyTest.kt @@ -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"), + ), + ) + } +} diff --git a/android/app/src/test/resources/finalization_quality_cases.tsv b/android/app/src/test/resources/finalization_quality_cases.tsv new file mode 100644 index 00000000..b6d26daa --- /dev/null +++ b/android/app/src/test/resources/finalization_quality_cases.tsv @@ -0,0 +1,6 @@ +# codec bit_depth sample_rate bitrate_kbps measured_quality expected_label +flac 24 96000 LOSSLESS 24bit-96kHz +flac LOSSLESS +flac 24-bit/44.1kHz 24bit-44.1kHz +mp3 320 HIGH 320kbps +opus OPUS 192kbps 192kbps diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index a5659af6..901fcac2 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -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 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 historyIndexes(String source) { + final indexes = {}; + 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 : fields[5]; + expect(actual, expected, reason: 'Shared fixture: $line'); + } + }); }); group('quality variant filenames', () {