mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
refactor(android): extract native finalization policies
This commit is contained in:
@@ -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
|
||||
|
@@ -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', () {
|
||||
|
||||
Reference in New Issue
Block a user