mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
Downloads from providers that stream FLAC inside an fMP4 container (e.g. Amazon Music) were being written to disk with a .flac extension while the payload still carried ISO-BMFF atoms. The container-conversion guard then saw codec=flac and skipped the remux, leaving native FLAC tag writers to fail with 'fLaC head incorrect'. Force '-f flac' on the decryption command whenever the target extension is .flac so FFmpeg emits a real FLAC stream, and add an 'fLaC' magic-byte probe on both the Dart and Kotlin container-conversion guards so a FLAC-in-MP4 source is remuxed rather than silently passed through as a tag-writer hazard.
2123 lines
91 KiB
Kotlin
2123 lines
91 KiB
Kotlin
package com.zarz.spotiflac
|
|
|
|
import android.content.ContentValues
|
|
import android.content.Context
|
|
import android.database.sqlite.SQLiteDatabase
|
|
import android.database.sqlite.SQLiteException
|
|
import android.net.Uri
|
|
import android.util.Base64
|
|
import android.util.Log
|
|
import com.antonkarpenko.ffmpegkit.FFmpegKit
|
|
import com.antonkarpenko.ffmpegkit.FFmpegKitConfig
|
|
import com.antonkarpenko.ffmpegkit.FFmpegSession
|
|
import com.antonkarpenko.ffmpegkit.FFmpegSessionCompleteCallback
|
|
import com.antonkarpenko.ffmpegkit.LogRedirectionStrategy
|
|
import com.antonkarpenko.ffmpegkit.ReturnCode
|
|
import gobackend.Gobackend
|
|
import org.json.JSONObject
|
|
import java.io.File
|
|
import java.io.RandomAccessFile
|
|
import java.nio.ByteBuffer
|
|
import java.util.Locale
|
|
import java.util.concurrent.CancellationException
|
|
import java.util.concurrent.CountDownLatch
|
|
import java.util.concurrent.TimeUnit
|
|
import java.util.concurrent.atomic.AtomicBoolean
|
|
import kotlin.math.pow
|
|
|
|
object NativeDownloadFinalizer {
|
|
private const val TAG = "NativeFinalizer"
|
|
const val NATIVE_WORKER_CONTRACT_VERSION = 1
|
|
// Native finalizer owns background-safe history writes while Flutter may be suspended.
|
|
// Keep this schema contract in sync with Dart HistoryDatabase before bumping either side.
|
|
private const val HISTORY_SCHEMA_VERSION = 8
|
|
private val activeFFmpegSessionIds = mutableSetOf<Long>()
|
|
private val nativeFFmpegSessionIds = mutableSetOf<Long>()
|
|
private val activeFFmpegSessionLock = Any()
|
|
private val ffmpegCompleteCallbackLock = Any()
|
|
private var forwardedFFmpegCompleteCallback: FFmpegSessionCompleteCallback? = null
|
|
private val nativeFilteringFFmpegCompleteCallback = FFmpegSessionCompleteCallback { session ->
|
|
val isNativeSession = synchronized(activeFFmpegSessionLock) {
|
|
nativeFFmpegSessionIds.contains(session.sessionId)
|
|
}
|
|
if (!isNativeSession) {
|
|
val delegate = synchronized(ffmpegCompleteCallbackLock) {
|
|
forwardedFFmpegCompleteCallback
|
|
}
|
|
delegate?.apply(session)
|
|
}
|
|
}
|
|
private val requiredHistoryColumns = setOf(
|
|
"id",
|
|
"track_name",
|
|
"artist_name",
|
|
"album_name",
|
|
"album_artist",
|
|
"cover_url",
|
|
"file_path",
|
|
"storage_mode",
|
|
"download_tree_uri",
|
|
"saf_relative_dir",
|
|
"saf_file_name",
|
|
"saf_repaired",
|
|
"service",
|
|
"downloaded_at",
|
|
"isrc",
|
|
"spotify_id",
|
|
"track_number",
|
|
"total_tracks",
|
|
"disc_number",
|
|
"total_discs",
|
|
"duration",
|
|
"release_date",
|
|
"quality",
|
|
"bit_depth",
|
|
"sample_rate",
|
|
"genre",
|
|
"composer",
|
|
"label",
|
|
"copyright",
|
|
"spotify_id_norm",
|
|
"isrc_norm",
|
|
"match_key",
|
|
)
|
|
private val androidStoragePathAliases = listOf(
|
|
"/storage/emulated/0",
|
|
"/storage/emulated/legacy",
|
|
"/storage/self/primary",
|
|
"/sdcard",
|
|
"/mnt/sdcard",
|
|
)
|
|
private val audioExtensions = listOf(
|
|
".flac",
|
|
".m4a",
|
|
".mp3",
|
|
".opus",
|
|
".ogg",
|
|
".wav",
|
|
".aac",
|
|
)
|
|
|
|
private data class FinalizeInput(
|
|
val itemId: String,
|
|
val request: JSONObject,
|
|
val item: JSONObject,
|
|
val track: JSONObject,
|
|
val result: JSONObject,
|
|
)
|
|
|
|
private data class FinalizeState(
|
|
var filePath: String,
|
|
var fileName: String,
|
|
var quality: String,
|
|
var bitDepth: Int?,
|
|
var sampleRate: Int?,
|
|
var bitrateKbps: Int? = null,
|
|
var pendingExternalLrc: String? = null,
|
|
var pendingExternalLrcFileName: String? = null,
|
|
)
|
|
|
|
private data class ReplayGainScan(
|
|
val trackGain: String,
|
|
val trackPeak: String,
|
|
val integratedLufs: Double,
|
|
val truePeakLinear: Double,
|
|
)
|
|
|
|
fun cancelActiveWork() {
|
|
val sessionIds = synchronized(activeFFmpegSessionLock) {
|
|
activeFFmpegSessionIds.toList()
|
|
}
|
|
for (sessionId in sessionIds) {
|
|
try {
|
|
FFmpegKit.cancel(sessionId)
|
|
} catch (_: Exception) {
|
|
}
|
|
}
|
|
}
|
|
|
|
fun finalize(
|
|
context: Context,
|
|
itemId: String,
|
|
requestJson: String,
|
|
itemJson: String,
|
|
result: JSONObject,
|
|
shouldCancel: () -> Boolean = { false },
|
|
): JSONObject {
|
|
if (!result.optBoolean("success", false)) return result
|
|
|
|
val itemObject = parseObject(itemJson)
|
|
val requestObject = parseObject(requestJson)
|
|
validateRequestContract(requestObject)
|
|
val input = FinalizeInput(
|
|
itemId = itemId,
|
|
request = requestObject,
|
|
item = itemObject,
|
|
track = itemObject.optJSONObject("track") ?: JSONObject(),
|
|
result = result,
|
|
)
|
|
val track = if (input.track.length() > 0) input.track else input.item.optJSONObject("track") ?: JSONObject()
|
|
val effectiveInput = input.copy(track = track)
|
|
|
|
val initialPath = result.optString("file_path", "").trim()
|
|
if (initialPath.isEmpty()) {
|
|
result.put("success", false)
|
|
result.put("error", "Native finalizer received empty file path")
|
|
result.put("error_type", "unknown")
|
|
return result
|
|
}
|
|
|
|
val state = FinalizeState(
|
|
filePath = initialPath,
|
|
fileName = result.optString("file_name", "").ifBlank { File(initialPath).name },
|
|
quality = requestQuality(effectiveInput),
|
|
bitDepth = optPositiveInt(result, "actual_bit_depth"),
|
|
sampleRate = optPositiveInt(result, "actual_sample_rate"),
|
|
bitrateKbps = optPositiveBitrateKbps(result, "bitrate")
|
|
?: optPositiveBitrateKbps(result, "actual_bitrate"),
|
|
)
|
|
|
|
try {
|
|
var qualityMetadataRefreshed = false
|
|
if (!result.optBoolean("already_exists", false)) {
|
|
checkCancelled(shouldCancel)
|
|
currentStatus("finalizing")
|
|
finalizeDecryption(context, effectiveInput, state, shouldCancel)
|
|
checkCancelled(shouldCancel)
|
|
finalizeHighConversion(context, effectiveInput, state, shouldCancel)
|
|
checkCancelled(shouldCancel)
|
|
finalizeContainerConversion(context, effectiveInput, state, shouldCancel)
|
|
checkCancelled(shouldCancel)
|
|
finalizeMetadata(context, effectiveInput, state)
|
|
checkCancelled(shouldCancel)
|
|
writeExternalLrc(context, effectiveInput, state)
|
|
checkCancelled(shouldCancel)
|
|
runPostProcessing(context, effectiveInput, state, shouldCancel)
|
|
checkCancelled(shouldCancel)
|
|
val replayGain = writeReplayGain(context, effectiveInput, state, shouldCancel)
|
|
if (replayGain != null) result.put("replaygain", replayGain)
|
|
checkCancelled(shouldCancel)
|
|
if (isDeferredSafPublish(effectiveInput)) {
|
|
refreshFinalAudioQualityMetadata(context, result, state)
|
|
qualityMetadataRefreshed = true
|
|
publishDeferredSafOutput(context, effectiveInput, state)
|
|
} else {
|
|
promoteStagedSafOutputIfNeeded(context, effectiveInput, state)
|
|
}
|
|
}
|
|
checkCancelled(shouldCancel)
|
|
if (!qualityMetadataRefreshed) {
|
|
refreshFinalAudioQualityMetadata(context, result, state)
|
|
}
|
|
|
|
val history = buildHistoryRow(effectiveInput, state)
|
|
upsertHistory(context, history)
|
|
|
|
result.put("file_path", state.filePath)
|
|
if (state.fileName.isNotBlank()) result.put("file_name", state.fileName)
|
|
result.put("native_finalized", true)
|
|
result.put("history_written", true)
|
|
result.put("history_item", historyToJson(history))
|
|
} catch (e: CancellationException) {
|
|
cleanupFailedFinalizationOutput(context, result, initialPath, state.filePath)
|
|
result.put("success", false)
|
|
result.put("error", "Native finalization cancelled")
|
|
result.put("error_type", "cancelled")
|
|
result.put("native_finalized", false)
|
|
} catch (e: Exception) {
|
|
cleanupFailedFinalizationOutput(context, result, initialPath, state.filePath)
|
|
result.put("success", false)
|
|
result.put("error", "Native finalization failed: ${e.message}")
|
|
result.put("error_type", "unknown")
|
|
result.put("native_finalized", false)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
private fun checkCancelled(shouldCancel: () -> Boolean) {
|
|
if (shouldCancel()) {
|
|
throw CancellationException("Native finalization cancelled")
|
|
}
|
|
}
|
|
|
|
fun replayGainAlbumKey(requestJson: String, itemJson: String): String {
|
|
val item = parseObject(itemJson)
|
|
val input = FinalizeInput(
|
|
itemId = item.optString("id", ""),
|
|
request = parseObject(requestJson),
|
|
item = item,
|
|
track = item.optJSONObject("track") ?: JSONObject(),
|
|
result = JSONObject(),
|
|
)
|
|
return albumKey(input)
|
|
}
|
|
|
|
fun writeAlbumReplayGain(context: Context, entriesJson: String): String {
|
|
val entries = org.json.JSONArray(entriesJson)
|
|
val grouped = linkedMapOf<String, MutableList<JSONObject>>()
|
|
for (index in 0 until entries.length()) {
|
|
val entry = entries.optJSONObject(index) ?: continue
|
|
val key = entry.optString("album_key", "")
|
|
if (key.isBlank()) continue
|
|
grouped.getOrPut(key) { mutableListOf() }.add(entry)
|
|
}
|
|
|
|
var albumsWritten = 0
|
|
var filesWritten = 0
|
|
for ((_, group) in grouped) {
|
|
if (group.size <= 1) continue
|
|
var sumWeightedPower = 0.0
|
|
var sumDuration = 0.0
|
|
var maxPeak = 0.0
|
|
for (entry in group) {
|
|
val integrated = entry.optDouble("integrated_lufs", Double.NaN)
|
|
if (integrated.isNaN()) continue
|
|
val duration = entry.optDouble("duration_secs", 1.0).let { if (it > 0) it else 1.0 }
|
|
val peak = entry.optDouble("true_peak_linear", 1.0)
|
|
sumWeightedPower += 10.0.pow(integrated / 10.0) * duration
|
|
sumDuration += duration
|
|
if (peak > maxPeak) maxPeak = peak
|
|
}
|
|
if (sumDuration <= 0) continue
|
|
val albumLufs = 10.0 * kotlin.math.log10(sumWeightedPower / sumDuration)
|
|
val albumGainDb = -18.0 - albumLufs
|
|
val albumGain = "${if (albumGainDb >= 0) "+" else ""}${"%.2f".format(Locale.US, albumGainDb)} dB"
|
|
val albumPeak = "%.6f".format(Locale.US, if (maxPeak > 0) maxPeak else 1.0)
|
|
val fields = JSONObject()
|
|
.put("replaygain_album_gain", albumGain)
|
|
.put("replaygain_album_peak", albumPeak)
|
|
var wroteForAlbum = false
|
|
for (entry in group) {
|
|
val path = entry.optString("file_path", "")
|
|
if (path.isBlank()) continue
|
|
try {
|
|
writeReplayGainFields(context, path, fields)
|
|
filesWritten++
|
|
wroteForAlbum = true
|
|
} catch (e: Exception) {
|
|
android.util.Log.w("SpotiFLAC", "Failed to write native album ReplayGain: ${e.message}")
|
|
}
|
|
}
|
|
if (wroteForAlbum) albumsWritten++
|
|
}
|
|
|
|
return JSONObject()
|
|
.put("success", true)
|
|
.put("albums_written", albumsWritten)
|
|
.put("files_written", filesWritten)
|
|
.toString()
|
|
}
|
|
|
|
private fun parseObject(raw: String): JSONObject {
|
|
val trimmed = raw.trim()
|
|
if (trimmed.isEmpty()) return JSONObject()
|
|
return try {
|
|
JSONObject(trimmed)
|
|
} catch (_: Exception) {
|
|
JSONObject()
|
|
}
|
|
}
|
|
|
|
private fun currentStatus(@Suppress("UNUSED_PARAMETER") status: String) {
|
|
// Kept as a narrow hook for future richer progress snapshots.
|
|
}
|
|
|
|
private fun cleanupFailedFinalizationOutput(
|
|
context: Context,
|
|
result: JSONObject,
|
|
initialPath: String,
|
|
currentPath: String,
|
|
) {
|
|
if (result.optBoolean("already_exists", false)) return
|
|
|
|
val paths = linkedSetOf<String>()
|
|
if (initialPath.isNotBlank()) paths.add(initialPath)
|
|
if (currentPath.isNotBlank()) paths.add(currentPath)
|
|
val resultPath = result.optString("file_path", "").trim()
|
|
if (resultPath.isNotBlank()) paths.add(resultPath)
|
|
|
|
var cleanedAny = false
|
|
for (path in paths) {
|
|
cleanedAny = deleteFinalizerOwnedOutput(context, path) || cleanedAny
|
|
}
|
|
if (cleanedAny) {
|
|
result.put("native_finalizer_cleaned_output", true)
|
|
}
|
|
}
|
|
|
|
private fun deleteFinalizerOwnedOutput(context: Context, path: String): Boolean {
|
|
if (path.startsWith("content://")) {
|
|
return SafDownloadHandler.deleteContentUri(context, path)
|
|
}
|
|
|
|
return try {
|
|
val file = File(path)
|
|
if (!file.exists()) return false
|
|
|
|
val canonicalPath = file.canonicalPath
|
|
val appDataPath = File(context.applicationInfo.dataDir).canonicalPath
|
|
val cachePath = context.cacheDir.canonicalPath
|
|
if (!canonicalPath.startsWith("$appDataPath/") && !canonicalPath.startsWith("$cachePath/")) {
|
|
return false
|
|
}
|
|
file.delete()
|
|
} catch (_: Exception) {
|
|
false
|
|
}
|
|
}
|
|
|
|
private fun requestQuality(input: FinalizeInput): String {
|
|
return input.request.optString("quality", "").ifBlank {
|
|
input.item.optString("qualityOverride", "").ifBlank { "LOSSLESS" }
|
|
}
|
|
}
|
|
|
|
private fun outputExt(input: FinalizeInput): String {
|
|
val safExt = input.request.optString("saf_output_ext", "")
|
|
val ext = safExt.ifBlank { input.request.optString("output_ext", "") }
|
|
return normalizeExt(ext.ifBlank {
|
|
when (requestQuality(input)) {
|
|
"HIGH" -> ".mp3"
|
|
else -> ".flac"
|
|
}
|
|
})
|
|
}
|
|
|
|
private fun finalizeDecryption(
|
|
context: Context,
|
|
input: FinalizeInput,
|
|
state: FinalizeState,
|
|
shouldCancel: () -> Boolean,
|
|
) {
|
|
val descriptor = input.result.optJSONObject("decryption")
|
|
val key = descriptor?.optString("key", "")?.trim().orEmpty()
|
|
.ifBlank { input.result.optString("decryption_key", "").trim() }
|
|
if (key.isEmpty()) return
|
|
|
|
val inputFormat = descriptor?.optString("input_format", "")?.trim().orEmpty().ifBlank { "mov" }
|
|
val requestedOutputExt = descriptor?.optString("output_extension", "")?.trim().orEmpty()
|
|
val preferredExt = resolvePreferredDecryptionExtension(state.filePath, requestedOutputExt)
|
|
val localInput = materializeForFFmpeg(context, input, state)
|
|
val originalPath = localInput
|
|
|
|
var outputPath = buildOutputPath(localInput, preferredExt)
|
|
var successPath: String? = null
|
|
var lastOutput = ""
|
|
|
|
try {
|
|
for (candidate in decryptionKeyCandidates(key)) {
|
|
checkCancelled(shouldCancel)
|
|
val attempts = mutableListOf<Pair<String, Boolean>>()
|
|
attempts.add(outputPath to (preferredExt == ".flac"))
|
|
if (preferredExt == ".flac") {
|
|
attempts.add(buildOutputPath(localInput, ".m4a") to false)
|
|
}
|
|
if (preferredExt == ".flac" || preferredExt == ".m4a") {
|
|
attempts.add(buildOutputPath(localInput, ".mp4") to false)
|
|
}
|
|
|
|
for ((candidateOutput, mapAudioOnly) in attempts) {
|
|
try {
|
|
val audioMap = if (mapAudioOnly) "-map 0:a " else ""
|
|
// Force the flac muxer when the target extension is
|
|
// .flac. Without this override FFmpeg keeps the ISO-BMFF
|
|
// stream layout, producing FLAC-in-MP4 under a .flac
|
|
// filename which downstream native FLAC tag writers
|
|
// cannot read.
|
|
val muxerOverride = if (candidateOutput.lowercase(Locale.ROOT).endsWith(".flac")) "-f flac " else ""
|
|
val command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${muxerOverride}${q(candidateOutput)} -y"
|
|
val result = runFFmpeg(command, shouldCancel)
|
|
lastOutput = result.second
|
|
if (result.first && File(candidateOutput).exists()) {
|
|
successPath = candidateOutput
|
|
outputPath = candidateOutput
|
|
break
|
|
}
|
|
File(candidateOutput).delete()
|
|
} catch (e: CancellationException) {
|
|
File(candidateOutput).delete()
|
|
throw e
|
|
} catch (e: Exception) {
|
|
File(candidateOutput).delete()
|
|
throw e
|
|
}
|
|
}
|
|
if (successPath != null) break
|
|
}
|
|
|
|
val decryptedPath = successPath ?: throw IllegalStateException("decrypt failed: $lastOutput")
|
|
replaceStatePath(context, input, state, decryptedPath, deleteOld = true)
|
|
} finally {
|
|
if (successPath == null) {
|
|
File(outputPath).delete()
|
|
}
|
|
if (originalPath != successPath && originalPath.startsWith(context.cacheDir.absolutePath)) {
|
|
File(originalPath).delete()
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun finalizeHighConversion(
|
|
context: Context,
|
|
input: FinalizeInput,
|
|
state: FinalizeState,
|
|
shouldCancel: () -> Boolean,
|
|
) {
|
|
if (requestQuality(input) != "HIGH") return
|
|
if (!looksLikeM4a(state.filePath, state.fileName)) return
|
|
|
|
val tidalHighFormat = input.request.optString("tidal_high_format", "").ifBlank { "mp3_320" }
|
|
val format = if (tidalHighFormat.startsWith("opus")) "opus" else "mp3"
|
|
val bitrate = if (tidalHighFormat.contains("_")) {
|
|
"${tidalHighFormat.substringAfterLast("_")}k"
|
|
} else {
|
|
if (format == "opus") "128k" else "320k"
|
|
}
|
|
val ext = if (format == "opus") ".opus" else ".mp3"
|
|
val localInput = materializeForFFmpeg(context, input, state)
|
|
val deleteLocalInput = state.filePath.startsWith("content://")
|
|
val output = buildOutputPath(localInput, ext)
|
|
var adoptedOutput = false
|
|
try {
|
|
val command = if (format == "opus") {
|
|
"-v error -hide_banner -i ${q(localInput)} -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a ${q(output)} -y"
|
|
} else {
|
|
"-v error -hide_banner -i ${q(localInput)} -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 ${q(output)} -y"
|
|
}
|
|
val result = runFFmpeg(command, shouldCancel)
|
|
if (!result.first || !File(output).exists()) {
|
|
throw IllegalStateException("HIGH conversion failed: ${result.second}")
|
|
}
|
|
embedBasicMetadata(context, output, input, format)
|
|
replaceStatePath(context, input, state, output, deleteOld = true)
|
|
adoptedOutput = true
|
|
} finally {
|
|
if (!adoptedOutput) File(output).delete()
|
|
if (deleteLocalInput) File(localInput).delete()
|
|
}
|
|
state.quality = "${format.uppercase(Locale.ROOT)} ${bitrate.removeSuffix("k")}kbps"
|
|
state.bitDepth = null
|
|
state.sampleRate = null
|
|
}
|
|
|
|
private fun finalizeContainerConversion(
|
|
context: Context,
|
|
input: FinalizeInput,
|
|
state: FinalizeState,
|
|
shouldCancel: () -> Boolean,
|
|
) {
|
|
if (requestQuality(input) == "HIGH" || outputExt(input) != ".flac") return
|
|
val requestedDecryptionExt = requestedDecryptionOutputExt(input)
|
|
if (requestedDecryptionExt.isNotBlank() && requestedDecryptionExt != ".flac") return
|
|
val mayNeedContainerConversion = shouldForceContainerConversion(input, state) ||
|
|
looksLikeM4a(state.filePath, state.fileName) ||
|
|
state.filePath.startsWith("content://")
|
|
if (!mayNeedContainerConversion) return
|
|
|
|
val localInput = materializeForFFmpeg(context, input, state)
|
|
val deleteLocalInput = state.filePath.startsWith("content://")
|
|
val output = buildOutputPath(localInput, ".flac")
|
|
var adoptedOutput = false
|
|
try {
|
|
val codec = probePrimaryAudioCodec(localInput, shouldCancel)
|
|
val isAlreadyNativeFlac = codec == "flac" && isNativeFlacFile(localInput)
|
|
if (!isLosslessAudioCodec(codec) || isAlreadyNativeFlac) {
|
|
val suffix = if (isAlreadyNativeFlac) " (native FLAC)" else ""
|
|
Log.d(TAG, "Preserving native container; audio codec is ${codec.ifBlank { "unknown" }}$suffix")
|
|
return
|
|
}
|
|
val result = runFFmpeg(
|
|
"-v error -xerror -i ${q(localInput)} -c:a flac -compression_level 8 ${q(output)} -y",
|
|
shouldCancel,
|
|
)
|
|
if (!result.first || !File(output).exists()) {
|
|
throw IllegalStateException("container conversion failed: ${result.second}")
|
|
}
|
|
embedBasicMetadata(context, output, input, "flac")
|
|
replaceStatePath(context, input, state, output, deleteOld = true)
|
|
adoptedOutput = true
|
|
} finally {
|
|
if (!adoptedOutput) File(output).delete()
|
|
if (deleteLocalInput) File(localInput).delete()
|
|
}
|
|
}
|
|
|
|
private fun finalizeMetadata(context: Context, input: FinalizeInput, state: FinalizeState) {
|
|
if (!input.request.optBoolean("embed_metadata", false)) return
|
|
if (!state.filePath.startsWith("content://")) {
|
|
embedBasicMetadata(context, state.filePath, input, formatForPath(state.filePath))
|
|
return
|
|
}
|
|
|
|
val tempPath = SafDownloadHandler.copyContentUriToTemp(context, state.filePath)
|
|
?: throw IllegalStateException("failed to copy SAF file for metadata")
|
|
try {
|
|
embedBasicMetadata(context, tempPath, input, formatForPath(state.fileName.ifBlank { tempPath }))
|
|
val tempFile = File(tempPath)
|
|
val finalName = desiredFileName(input, state, normalizeExt(state.fileName.substringAfterLast('.', "")))
|
|
val newUri = SafDownloadHandler.writeFileToSaf(
|
|
context = context,
|
|
treeUriStr = input.request.optString("saf_tree_uri", ""),
|
|
relativeDir = input.request.optString("saf_relative_dir", ""),
|
|
fileName = finalName,
|
|
mimeType = mimeTypeForExt(finalName.substringAfterLast('.', "")),
|
|
srcPath = tempFile.absolutePath,
|
|
) ?: throw IllegalStateException("failed to write metadata-updated SAF file")
|
|
if (newUri != state.filePath) SafDownloadHandler.deleteContentUri(context, state.filePath)
|
|
state.filePath = newUri
|
|
state.fileName = finalName
|
|
} finally {
|
|
File(tempPath).delete()
|
|
}
|
|
}
|
|
|
|
private fun writeReplayGain(
|
|
context: Context,
|
|
input: FinalizeInput,
|
|
state: FinalizeState,
|
|
shouldCancel: () -> Boolean,
|
|
): JSONObject? {
|
|
if (!input.request.optBoolean("embed_replaygain", false)) return null
|
|
val ext = normalizeExt(File(state.filePath).extension)
|
|
val fileExt = if (state.filePath.startsWith("content://")) {
|
|
normalizeExt(state.fileName.substringAfterLast('.', ""))
|
|
} else {
|
|
ext
|
|
}
|
|
if (fileExt != ".flac" && fileExt != ".m4a" && fileExt != ".mp4") return null
|
|
|
|
val scanPath = if (state.filePath.startsWith("content://")) {
|
|
SafDownloadHandler.copyContentUriToTemp(context, state.filePath)
|
|
?: throw IllegalStateException("failed to copy SAF file for ReplayGain")
|
|
} else {
|
|
state.filePath
|
|
}
|
|
val deleteScanPath = scanPath != state.filePath
|
|
val scan = try {
|
|
scanReplayGain(scanPath, shouldCancel) ?: return null
|
|
} finally {
|
|
if (deleteScanPath) File(scanPath).delete()
|
|
}
|
|
checkCancelled(shouldCancel)
|
|
val fields = JSONObject()
|
|
.put("replaygain_track_gain", scan.trackGain)
|
|
.put("replaygain_track_peak", scan.trackPeak)
|
|
writeReplayGainFields(context, state.filePath, fields)
|
|
|
|
return JSONObject()
|
|
.put("album_key", albumKey(input))
|
|
.put("file_path", state.filePath)
|
|
.put("file_name", state.fileName)
|
|
.put("track_id", trackString(input, "id", input.request.optString("spotify_id", input.itemId)))
|
|
.put("integrated_lufs", scan.integratedLufs)
|
|
.put("true_peak_linear", scan.truePeakLinear)
|
|
.put("duration_secs", replayGainDurationSeconds(input))
|
|
.put("track_gain", scan.trackGain)
|
|
.put("track_peak", scan.trackPeak)
|
|
}
|
|
|
|
private fun writeReplayGainFields(context: Context, path: String, fields: JSONObject) {
|
|
if (!path.startsWith("content://")) {
|
|
Gobackend.editFileMetadata(path, fields.toString())
|
|
return
|
|
}
|
|
|
|
val tempPath = SafDownloadHandler.copyContentUriToTemp(context, path)
|
|
?: throw IllegalStateException("failed to copy SAF file for ReplayGain write")
|
|
try {
|
|
Gobackend.editFileMetadata(tempPath, fields.toString())
|
|
val uri = Uri.parse(path)
|
|
context.contentResolver.openOutputStream(uri, "wt")?.use { output ->
|
|
File(tempPath).inputStream().use { input -> input.copyTo(output) }
|
|
} ?: throw IllegalStateException("failed to write ReplayGain back to SAF")
|
|
} finally {
|
|
File(tempPath).delete()
|
|
}
|
|
}
|
|
|
|
private fun refreshFinalAudioQualityMetadata(context: Context, result: JSONObject, state: FinalizeState) {
|
|
if (!supportsAudioMetadataProbe(state.filePath, state.fileName)) return
|
|
|
|
val probePath = if (state.filePath.startsWith("content://")) {
|
|
SafDownloadHandler.copyContentUriToTemp(context, state.filePath) ?: return
|
|
} else {
|
|
state.filePath
|
|
}
|
|
val deleteProbePath = probePath != state.filePath
|
|
|
|
try {
|
|
val metadata = parseObject(Gobackend.readFileMetadata(probePath))
|
|
if (metadata.has("error")) return
|
|
|
|
val bitDepth = optPositiveInt(metadata, "bit_depth")
|
|
val sampleRate = optPositiveInt(metadata, "sample_rate")
|
|
if (bitDepth != null) {
|
|
state.bitDepth = bitDepth
|
|
result.put("actual_bit_depth", bitDepth)
|
|
}
|
|
if (sampleRate != null) {
|
|
state.sampleRate = sampleRate
|
|
result.put("actual_sample_rate", sampleRate)
|
|
}
|
|
val bitrateKbps = optPositiveBitrateKbps(metadata, "bitrate")
|
|
?: optPositiveBitrateKbps(metadata, "bit_rate")
|
|
if (bitrateKbps != null) {
|
|
state.bitrateKbps = bitrateKbps
|
|
result.put("bitrate", bitrateKbps)
|
|
}
|
|
|
|
val displayQuality = displayAudioQuality(
|
|
filePath = state.filePath,
|
|
fileName = state.fileName,
|
|
bitDepth = state.bitDepth,
|
|
sampleRate = state.sampleRate,
|
|
bitrateKbps = state.bitrateKbps,
|
|
storedQuality = state.quality,
|
|
)
|
|
if (displayQuality != null) {
|
|
state.quality = displayQuality
|
|
}
|
|
} catch (_: Exception) {
|
|
} finally {
|
|
if (deleteProbePath) File(probePath).delete()
|
|
}
|
|
}
|
|
|
|
private fun supportsAudioMetadataProbe(filePath: String, fileName: String): Boolean {
|
|
val lowerPath = filePath.trim().lowercase(Locale.ROOT)
|
|
val lowerName = fileName.trim().lowercase(Locale.ROOT)
|
|
if (lowerPath.startsWith("content://")) return true
|
|
return lowerPath.endsWith(".flac") ||
|
|
lowerPath.endsWith(".m4a") ||
|
|
lowerPath.endsWith(".mp4") ||
|
|
lowerPath.endsWith(".aac") ||
|
|
lowerPath.endsWith(".mp3") ||
|
|
lowerPath.endsWith(".opus") ||
|
|
lowerPath.endsWith(".ogg") ||
|
|
lowerName.endsWith(".flac") ||
|
|
lowerName.endsWith(".m4a") ||
|
|
lowerName.endsWith(".mp4") ||
|
|
lowerName.endsWith(".aac") ||
|
|
lowerName.endsWith(".mp3") ||
|
|
lowerName.endsWith(".opus") ||
|
|
lowerName.endsWith(".ogg")
|
|
}
|
|
|
|
private fun displayAudioQuality(
|
|
filePath: String,
|
|
fileName: String,
|
|
bitDepth: Int?,
|
|
sampleRate: Int?,
|
|
bitrateKbps: Int?,
|
|
storedQuality: String?,
|
|
): String? {
|
|
val format = audioFormatForPath(filePath, fileName)
|
|
if (format == "OPUS" ||
|
|
format == "MP3" ||
|
|
format == "AAC" ||
|
|
(format == "M4A" && (bitDepth == null || bitDepth <= 0))
|
|
) {
|
|
return if (bitrateKbps != null && bitrateKbps > 0) {
|
|
"$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 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 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", "")
|
|
if (lyricsMode != "external" && lyricsMode != "both") return
|
|
val lrc = resolveLyricsLrc(input)
|
|
if (lrc.isBlank() || lrc == "[instrumental:true]") return
|
|
val audioFileName = if (isDeferredSafRequest(input)) {
|
|
desiredFileName(input, state, File(state.filePath).extension)
|
|
} else {
|
|
state.fileName
|
|
}
|
|
val baseName = audioFileName.replace(Regex("\\.[^.]+$"), "")
|
|
if (isDeferredSafRequest(input)) {
|
|
state.pendingExternalLrc = lrc
|
|
state.pendingExternalLrcFileName = "$baseName.lrc"
|
|
return
|
|
}
|
|
if (state.filePath.startsWith("content://")) {
|
|
val treeUri = input.request.optString("saf_tree_uri", "")
|
|
val relativeDir = input.request.optString("saf_relative_dir", "")
|
|
val temp = File(context.cacheDir, "native_lrc_${System.nanoTime()}.lrc")
|
|
temp.writeText(lrc)
|
|
try {
|
|
SafDownloadHandler.writeFileToSaf(
|
|
context = context,
|
|
treeUriStr = treeUri,
|
|
relativeDir = relativeDir,
|
|
fileName = "$baseName.lrc",
|
|
mimeType = "application/octet-stream",
|
|
srcPath = temp.absolutePath,
|
|
)
|
|
} finally {
|
|
temp.delete()
|
|
}
|
|
} else {
|
|
val target = File(File(state.filePath).parentFile, "$baseName.lrc")
|
|
target.writeText(lrc)
|
|
}
|
|
}
|
|
|
|
private fun resolveLyricsLrc(input: FinalizeInput): String {
|
|
val existing = input.result.optString("lyrics_lrc", "").trim()
|
|
if (existing.isNotEmpty()) return existing
|
|
|
|
val spotifyId = trackString(input, "id", input.request.optString("spotify_id", ""))
|
|
val trackName = trackString(input, "name", input.request.optString("track_name", ""))
|
|
val artistName = trackString(input, "artistName", input.request.optString("artist_name", ""))
|
|
if (trackName.isBlank() || artistName.isBlank()) return ""
|
|
|
|
return try {
|
|
val fetched = Gobackend.getLyricsLRC(
|
|
spotifyId,
|
|
trackName,
|
|
artistName,
|
|
"",
|
|
lyricsDurationMs(input),
|
|
).trim()
|
|
if (fetched.isNotEmpty()) {
|
|
input.result.put("lyrics_lrc", fetched)
|
|
}
|
|
fetched
|
|
} catch (_: Exception) {
|
|
""
|
|
}
|
|
}
|
|
|
|
private fun lyricsDurationMs(input: FinalizeInput): Long {
|
|
val requestDuration = input.request.optLong("duration_ms", 0L)
|
|
val trackDuration = trackInt(input, "duration", 0).toLong()
|
|
val duration = if (requestDuration > 0L) requestDuration else trackDuration
|
|
if (duration <= 0L) return 0L
|
|
return if (duration > 10000L) duration else duration * 1000L
|
|
}
|
|
|
|
private fun runPostProcessing(
|
|
context: Context,
|
|
input: FinalizeInput,
|
|
state: FinalizeState,
|
|
shouldCancel: () -> Boolean,
|
|
) {
|
|
if (!input.request.optBoolean("post_processing_enabled", false)) return
|
|
val metadata = JSONObject()
|
|
.put("title", trackString(input, "name", input.request.optString("track_name", "")))
|
|
.put("artist", trackString(input, "artistName", input.request.optString("artist_name", "")))
|
|
.put("album", trackString(input, "albumName", input.request.optString("album_name", "")))
|
|
.put("album_artist", trackString(input, "albumArtist", input.request.optString("album_artist", "")))
|
|
.put("track_number", trackInt(input, "trackNumber", input.request.optInt("track_number", 0)))
|
|
.put("disc_number", trackInt(input, "discNumber", input.request.optInt("disc_number", 0)))
|
|
.put("isrc", trackString(input, "isrc", input.request.optString("isrc", "")))
|
|
.put("release_date", trackString(input, "releaseDate", input.request.optString("release_date", "")))
|
|
.put("duration_ms", trackInt(input, "duration", 0) * 1000)
|
|
.put("cover_url", metadataCoverUrl(input))
|
|
|
|
if (state.filePath.startsWith("content://")) {
|
|
val uri = state.filePath
|
|
val tempInput = SafDownloadHandler.copyContentUriToTemp(context, uri)
|
|
?: throw IllegalStateException("failed to copy SAF file for post-processing")
|
|
try {
|
|
val inputObj = JSONObject()
|
|
.put("path", tempInput)
|
|
.put("uri", uri)
|
|
.put("name", state.fileName)
|
|
.put("mime_type", mimeTypeForExt(state.fileName.substringAfterLast('.', "")))
|
|
.put("size", File(tempInput).length())
|
|
.put("is_saf", true)
|
|
val response = JSONObject(
|
|
withFFmpegCommandPump(shouldCancel) {
|
|
checkCancelled(shouldCancel)
|
|
Gobackend.runPostProcessingV2JSON(inputObj.toString(), metadata.toString())
|
|
}
|
|
)
|
|
checkCancelled(shouldCancel)
|
|
if (!response.optBoolean("success", false)) return
|
|
val newPath = response.optString("new_file_path", "")
|
|
val outputPath = newPath.ifBlank { tempInput }
|
|
val outputFile = File(outputPath)
|
|
if (!outputFile.exists()) return
|
|
val outputName = if (newPath.isBlank()) state.fileName else outputFile.name
|
|
val newUri = SafDownloadHandler.writeFileToSaf(
|
|
context = context,
|
|
treeUriStr = input.request.optString("saf_tree_uri", ""),
|
|
relativeDir = input.request.optString("saf_relative_dir", ""),
|
|
fileName = outputName,
|
|
mimeType = mimeTypeForExt(outputFile.extension),
|
|
srcPath = outputFile.absolutePath,
|
|
) ?: return
|
|
if (newUri != uri) SafDownloadHandler.deleteContentUri(context, uri)
|
|
state.filePath = newUri
|
|
state.fileName = outputName
|
|
if (outputPath != tempInput) outputFile.delete()
|
|
} finally {
|
|
File(tempInput).delete()
|
|
}
|
|
return
|
|
}
|
|
|
|
val inputObj = JSONObject()
|
|
.put("path", state.filePath)
|
|
.put("name", state.fileName)
|
|
.put("is_saf", false)
|
|
val response = JSONObject(
|
|
withFFmpegCommandPump(shouldCancel) {
|
|
checkCancelled(shouldCancel)
|
|
Gobackend.runPostProcessingV2JSON(inputObj.toString(), metadata.toString())
|
|
}
|
|
)
|
|
checkCancelled(shouldCancel)
|
|
if (response.optBoolean("success", false)) {
|
|
val newPath = response.optString("new_file_path", "")
|
|
if (newPath.isNotBlank() && newPath != state.filePath) {
|
|
state.filePath = newPath
|
|
state.fileName = File(newPath).name
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun materializeForFFmpeg(context: Context, input: FinalizeInput, state: FinalizeState): String {
|
|
if (!state.filePath.startsWith("content://")) return state.filePath
|
|
return SafDownloadHandler.copyContentUriToTemp(context, state.filePath)
|
|
?: throw IllegalStateException("failed to copy SAF file")
|
|
}
|
|
|
|
private fun replaceStatePath(
|
|
context: Context,
|
|
input: FinalizeInput,
|
|
state: FinalizeState,
|
|
localOutput: String,
|
|
deleteOld: Boolean,
|
|
) {
|
|
if (state.filePath.startsWith("content://")) {
|
|
val outputFile = File(localOutput)
|
|
val finalName = desiredFileName(input, state, outputFile.extension)
|
|
val newUri = SafDownloadHandler.writeFileToSaf(
|
|
context = context,
|
|
treeUriStr = input.request.optString("saf_tree_uri", ""),
|
|
relativeDir = input.request.optString("saf_relative_dir", ""),
|
|
fileName = finalName,
|
|
mimeType = mimeTypeForExt(outputFile.extension),
|
|
srcPath = outputFile.absolutePath,
|
|
) ?: throw IllegalStateException("failed to write finalized file to SAF")
|
|
SafDownloadHandler.deleteContentUri(context, state.filePath)
|
|
state.filePath = newUri
|
|
state.fileName = finalName
|
|
outputFile.delete()
|
|
return
|
|
}
|
|
|
|
val oldPath = state.filePath
|
|
state.filePath = localOutput
|
|
state.fileName = File(localOutput).name
|
|
if (deleteOld && oldPath != localOutput) File(oldPath).delete()
|
|
}
|
|
|
|
private fun embedBasicMetadata(context: Context, path: String, input: FinalizeInput, format: String) {
|
|
if (!input.request.optBoolean("embed_metadata", false)) return
|
|
val title = resultString(input, "title").ifBlank {
|
|
trackString(input, "name", requestString(input, "track_name"))
|
|
}
|
|
val artist = resultString(input, "artist").ifBlank {
|
|
trackString(input, "artistName", requestString(input, "artist_name"))
|
|
}
|
|
val album = resultString(input, "album").ifBlank {
|
|
trackString(input, "albumName", requestString(input, "album_name"))
|
|
}
|
|
val albumArtist = resultString(input, "album_artist").ifBlank {
|
|
trackString(input, "albumArtist", requestString(input, "album_artist"))
|
|
}
|
|
val date = resultString(input, "release_date").ifBlank {
|
|
resultString(input, "date").ifBlank {
|
|
trackString(input, "releaseDate", requestString(input, "release_date"))
|
|
}
|
|
}
|
|
val trackNumberValue = positiveOrNull(input.result.optInt("track_number", 0), trackInt(input, "trackNumber", input.request.optInt("track_number", 0))) ?: 0
|
|
val totalTracksValue = positiveOrNull(input.result.optInt("total_tracks", 0), trackInt(input, "totalTracks", input.request.optInt("total_tracks", 0))) ?: 0
|
|
val discNumberValue = positiveOrNull(input.result.optInt("disc_number", 0), trackInt(input, "discNumber", input.request.optInt("disc_number", 0))) ?: 0
|
|
val totalDiscsValue = positiveOrNull(input.result.optInt("total_discs", 0), trackInt(input, "totalDiscs", input.request.optInt("total_discs", 0))) ?: 0
|
|
val trackNumber = formatIndexTag(trackNumberValue, totalTracksValue)
|
|
val discNumber = formatIndexTag(discNumberValue, totalDiscsValue)
|
|
val isrc = resultString(input, "isrc").ifBlank {
|
|
trackString(input, "isrc", requestString(input, "isrc"))
|
|
}
|
|
val composer = resultString(input, "composer").ifBlank {
|
|
trackString(input, "composer", requestString(input, "composer"))
|
|
}
|
|
val genre = resultString(input, "genre").ifBlank { requestString(input, "genre") }
|
|
val label = resultString(input, "label").ifBlank { requestString(input, "label") }
|
|
val copyright = resultString(input, "copyright").ifBlank { requestString(input, "copyright") }
|
|
val lyrics = resolveLyricsLrc(input)
|
|
val shouldEmbedLyrics = input.request.optBoolean("embed_lyrics", false) &&
|
|
(input.request.optString("lyrics_mode", "embed") == "embed" ||
|
|
input.request.optString("lyrics_mode", "embed") == "both") &&
|
|
lyrics.isNotBlank() &&
|
|
lyrics != "[instrumental:true]"
|
|
if (format == "flac") {
|
|
val coverFile = downloadCoverForMetadata(context, input)
|
|
val fields = JSONObject()
|
|
.put("title", title)
|
|
.put("artist", artist)
|
|
.put("album", album)
|
|
.put("album_artist", albumArtist)
|
|
.put("date", date)
|
|
.put("isrc", isrc)
|
|
.put("composer", composer)
|
|
.put("genre", genre)
|
|
.put("label", label)
|
|
.put("copyright", copyright)
|
|
if (trackNumberValue > 0) fields.put("track_number", trackNumberValue.toString())
|
|
if (totalTracksValue > 0) fields.put("track_total", totalTracksValue.toString())
|
|
if (discNumberValue > 0) fields.put("disc_number", discNumberValue.toString())
|
|
if (totalDiscsValue > 0) fields.put("disc_total", totalDiscsValue.toString())
|
|
if (coverFile != null) fields.put("cover_path", coverFile.absolutePath)
|
|
if (shouldEmbedLyrics) {
|
|
fields.put("lyrics", lyrics)
|
|
fields.put("unsyncedlyrics", lyrics)
|
|
}
|
|
try {
|
|
Gobackend.editFileMetadata(path, fields.toString())
|
|
} finally {
|
|
coverFile?.delete()
|
|
}
|
|
return
|
|
}
|
|
|
|
val ext = normalizeExt(File(path).extension).ifBlank { ".tmp" }
|
|
val inputFile = File(path)
|
|
val temp = File(inputFile.parentFile, "${inputFile.nameWithoutExtension}_tagged$ext")
|
|
val isM4a = format == "m4a"
|
|
val isOpus = format == "opus"
|
|
val coverFile = if (isM4a || isOpus) downloadCoverForMetadata(context, input) else null
|
|
val labelKey = if (isM4a) "organization" else "label"
|
|
val metadataPairs = mutableListOf(
|
|
"title" to title,
|
|
"artist" to artist,
|
|
"album" to album,
|
|
"album_artist" to albumArtist,
|
|
"date" to date,
|
|
"track" to trackNumber,
|
|
"disc" to discNumber,
|
|
"isrc" to isrc,
|
|
"composer" to composer,
|
|
"genre" to genre,
|
|
labelKey to label,
|
|
"copyright" to copyright,
|
|
"lyrics" to if (shouldEmbedLyrics) lyrics else "",
|
|
"unsyncedlyrics" to if (shouldEmbedLyrics) lyrics else "",
|
|
)
|
|
if (isOpus && coverFile != null) {
|
|
createMetadataBlockPicture(coverFile)?.let {
|
|
metadataPairs.add("METADATA_BLOCK_PICTURE" to it)
|
|
}
|
|
}
|
|
val metadataArgs = metadataPairs
|
|
.filter { it.second.isNotBlank() && it.second != "0" }
|
|
.joinToString(" ") { "-metadata ${it.first}=${q(it.second)}" }
|
|
if (metadataArgs.isBlank() && coverFile == null) return
|
|
val mp3Flags = if (format == "mp3") "-id3v2_version 3 " else ""
|
|
var adoptedTemp = false
|
|
var originalDeleted = false
|
|
try {
|
|
val command = if (isM4a && coverFile != null) {
|
|
"-v error -hide_banner -i ${q(path)} -i ${q(coverFile.absolutePath)} " +
|
|
"-map 0:a -c:a copy -map_metadata 0 -map 1:v -c:v copy " +
|
|
"-disposition:v:0 attached_pic " +
|
|
"-metadata:s:v ${q("title=Album cover")} " +
|
|
"-metadata:s:v ${q("comment=Cover (front)")} " +
|
|
"$metadataArgs -f mp4 ${q(temp.absolutePath)} -y"
|
|
} else {
|
|
"-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags${q(temp.absolutePath)} -y"
|
|
}
|
|
val result = runFFmpeg(command)
|
|
if (result.first && temp.exists()) {
|
|
if (inputFile.delete()) {
|
|
originalDeleted = true
|
|
adoptedTemp = temp.renameTo(inputFile)
|
|
}
|
|
}
|
|
} finally {
|
|
if (!adoptedTemp && !originalDeleted) {
|
|
temp.delete()
|
|
}
|
|
coverFile?.delete()
|
|
}
|
|
}
|
|
|
|
private fun createMetadataBlockPicture(coverFile: File): String? {
|
|
return try {
|
|
if (!coverFile.exists() || coverFile.length() <= 0L) return null
|
|
val imageData = coverFile.readBytes()
|
|
if (imageData.isEmpty()) return null
|
|
val mimeType = detectCoverMimeType(coverFile, imageData)
|
|
val mimeBytes = mimeType.toByteArray(Charsets.UTF_8)
|
|
val descriptionBytes = ByteArray(0)
|
|
val blockSize = 4 + 4 + mimeBytes.size + 4 + descriptionBytes.size + 4 + 4 + 4 + 4 + 4 + imageData.size
|
|
val buffer = ByteBuffer.allocate(blockSize)
|
|
buffer.putInt(3)
|
|
buffer.putInt(mimeBytes.size)
|
|
buffer.put(mimeBytes)
|
|
buffer.putInt(descriptionBytes.size)
|
|
buffer.put(descriptionBytes)
|
|
buffer.putInt(0)
|
|
buffer.putInt(0)
|
|
buffer.putInt(0)
|
|
buffer.putInt(0)
|
|
buffer.putInt(imageData.size)
|
|
buffer.put(imageData)
|
|
Base64.encodeToString(buffer.array(), Base64.NO_WRAP)
|
|
} catch (e: Exception) {
|
|
Log.w(TAG, "Failed to create Opus cover picture block: ${e.message}")
|
|
null
|
|
}
|
|
}
|
|
|
|
private fun detectCoverMimeType(coverFile: File, imageData: ByteArray): String {
|
|
val ext = coverFile.extension.lowercase(Locale.ROOT)
|
|
if (ext == "png") return "image/png"
|
|
if (ext == "jpg" || ext == "jpeg") return "image/jpeg"
|
|
if (imageData.size >= 8 &&
|
|
imageData[0] == 0x89.toByte() &&
|
|
imageData[1] == 0x50.toByte() &&
|
|
imageData[2] == 0x4E.toByte() &&
|
|
imageData[3] == 0x47.toByte()
|
|
) {
|
|
return "image/png"
|
|
}
|
|
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
|
|
|
|
val safeItemId = input.itemId.ifBlank { "item" }.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
|
val output = File.createTempFile("native_cover_${safeItemId}_", ".jpg", context.cacheDir)
|
|
return try {
|
|
Gobackend.downloadCoverToFile(
|
|
coverUrl,
|
|
output.absolutePath,
|
|
input.request.optBoolean("embed_max_quality_cover", true)
|
|
)
|
|
if (output.exists() && output.length() > 0L) {
|
|
output
|
|
} else {
|
|
output.delete()
|
|
null
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.w(TAG, "Failed to download metadata cover: ${e.message}")
|
|
output.delete()
|
|
null
|
|
}
|
|
}
|
|
|
|
private fun formatForPath(path: String): String {
|
|
return when (normalizeExt(File(path).extension)) {
|
|
".mp3" -> "mp3"
|
|
".opus", ".ogg" -> "opus"
|
|
".m4a", ".mp4" -> "m4a"
|
|
else -> "flac"
|
|
}
|
|
}
|
|
|
|
private fun scanReplayGain(path: String, shouldCancel: () -> Boolean = { false }): ReplayGainScan? {
|
|
val command = "-hide_banner -nostats -i ${q(path)} -filter_complex ebur128=peak=true:framelog=quiet -f null -"
|
|
val result = runFFmpeg(command, shouldCancel)
|
|
val output = result.second
|
|
val integrated = Regex("I:\\s+(-?\\d+\\.?\\d*)\\s+LUFS")
|
|
.findAll(output)
|
|
.lastOrNull()
|
|
?.groupValues
|
|
?.getOrNull(1)
|
|
?.toDoubleOrNull() ?: return null
|
|
val truePeak = Regex("Peak:\\s+(-?\\d+\\.?\\d*)\\s+dBFS")
|
|
.findAll(output)
|
|
.mapNotNull { it.groupValues.getOrNull(1)?.toDoubleOrNull() }
|
|
.maxOrNull()
|
|
val gain = -18.0 - integrated
|
|
val peak = if (truePeak != null) 10.0.pow(truePeak / 20.0) else 1.0
|
|
return ReplayGainScan(
|
|
trackGain = "${if (gain >= 0) "+" else ""}${"%.2f".format(Locale.US, gain)} dB",
|
|
trackPeak = "%.6f".format(Locale.US, peak),
|
|
integratedLufs = integrated,
|
|
truePeakLinear = peak,
|
|
)
|
|
}
|
|
|
|
private fun runFFmpeg(command: String, shouldCancel: () -> Boolean = { false }): Pair<Boolean, String> {
|
|
checkCancelled(shouldCancel)
|
|
installNativeFFmpegCallbackFilter()
|
|
val latch = CountDownLatch(1)
|
|
var completedSession: FFmpegSession? = null
|
|
val session = FFmpegSession.create(
|
|
FFmpegKitConfig.parseArguments(command),
|
|
{ finishedSession ->
|
|
completedSession = finishedSession
|
|
latch.countDown()
|
|
},
|
|
null,
|
|
null,
|
|
LogRedirectionStrategy.NEVER_PRINT_LOGS,
|
|
)
|
|
val sessionId = session.sessionId
|
|
synchronized(activeFFmpegSessionLock) {
|
|
activeFFmpegSessionIds.add(sessionId)
|
|
nativeFFmpegSessionIds.add(sessionId)
|
|
}
|
|
FFmpegKitConfig.asyncFFmpegExecute(session)
|
|
try {
|
|
var cancelRequested = false
|
|
while (!latch.await(200, TimeUnit.MILLISECONDS)) {
|
|
if (shouldCancel()) {
|
|
cancelRequested = true
|
|
try {
|
|
FFmpegKit.cancel(sessionId)
|
|
} catch (_: Exception) {
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if (cancelRequested) {
|
|
latch.await(5, TimeUnit.SECONDS)
|
|
throw CancellationException("Native FFmpeg session cancelled")
|
|
}
|
|
val finalSession = completedSession ?: session
|
|
val output = finalSession.getAllLogsAsString(1000) ?: ""
|
|
checkCancelled(shouldCancel)
|
|
return ReturnCode.isSuccess(finalSession.returnCode) to output
|
|
} finally {
|
|
synchronized(activeFFmpegSessionLock) {
|
|
activeFFmpegSessionIds.remove(sessionId)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun installNativeFFmpegCallbackFilter() {
|
|
synchronized(ffmpegCompleteCallbackLock) {
|
|
val current = FFmpegKitConfig.getFFmpegSessionCompleteCallback()
|
|
if (current !== nativeFilteringFFmpegCompleteCallback) {
|
|
forwardedFFmpegCompleteCallback = current
|
|
FFmpegKitConfig.enableFFmpegSessionCompleteCallback(nativeFilteringFFmpegCompleteCallback)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun withFFmpegCommandPump(
|
|
shouldCancel: () -> Boolean = { false },
|
|
block: () -> String,
|
|
): String {
|
|
val running = AtomicBoolean(true)
|
|
val handled = mutableSetOf<String>()
|
|
val pump = Thread {
|
|
while (running.get()) {
|
|
if (shouldCancel()) return@Thread
|
|
try {
|
|
val raw = Gobackend.getAllPendingFFmpegCommandsJSON()
|
|
val commands = org.json.JSONArray(raw)
|
|
for (index in 0 until commands.length()) {
|
|
val command = commands.optJSONObject(index) ?: continue
|
|
val id = command.optString("command_id", "")
|
|
val commandLine = command.optString("command", "")
|
|
if (id.isBlank() || commandLine.isBlank() || handled.contains(id)) {
|
|
continue
|
|
}
|
|
handled.add(id)
|
|
val result = runFFmpeg(commandLine, shouldCancel)
|
|
Gobackend.setFFmpegCommandResultByID(
|
|
id,
|
|
result.first,
|
|
result.second,
|
|
if (result.first) "" else result.second,
|
|
)
|
|
}
|
|
} catch (_: Exception) {
|
|
}
|
|
try {
|
|
Thread.sleep(100)
|
|
} catch (_: InterruptedException) {
|
|
return@Thread
|
|
}
|
|
}
|
|
}
|
|
pump.isDaemon = true
|
|
pump.start()
|
|
return try {
|
|
block()
|
|
} finally {
|
|
running.set(false)
|
|
pump.interrupt()
|
|
}
|
|
}
|
|
|
|
private fun buildOutputPath(inputPath: String, extension: String): String {
|
|
val ext = normalizeExt(extension).ifBlank { ".tmp" }
|
|
val file = File(inputPath)
|
|
val base = file.nameWithoutExtension.ifBlank { "track" }
|
|
val candidate = File(file.parentFile, "$base$ext").absolutePath
|
|
if (candidate != inputPath) return candidate
|
|
return File(file.parentFile, "${base}_converted$ext").absolutePath
|
|
}
|
|
|
|
private fun desiredFileName(input: FinalizeInput, state: FinalizeState, extension: String): String {
|
|
val ext = normalizeExt(extension).ifBlank { normalizeExt(File(state.fileName).extension).ifBlank { ".flac" } }
|
|
val rawName = input.request.optString("saf_file_name", "")
|
|
.ifBlank { state.fileName }
|
|
.ifBlank { "${trackString(input, "artistName", input.request.optString("artist_name", "Artist"))} - ${trackString(input, "name", input.request.optString("track_name", "Track"))}" }
|
|
val knownExts = listOf(".flac", ".m4a", ".mp4", ".mp3", ".opus", ".ogg", ".lrc")
|
|
var base = rawName.trim()
|
|
val lower = base.lowercase(Locale.ROOT)
|
|
for (knownExt in knownExts) {
|
|
if (lower.endsWith(knownExt)) {
|
|
base = base.dropLast(knownExt.length)
|
|
break
|
|
}
|
|
}
|
|
base = base
|
|
.replace("/", " ")
|
|
.replace(Regex("[\\\\:*?\"<>|]"), " ")
|
|
.trim()
|
|
.trim('.', ' ')
|
|
.ifBlank { "track" }
|
|
return "$base$ext"
|
|
}
|
|
|
|
private fun shouldForceContainerConversion(input: FinalizeInput, state: FinalizeState): Boolean {
|
|
if (input.result.optBoolean("requires_container_conversion", false)) return true
|
|
if (input.request.optBoolean("requires_container_conversion", false)) return true
|
|
return false
|
|
}
|
|
|
|
private fun probePrimaryAudioCodec(path: String, shouldCancel: () -> Boolean = { false }): String {
|
|
val result = runFFmpeg("-hide_banner -nostdin -i ${q(path)} -map 0:a:0 -frames:a 1 -f null -", shouldCancel)
|
|
val output = result.second
|
|
val match = Regex("Audio:\\s*([^,\\s]+)", RegexOption.IGNORE_CASE).find(output)
|
|
return match?.groupValues?.getOrNull(1)
|
|
?.trim()
|
|
?.lowercase(Locale.ROOT)
|
|
?.replace('-', '_')
|
|
.orEmpty()
|
|
}
|
|
|
|
/**
|
|
* Returns true when the file on [path] starts with the native FLAC magic
|
|
* bytes (`fLaC`). A file may contain a FLAC audio stream yet live inside
|
|
* an MP4/fMP4 container (e.g. some Amazon Music downloads); native FLAC
|
|
* tag writers require the raw fLaC header, so we must detect that mismatch
|
|
* before skipping the container conversion step.
|
|
*/
|
|
private fun isNativeFlacFile(path: String): Boolean {
|
|
return try {
|
|
RandomAccessFile(path, "r").use { raf ->
|
|
if (raf.length() < 4L) return false
|
|
val header = ByteArray(4)
|
|
raf.readFully(header)
|
|
header[0] == 0x66.toByte() && // 'f'
|
|
header[1] == 0x4C.toByte() && // 'L'
|
|
header[2] == 0x61.toByte() && // 'a'
|
|
header[3] == 0x43.toByte() // 'C'
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.w(TAG, "Native FLAC magic probe failed for $path: ${e.message}")
|
|
false
|
|
}
|
|
}
|
|
|
|
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(
|
|
descriptor?.optString("output_extension", "")
|
|
?.ifBlank { input.result.optString("output_extension", "") }
|
|
)
|
|
}
|
|
|
|
private fun validateRequestContract(request: JSONObject) {
|
|
val version = request.optInt("contract_version", -1)
|
|
if (version != NATIVE_WORKER_CONTRACT_VERSION) {
|
|
throw IllegalArgumentException(
|
|
"unsupported native worker request contract v$version"
|
|
)
|
|
}
|
|
|
|
val required = listOf("item_id", "service", "track_name", "quality", "storage_mode")
|
|
val missing = required.filter { request.optString(it, "").trim().isEmpty() }
|
|
if (missing.isNotEmpty()) {
|
|
throw IllegalArgumentException(
|
|
"native worker request missing fields: ${missing.joinToString()}"
|
|
)
|
|
}
|
|
}
|
|
|
|
private fun promoteStagedSafOutputIfNeeded(
|
|
context: Context,
|
|
input: FinalizeInput,
|
|
state: FinalizeState,
|
|
) {
|
|
if (!state.filePath.startsWith("content://")) return
|
|
if (!input.result.optBoolean("saf_staged_output", false)) return
|
|
val stagedName = input.result.optString("saf_staged_file_name", "").trim()
|
|
if (stagedName.isNotEmpty() && state.fileName != stagedName) return
|
|
|
|
val localInput = materializeForFFmpeg(context, input, state)
|
|
try {
|
|
replaceStatePath(context, input, state, localInput, deleteOld = true)
|
|
} finally {
|
|
File(localInput).delete()
|
|
}
|
|
}
|
|
|
|
private fun isDeferredSafPublish(input: FinalizeInput): Boolean {
|
|
return input.request.optBoolean("defer_saf_publish", false) &&
|
|
input.result.optBoolean("saf_deferred_publish", false)
|
|
}
|
|
|
|
private fun isDeferredSafRequest(input: FinalizeInput): Boolean {
|
|
return input.request.optString("storage_mode", "") == "saf" &&
|
|
input.request.optBoolean("defer_saf_publish", false)
|
|
}
|
|
|
|
private fun publishDeferredSafOutput(
|
|
context: Context,
|
|
input: FinalizeInput,
|
|
state: FinalizeState,
|
|
) {
|
|
if (!isDeferredSafPublish(input)) return
|
|
if (state.filePath.startsWith("content://")) return
|
|
|
|
val outputFile = File(state.filePath)
|
|
if (!outputFile.exists() || outputFile.length() <= 0L) {
|
|
throw IllegalStateException("deferred SAF output missing or empty")
|
|
}
|
|
|
|
val finalName = desiredFileName(input, state, outputFile.extension)
|
|
val treeUri = input.result.optString("saf_tree_uri", "")
|
|
.ifBlank { input.request.optString("saf_tree_uri", "") }
|
|
val relativeDir = input.result.optString("saf_relative_dir", "")
|
|
.ifBlank { input.request.optString("saf_relative_dir", "") }
|
|
val mimeType = mimeTypeForExt(outputFile.extension)
|
|
val newUri = SafDownloadHandler.writeFileToSaf(
|
|
context = context,
|
|
treeUriStr = treeUri,
|
|
relativeDir = relativeDir,
|
|
fileName = finalName,
|
|
mimeType = mimeType,
|
|
srcPath = outputFile.absolutePath,
|
|
) ?: throw IllegalStateException("failed to publish deferred SAF output")
|
|
|
|
Log.i(TAG, "Published deferred SAF output once: file=$finalName bytes=${outputFile.length()}")
|
|
outputFile.delete()
|
|
state.filePath = newUri
|
|
state.fileName = finalName
|
|
input.result.put("file_path", newUri)
|
|
input.result.put("file_name", finalName)
|
|
input.result.optJSONObject("replaygain")?.let { replayGain ->
|
|
replayGain.put("file_path", newUri)
|
|
replayGain.put("file_name", finalName)
|
|
}
|
|
input.result.put("saf_deferred_published", true)
|
|
publishPendingDeferredExternalLrc(context, input, state)
|
|
}
|
|
|
|
private fun publishPendingDeferredExternalLrc(
|
|
context: Context,
|
|
input: FinalizeInput,
|
|
state: FinalizeState,
|
|
) {
|
|
val lrc = state.pendingExternalLrc ?: return
|
|
val fileName = state.pendingExternalLrcFileName ?: return
|
|
val treeUri = input.result.optString("saf_tree_uri", "")
|
|
.ifBlank { input.request.optString("saf_tree_uri", "") }
|
|
val relativeDir = input.result.optString("saf_relative_dir", "")
|
|
.ifBlank { input.request.optString("saf_relative_dir", "") }
|
|
val temp = File(context.cacheDir, "native_lrc_${System.nanoTime()}.lrc")
|
|
try {
|
|
temp.writeText(lrc)
|
|
val newUri = SafDownloadHandler.writeFileToSaf(
|
|
context = context,
|
|
treeUriStr = treeUri,
|
|
relativeDir = relativeDir,
|
|
fileName = fileName,
|
|
mimeType = "application/octet-stream",
|
|
srcPath = temp.absolutePath,
|
|
)
|
|
if (newUri == null) {
|
|
Log.w(TAG, "Failed to publish deferred external LRC: $fileName")
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.w(TAG, "Failed to publish deferred external LRC: ${e.message}")
|
|
} finally {
|
|
temp.delete()
|
|
state.pendingExternalLrc = null
|
|
state.pendingExternalLrcFileName = null
|
|
}
|
|
}
|
|
|
|
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) {
|
|
val trimmed = value.trim()
|
|
if (trimmed.isNotEmpty()) candidates.add(trimmed)
|
|
}
|
|
val trimmed = raw.trim()
|
|
if (trimmed.isEmpty()) return emptyList()
|
|
add(trimmed)
|
|
val noPrefix = if (trimmed.startsWith("0x", ignoreCase = true)) trimmed.substring(2) else trimmed
|
|
add(noPrefix)
|
|
val compactHex = noPrefix.replace(Regex("[^0-9a-fA-F]"), "")
|
|
if (compactHex.isNotEmpty() && compactHex.length % 2 == 0) add(compactHex)
|
|
try {
|
|
val decoded = Base64.decode(noPrefix.replace(Regex("\\s+"), ""), Base64.DEFAULT)
|
|
if (decoded.isNotEmpty()) {
|
|
add(decoded.joinToString("") { "%02x".format(it) })
|
|
}
|
|
} catch (_: Exception) {
|
|
}
|
|
return candidates.toList()
|
|
}
|
|
|
|
private fun looksLikeM4a(path: String, fileName: String): Boolean {
|
|
val lowerPath = path.lowercase(Locale.ROOT)
|
|
val lowerName = fileName.lowercase(Locale.ROOT)
|
|
return lowerPath.endsWith(".m4a") ||
|
|
lowerPath.endsWith(".mp4") ||
|
|
lowerName.endsWith(".m4a") ||
|
|
lowerName.endsWith(".mp4")
|
|
}
|
|
|
|
private fun albumKey(input: FinalizeInput): String {
|
|
val albumId = trackString(input, "albumId", "")
|
|
if (albumId.isNotBlank()) return "id:$albumId"
|
|
val albumName = trackString(input, "albumName", input.request.optString("album_name", ""))
|
|
val albumArtist = trackString(input, "albumArtist", input.request.optString("album_artist", ""))
|
|
return "name:$albumName|$albumArtist"
|
|
}
|
|
|
|
private fun replayGainDurationSeconds(input: FinalizeInput): Double {
|
|
val duration = input.request.optInt("duration_ms", 0).let {
|
|
if (it > 0) it else trackInt(input, "duration", 0)
|
|
}
|
|
if (duration <= 0) return 1.0
|
|
return if (duration > 10000) duration / 1000.0 else duration.toDouble()
|
|
}
|
|
|
|
private fun buildHistoryRow(input: FinalizeInput, state: FinalizeState): ContentValues {
|
|
val result = input.result
|
|
val values = ContentValues()
|
|
values.put("id", input.itemId)
|
|
values.put("track_name", result.optString("title", "").ifBlank { trackString(input, "name", input.request.optString("track_name", "")) })
|
|
values.put("artist_name", result.optString("artist", "").ifBlank { trackString(input, "artistName", input.request.optString("artist_name", "")) })
|
|
values.put("album_name", result.optString("album", "").ifBlank { trackString(input, "albumName", input.request.optString("album_name", "")) })
|
|
values.put("album_artist", normalizeOptional(resultString(input, "album_artist").ifBlank { trackString(input, "albumArtist", requestString(input, "album_artist")) }))
|
|
values.put("cover_url", normalizeOptional(metadataCoverUrl(input).ifBlank { resultString(input, "cover_url") }))
|
|
values.put("file_path", state.filePath)
|
|
values.put("storage_mode", input.request.optString("storage_mode", "app"))
|
|
values.put("download_tree_uri", normalizeOptional(input.request.optString("saf_tree_uri", "")))
|
|
values.put("saf_relative_dir", normalizeOptional(input.request.optString("saf_relative_dir", "")))
|
|
values.put("saf_file_name", if (state.filePath.startsWith("content://")) state.fileName else null)
|
|
values.put("saf_repaired", 0)
|
|
values.put("service", result.optString("service", "").ifBlank { input.item.optString("service", "") })
|
|
values.put("downloaded_at", java.time.Instant.now().toString())
|
|
values.put("isrc", normalizeOptional(result.optString("isrc", "").ifBlank { trackString(input, "isrc", input.request.optString("isrc", "")) }))
|
|
values.put("spotify_id", normalizeOptional(trackString(input, "id", input.request.optString("spotify_id", ""))))
|
|
values.put("track_number", positiveOrNull(result.optInt("track_number", 0), trackInt(input, "trackNumber", input.request.optInt("track_number", 0))))
|
|
values.put("total_tracks", positiveOrNull(result.optInt("total_tracks", 0), trackInt(input, "totalTracks", input.request.optInt("total_tracks", 0))))
|
|
values.put("disc_number", positiveOrNull(result.optInt("disc_number", 0), trackInt(input, "discNumber", input.request.optInt("disc_number", 0))))
|
|
values.put("total_discs", positiveOrNull(result.optInt("total_discs", 0), trackInt(input, "totalDiscs", input.request.optInt("total_discs", 0))))
|
|
values.put("duration", trackInt(input, "duration", input.request.optInt("duration_ms", 0) / 1000))
|
|
values.put("release_date", normalizeOptional(resultString(input, "release_date").ifBlank { resultString(input, "date").ifBlank { trackString(input, "releaseDate", requestString(input, "release_date")) } }))
|
|
values.put("quality", state.quality)
|
|
state.bitDepth?.let { values.put("bit_depth", it) }
|
|
state.sampleRate?.let { values.put("sample_rate", it) }
|
|
values.put("genre", normalizeOptional(result.optString("genre", "").ifBlank { input.request.optString("genre", "") }))
|
|
values.put("composer", normalizeOptional(resultString(input, "composer").ifBlank { trackString(input, "composer", requestString(input, "composer")) }))
|
|
values.put("label", normalizeOptional(result.optString("label", "").ifBlank { input.request.optString("label", "") }))
|
|
values.put("copyright", normalizeOptional(result.optString("copyright", "").ifBlank { input.request.optString("copyright", "") }))
|
|
putNormalizedHistoryColumns(values)
|
|
return values
|
|
}
|
|
|
|
private fun upsertHistory(context: Context, values: ContentValues) {
|
|
val dbFile = File(File(context.applicationInfo.dataDir, "app_flutter"), "history.db")
|
|
dbFile.parentFile?.mkdirs()
|
|
val db = SQLiteDatabase.openDatabase(
|
|
dbFile.absolutePath,
|
|
null,
|
|
SQLiteDatabase.OPEN_READWRITE or
|
|
SQLiteDatabase.CREATE_IF_NECESSARY or
|
|
SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING,
|
|
)
|
|
try {
|
|
configureHistoryDatabase(db)
|
|
db.beginTransaction()
|
|
try {
|
|
if (db.version > HISTORY_SCHEMA_VERSION) {
|
|
throw IllegalStateException(
|
|
"history schema v${db.version} is newer than native finalizer contract v$HISTORY_SCHEMA_VERSION"
|
|
)
|
|
}
|
|
val needsBackfill = db.version < HISTORY_SCHEMA_VERSION
|
|
db.execSQL(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS history (
|
|
id TEXT PRIMARY KEY,
|
|
track_name TEXT NOT NULL,
|
|
artist_name TEXT NOT NULL,
|
|
album_name TEXT NOT NULL,
|
|
album_artist TEXT,
|
|
cover_url TEXT,
|
|
file_path TEXT NOT NULL,
|
|
storage_mode TEXT,
|
|
download_tree_uri TEXT,
|
|
saf_relative_dir TEXT,
|
|
saf_file_name TEXT,
|
|
saf_repaired INTEGER,
|
|
service TEXT NOT NULL,
|
|
downloaded_at TEXT NOT NULL,
|
|
isrc TEXT,
|
|
spotify_id TEXT,
|
|
track_number INTEGER,
|
|
total_tracks INTEGER,
|
|
disc_number INTEGER,
|
|
total_discs INTEGER,
|
|
duration INTEGER,
|
|
release_date TEXT,
|
|
quality TEXT,
|
|
bit_depth INTEGER,
|
|
sample_rate INTEGER,
|
|
genre TEXT,
|
|
composer TEXT,
|
|
label TEXT,
|
|
copyright TEXT
|
|
)
|
|
""".trimIndent()
|
|
)
|
|
ensureHistoryColumn(db, "storage_mode", "ALTER TABLE history ADD COLUMN storage_mode TEXT")
|
|
ensureHistoryColumn(db, "download_tree_uri", "ALTER TABLE history ADD COLUMN download_tree_uri TEXT")
|
|
ensureHistoryColumn(db, "saf_relative_dir", "ALTER TABLE history ADD COLUMN saf_relative_dir TEXT")
|
|
ensureHistoryColumn(db, "saf_file_name", "ALTER TABLE history ADD COLUMN saf_file_name TEXT")
|
|
ensureHistoryColumn(db, "saf_repaired", "ALTER TABLE history ADD COLUMN saf_repaired INTEGER")
|
|
ensureHistoryColumn(db, "composer", "ALTER TABLE history ADD COLUMN composer TEXT")
|
|
ensureHistoryColumn(db, "total_tracks", "ALTER TABLE history ADD COLUMN total_tracks INTEGER")
|
|
ensureHistoryColumn(db, "total_discs", "ALTER TABLE history ADD COLUMN total_discs INTEGER")
|
|
ensureHistoryColumn(db, "spotify_id_norm", "ALTER TABLE history ADD COLUMN spotify_id_norm TEXT")
|
|
ensureHistoryColumn(db, "isrc_norm", "ALTER TABLE history ADD COLUMN isrc_norm TEXT")
|
|
ensureHistoryColumn(db, "match_key", "ALTER TABLE history ADD COLUMN match_key TEXT")
|
|
ensureHistoryPathKeyTable(db)
|
|
if (needsBackfill) {
|
|
backfillNormalizedHistoryColumns(db)
|
|
backfillHistoryPathKeys(db)
|
|
}
|
|
validateHistorySchema(db)
|
|
db.execSQL("CREATE INDEX IF NOT EXISTS idx_spotify_id ON history(spotify_id)")
|
|
db.execSQL("CREATE INDEX IF NOT EXISTS idx_isrc ON history(isrc)")
|
|
db.execSQL("CREATE INDEX IF NOT EXISTS idx_downloaded_at ON history(downloaded_at DESC)")
|
|
db.execSQL("CREATE INDEX IF NOT EXISTS idx_album ON history(album_name, album_artist)")
|
|
db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_track_artist ON history(track_name, artist_name)")
|
|
db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_spotify_id_norm ON history(spotify_id_norm)")
|
|
db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_isrc_norm ON history(isrc_norm)")
|
|
db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_match_key ON history(match_key)")
|
|
if (db.version < HISTORY_SCHEMA_VERSION) db.version = HISTORY_SCHEMA_VERSION
|
|
deleteDuplicateHistoryRows(db, values)
|
|
db.insertWithOnConflict("history", null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
|
replaceHistoryPathKeys(db, values.getAsString("id"), values.getAsString("file_path"))
|
|
db.setTransactionSuccessful()
|
|
} finally {
|
|
db.endTransaction()
|
|
}
|
|
} finally {
|
|
db.close()
|
|
}
|
|
}
|
|
|
|
private fun configureHistoryDatabase(db: SQLiteDatabase) {
|
|
runHistoryPragma(db, "PRAGMA busy_timeout = 5000", required = false)
|
|
runHistoryPragma(db, "PRAGMA synchronous = NORMAL", required = false)
|
|
runHistoryPragma(db, "PRAGMA journal_mode = WAL", required = false)
|
|
}
|
|
|
|
private fun runHistoryPragma(db: SQLiteDatabase, sql: String, required: Boolean) {
|
|
try {
|
|
db.rawQuery(sql, null).use { cursor ->
|
|
while (cursor.moveToNext()) {
|
|
// PRAGMA setters may return a row; consume it so Android closes the cursor cleanly.
|
|
}
|
|
}
|
|
} catch (e: SQLiteException) {
|
|
if (required) throw e
|
|
Log.w(TAG, "Unable to apply history database setting: $sql", e)
|
|
}
|
|
}
|
|
|
|
private fun validateHistorySchema(db: SQLiteDatabase) {
|
|
val columns = mutableSetOf<String>()
|
|
db.rawQuery("PRAGMA table_info(history)", null).use { cursor ->
|
|
val nameIndex = cursor.getColumnIndex("name")
|
|
while (cursor.moveToNext()) {
|
|
if (nameIndex >= 0) {
|
|
columns.add(cursor.getString(nameIndex).lowercase(Locale.ROOT))
|
|
}
|
|
}
|
|
}
|
|
val missing = requiredHistoryColumns.filterNot { columns.contains(it) }
|
|
if (missing.isNotEmpty()) {
|
|
throw IllegalStateException("history schema missing columns for native finalizer: ${missing.joinToString()}")
|
|
}
|
|
}
|
|
|
|
private fun deleteDuplicateHistoryRows(db: SQLiteDatabase, values: ContentValues) {
|
|
val id = values.getAsString("id") ?: return
|
|
val duplicateIds = linkedSetOf<String>()
|
|
val spotifyId = values.getAsString("spotify_id")?.trim().orEmpty()
|
|
val spotifyIdNorm = values.getAsString("spotify_id_norm")?.trim().orEmpty()
|
|
if (spotifyId.isNotEmpty() || spotifyIdNorm.isNotEmpty()) {
|
|
duplicateIds.addAll(
|
|
historyIdsForWhere(
|
|
db,
|
|
"(spotify_id = ? OR spotify_id_norm = ?) AND id <> ?",
|
|
arrayOf(spotifyId, spotifyIdNorm, id),
|
|
)
|
|
)
|
|
}
|
|
|
|
val isrc = values.getAsString("isrc")?.trim().orEmpty()
|
|
val isrcNorm = values.getAsString("isrc_norm")?.trim().orEmpty()
|
|
if (isrc.isNotEmpty() || isrcNorm.isNotEmpty()) {
|
|
duplicateIds.addAll(
|
|
historyIdsForWhere(
|
|
db,
|
|
"(isrc = ? OR isrc_norm = ?) AND id <> ?",
|
|
arrayOf(isrc, isrcNorm, id),
|
|
)
|
|
)
|
|
}
|
|
|
|
if (spotifyIdNorm.isEmpty() && isrcNorm.isEmpty()) {
|
|
val matchKey = values.getAsString("match_key")?.trim().orEmpty()
|
|
if (matchKey.isNotEmpty()) {
|
|
duplicateIds.addAll(
|
|
historyIdsForWhere(
|
|
db,
|
|
"match_key = ? AND id <> ?",
|
|
arrayOf(matchKey, id),
|
|
)
|
|
)
|
|
}
|
|
}
|
|
if (duplicateIds.isEmpty()) return
|
|
deleteHistoryPathKeys(db, duplicateIds)
|
|
val placeholders = duplicateIds.joinToString(",") { "?" }
|
|
db.delete("history", "id IN ($placeholders)", duplicateIds.toTypedArray())
|
|
}
|
|
|
|
private fun historyIdsForWhere(db: SQLiteDatabase, where: String, args: Array<String>): List<String> {
|
|
val ids = mutableListOf<String>()
|
|
db.query("history", arrayOf("id"), where, args, null, null, null).use { cursor ->
|
|
val idIndex = cursor.getColumnIndex("id")
|
|
while (cursor.moveToNext()) {
|
|
if (idIndex >= 0) ids.add(cursor.getString(idIndex))
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
private fun deleteHistoryPathKeys(db: SQLiteDatabase, ids: Collection<String>) {
|
|
if (ids.isEmpty()) return
|
|
val placeholders = ids.joinToString(",") { "?" }
|
|
db.delete("history_path_keys", "item_id IN ($placeholders)", ids.toTypedArray())
|
|
}
|
|
|
|
private fun ensureHistoryPathKeyTable(db: SQLiteDatabase) {
|
|
db.execSQL(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS history_path_keys (
|
|
item_id TEXT NOT NULL,
|
|
path_key TEXT NOT NULL,
|
|
PRIMARY KEY (item_id, path_key)
|
|
)
|
|
""".trimIndent()
|
|
)
|
|
db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_path_keys_key ON history_path_keys(path_key)")
|
|
}
|
|
|
|
private fun backfillNormalizedHistoryColumns(db: SQLiteDatabase) {
|
|
db.query(
|
|
"history",
|
|
arrayOf("id", "spotify_id", "isrc", "track_name", "artist_name"),
|
|
"spotify_id_norm IS NULL OR isrc_norm IS NULL OR match_key IS NULL",
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
).use { cursor ->
|
|
val idIndex = cursor.getColumnIndex("id")
|
|
val spotifyIndex = cursor.getColumnIndex("spotify_id")
|
|
val isrcIndex = cursor.getColumnIndex("isrc")
|
|
val trackIndex = cursor.getColumnIndex("track_name")
|
|
val artistIndex = cursor.getColumnIndex("artist_name")
|
|
while (cursor.moveToNext()) {
|
|
if (idIndex < 0) continue
|
|
val values = ContentValues()
|
|
val spotifyId = cursor.getNullableString(spotifyIndex)
|
|
val isrc = cursor.getNullableString(isrcIndex)
|
|
val trackName = cursor.getNullableString(trackIndex)
|
|
val artistName = cursor.getNullableString(artistIndex)
|
|
values.put("spotify_id_norm", normalizeSpotifyId(spotifyId))
|
|
values.put("isrc_norm", normalizeIsrc(isrc))
|
|
values.put("match_key", matchKeyFor(trackName, artistName))
|
|
db.update("history", values, "id = ?", arrayOf(cursor.getString(idIndex)))
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun backfillHistoryPathKeys(db: SQLiteDatabase) {
|
|
db.query("history", arrayOf("id", "file_path"), null, null, null, null, null).use { cursor ->
|
|
val idIndex = cursor.getColumnIndex("id")
|
|
val pathIndex = cursor.getColumnIndex("file_path")
|
|
while (cursor.moveToNext()) {
|
|
if (idIndex >= 0) {
|
|
replaceHistoryPathKeys(db, cursor.getString(idIndex), cursor.getNullableString(pathIndex))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun replaceHistoryPathKeys(db: SQLiteDatabase, itemId: String?, filePath: String?) {
|
|
val id = itemId?.trim().orEmpty()
|
|
if (id.isEmpty()) return
|
|
db.delete("history_path_keys", "item_id = ?", arrayOf(id))
|
|
for (key in buildPathMatchKeys(filePath)) {
|
|
val values = ContentValues()
|
|
values.put("item_id", id)
|
|
values.put("path_key", key)
|
|
db.insertWithOnConflict("history_path_keys", null, values, SQLiteDatabase.CONFLICT_IGNORE)
|
|
}
|
|
}
|
|
|
|
private fun putNormalizedHistoryColumns(values: ContentValues) {
|
|
values.put("spotify_id_norm", normalizeSpotifyId(values.getAsString("spotify_id")))
|
|
values.put("isrc_norm", normalizeIsrc(values.getAsString("isrc")))
|
|
values.put(
|
|
"match_key",
|
|
matchKeyFor(values.getAsString("track_name"), values.getAsString("artist_name")),
|
|
)
|
|
}
|
|
|
|
private fun normalizeLookupText(value: String?): String =
|
|
cleanMetadataString(value).lowercase(Locale.ROOT)
|
|
|
|
private fun normalizeSpotifyId(value: String?): String =
|
|
cleanMetadataString(value).lowercase(Locale.ROOT)
|
|
|
|
private fun normalizeIsrc(value: String?): String =
|
|
cleanMetadataString(value)
|
|
.uppercase(Locale.ROOT)
|
|
.replace(Regex("[-\\s]"), "")
|
|
|
|
private fun matchKeyFor(trackName: String?, artistName: String?): String {
|
|
val track = normalizeLookupText(trackName)
|
|
if (track.isEmpty()) return ""
|
|
return "$track|${normalizeLookupText(artistName)}"
|
|
}
|
|
|
|
private fun buildPathMatchKeys(filePath: String?): Set<String> {
|
|
val raw = filePath?.trim().orEmpty()
|
|
if (raw.isEmpty()) return emptySet()
|
|
val cleaned = if (raw.startsWith("EXISTS:")) raw.substring(7).trim() else raw
|
|
if (cleaned.isEmpty()) return emptySet()
|
|
|
|
val keys = linkedSetOf<String>()
|
|
val visited = linkedSetOf<String>()
|
|
|
|
fun addNormalized(value: String) {
|
|
val trimmed = value.trim()
|
|
if (trimmed.isEmpty()) return
|
|
if (!visited.add(trimmed)) return
|
|
|
|
keys.add(trimmed)
|
|
keys.add(trimmed.lowercase(Locale.ROOT))
|
|
|
|
if (trimmed.contains('\\')) {
|
|
val slash = trimmed.replace('\\', '/')
|
|
if (slash != trimmed) addNormalized(slash)
|
|
}
|
|
|
|
if (trimmed.contains('%')) {
|
|
try {
|
|
val decoded = Uri.decode(trimmed)
|
|
if (decoded != trimmed) addNormalized(decoded)
|
|
} catch (_: Throwable) {
|
|
}
|
|
}
|
|
|
|
val parsed = try {
|
|
Uri.parse(trimmed)
|
|
} catch (_: Throwable) {
|
|
null
|
|
}
|
|
if (parsed != null && !parsed.scheme.isNullOrEmpty()) {
|
|
val stripped = stripUriQueryAndFragment(trimmed)
|
|
keys.add(stripped)
|
|
keys.add(stripped.lowercase(Locale.ROOT))
|
|
if (parsed.scheme.equals("file", ignoreCase = true)) {
|
|
parsed.path?.let { addNormalized(it) }
|
|
}
|
|
} else if (trimmed.startsWith("/")) {
|
|
try {
|
|
val asFileUri = Uri.fromFile(File(trimmed)).toString()
|
|
keys.add(asFileUri)
|
|
keys.add(asFileUri.lowercase(Locale.ROOT))
|
|
} catch (_: Throwable) {
|
|
}
|
|
}
|
|
|
|
for (alias in androidEquivalentPaths(trimmed)) {
|
|
if (alias != trimmed) addNormalized(alias)
|
|
}
|
|
}
|
|
|
|
addNormalized(cleaned)
|
|
|
|
val extensionStripped = linkedSetOf<String>()
|
|
for (key in keys) {
|
|
stripAudioExtension(key)?.let {
|
|
if (it.isNotEmpty()) extensionStripped.add(it)
|
|
}
|
|
}
|
|
keys.addAll(extensionStripped)
|
|
return keys
|
|
}
|
|
|
|
private fun stripUriQueryAndFragment(value: String): String {
|
|
val queryIndex = value.indexOf('?').let { if (it >= 0) it else value.length }
|
|
val fragmentIndex = value.indexOf('#').let { if (it >= 0) it else value.length }
|
|
val cut = minOf(queryIndex, fragmentIndex)
|
|
return value.substring(0, cut)
|
|
}
|
|
|
|
private fun stripAudioExtension(path: String): String? {
|
|
val lower = path.lowercase(Locale.ROOT)
|
|
for (ext in audioExtensions) {
|
|
if (lower.endsWith(ext)) {
|
|
return path.substring(0, path.length - ext.length)
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
private fun androidEquivalentPaths(path: String): List<String> {
|
|
val normalized = path.replace('\\', '/')
|
|
val lower = normalized.lowercase(Locale.ROOT)
|
|
var suffix: String? = null
|
|
for (prefix in androidStoragePathAliases) {
|
|
if (lower == prefix) {
|
|
suffix = ""
|
|
break
|
|
}
|
|
val withSlash = "$prefix/"
|
|
if (lower.startsWith(withSlash)) {
|
|
suffix = normalized.substring(prefix.length)
|
|
break
|
|
}
|
|
}
|
|
val resolvedSuffix = suffix ?: return emptyList()
|
|
return androidStoragePathAliases.map { "$it$resolvedSuffix" }
|
|
}
|
|
|
|
private fun android.database.Cursor.getNullableString(index: Int): String? {
|
|
if (index < 0 || isNull(index)) return null
|
|
return getString(index)
|
|
}
|
|
|
|
private fun ensureHistoryColumn(db: SQLiteDatabase, column: String, alterSql: String) {
|
|
db.rawQuery("PRAGMA table_info(history)", null).use { cursor ->
|
|
val nameIndex = cursor.getColumnIndex("name")
|
|
while (cursor.moveToNext()) {
|
|
if (nameIndex >= 0 && cursor.getString(nameIndex).equals(column, ignoreCase = true)) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
db.execSQL(alterSql)
|
|
}
|
|
|
|
private fun historyToJson(values: ContentValues): JSONObject {
|
|
val json = JSONObject()
|
|
fun putCamel(column: String, key: String) {
|
|
if (values.containsKey(column)) json.put(key, values.get(column))
|
|
}
|
|
putCamel("id", "id")
|
|
putCamel("track_name", "trackName")
|
|
putCamel("artist_name", "artistName")
|
|
putCamel("album_name", "albumName")
|
|
putCamel("album_artist", "albumArtist")
|
|
putCamel("cover_url", "coverUrl")
|
|
putCamel("file_path", "filePath")
|
|
putCamel("storage_mode", "storageMode")
|
|
putCamel("download_tree_uri", "downloadTreeUri")
|
|
putCamel("saf_relative_dir", "safRelativeDir")
|
|
putCamel("saf_file_name", "safFileName")
|
|
json.put("safRepaired", values.getAsInteger("saf_repaired") == 1)
|
|
putCamel("service", "service")
|
|
putCamel("downloaded_at", "downloadedAt")
|
|
putCamel("isrc", "isrc")
|
|
putCamel("spotify_id", "spotifyId")
|
|
putCamel("track_number", "trackNumber")
|
|
putCamel("total_tracks", "totalTracks")
|
|
putCamel("disc_number", "discNumber")
|
|
putCamel("total_discs", "totalDiscs")
|
|
putCamel("duration", "duration")
|
|
putCamel("release_date", "releaseDate")
|
|
putCamel("quality", "quality")
|
|
putCamel("bit_depth", "bitDepth")
|
|
putCamel("sample_rate", "sampleRate")
|
|
putCamel("genre", "genre")
|
|
putCamel("composer", "composer")
|
|
putCamel("label", "label")
|
|
putCamel("copyright", "copyright")
|
|
return json
|
|
}
|
|
|
|
private fun trackString(input: FinalizeInput, key: String, fallback: String): String =
|
|
cleanMetadataString(input.track.optString(key, "")).ifBlank { cleanMetadataString(fallback) }
|
|
|
|
private fun requestString(input: FinalizeInput, key: String): String =
|
|
cleanMetadataString(input.request.optString(key, ""))
|
|
|
|
private fun resultString(input: FinalizeInput, key: String): String =
|
|
cleanMetadataString(input.result.optString(key, ""))
|
|
|
|
private fun metadataCoverUrl(input: FinalizeInput): String =
|
|
trackString(input, "coverUrl", requestString(input, "cover_url"))
|
|
|
|
private fun trackInt(input: FinalizeInput, key: String, fallback: Int): Int {
|
|
val value = input.track.optInt(key, 0)
|
|
return if (value > 0) value else fallback
|
|
}
|
|
|
|
private fun optPositiveInt(obj: JSONObject, key: String): Int? {
|
|
val value = obj.optInt(key, 0)
|
|
return if (value > 0) value else null
|
|
}
|
|
|
|
private fun optPositiveBitrateKbps(obj: JSONObject, key: String): Int? {
|
|
val value = optPositiveInt(obj, key) ?: return null
|
|
return if (value >= 10000) {
|
|
Math.round(value / 1000.0).toInt()
|
|
} else {
|
|
value
|
|
}
|
|
}
|
|
|
|
private fun positiveOrNull(primary: Int, fallback: Int): Int? {
|
|
val value = if (primary > 0) primary else fallback
|
|
return if (value > 0) value else null
|
|
}
|
|
|
|
private fun normalizeOptional(value: String?): String? {
|
|
val trimmed = cleanMetadataString(value)
|
|
return trimmed.ifBlank { null }
|
|
}
|
|
|
|
private fun cleanMetadataString(value: String?): String {
|
|
val trimmed = value?.trim().orEmpty()
|
|
return if (trimmed.equals("null", ignoreCase = true)) "" else trimmed
|
|
}
|
|
|
|
private fun normalizeExt(ext: String?): String {
|
|
val trimmed = ext?.trim().orEmpty()
|
|
if (trimmed.isEmpty()) return ""
|
|
return if (trimmed.startsWith(".")) trimmed.lowercase(Locale.ROOT) else ".${trimmed.lowercase(Locale.ROOT)}"
|
|
}
|
|
|
|
private fun mimeTypeForExt(ext: String?): String {
|
|
return when (normalizeExt(ext)) {
|
|
".m4a", ".mp4" -> "audio/mp4"
|
|
".mp3" -> "audio/mpeg"
|
|
".opus", ".ogg" -> "audio/ogg"
|
|
".flac" -> "audio/flac"
|
|
".lrc" -> "application/octet-stream"
|
|
else -> "application/octet-stream"
|
|
}
|
|
}
|
|
|
|
private fun q(value: String): String = "\"${value.replace("\"", "\\\"")}\""
|
|
}
|