mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
refactor(android): split media, ffmpeg, and SAF publish helpers out of NativeDownloadFinalizer
This commit is contained in:
@@ -35,17 +35,17 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.pow
|
||||
|
||||
object NativeDownloadFinalizer {
|
||||
private const val TAG = "NativeFinalizer"
|
||||
internal 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.
|
||||
const val HISTORY_SCHEMA_VERSION = 10
|
||||
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 ->
|
||||
internal val activeFFmpegSessionIds = mutableSetOf<Long>()
|
||||
internal val nativeFFmpegSessionIds = mutableSetOf<Long>()
|
||||
internal val activeFFmpegSessionLock = Any()
|
||||
internal val ffmpegCompleteCallbackLock = Any()
|
||||
internal var forwardedFFmpegCompleteCallback: FFmpegSessionCompleteCallback? = null
|
||||
internal val nativeFilteringFFmpegCompleteCallback = FFmpegSessionCompleteCallback { session ->
|
||||
val isNativeSession = synchronized(activeFFmpegSessionLock) {
|
||||
nativeFFmpegSessionIds.contains(session.sessionId)
|
||||
}
|
||||
@@ -112,7 +112,7 @@ object NativeDownloadFinalizer {
|
||||
".mp4",
|
||||
)
|
||||
|
||||
private data class FinalizeInput(
|
||||
internal data class FinalizeInput(
|
||||
val itemId: String,
|
||||
val request: JSONObject,
|
||||
val item: JSONObject,
|
||||
@@ -120,7 +120,7 @@ object NativeDownloadFinalizer {
|
||||
val result: JSONObject,
|
||||
)
|
||||
|
||||
private data class FinalizeState(
|
||||
internal data class FinalizeState(
|
||||
var filePath: String,
|
||||
var fileName: String,
|
||||
var quality: String,
|
||||
@@ -132,7 +132,7 @@ object NativeDownloadFinalizer {
|
||||
var pendingExternalLrcFileName: String? = null,
|
||||
)
|
||||
|
||||
private data class ReplayGainScan(
|
||||
internal data class ReplayGainScan(
|
||||
val trackGain: String,
|
||||
val trackPeak: String,
|
||||
val integratedLufs: Double,
|
||||
@@ -304,7 +304,7 @@ object NativeDownloadFinalizer {
|
||||
return result
|
||||
}
|
||||
|
||||
private fun checkCancelled(shouldCancel: () -> Boolean) {
|
||||
internal fun checkCancelled(shouldCancel: () -> Boolean) {
|
||||
if (shouldCancel()) {
|
||||
throw CancellationException("Native finalization cancelled")
|
||||
}
|
||||
@@ -710,22 +710,6 @@ object NativeDownloadFinalizer {
|
||||
state.audioCodec = normalizeAudioCodec(codec)
|
||||
}
|
||||
|
||||
private fun isMP4ContainerFile(path: String): Boolean {
|
||||
return try {
|
||||
File(path).inputStream().use { stream ->
|
||||
val header = ByteArray(12)
|
||||
val read = stream.read(header)
|
||||
read >= 8 &&
|
||||
header[4] == 'f'.code.toByte() &&
|
||||
header[5] == 't'.code.toByte() &&
|
||||
header[6] == 'y'.code.toByte() &&
|
||||
header[7] == 'p'.code.toByte()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun finalizeMetadata(context: Context, input: FinalizeInput, state: FinalizeState) {
|
||||
if (!input.request.optBoolean("embed_metadata", false)) return
|
||||
if (!state.filePath.startsWith("content://")) {
|
||||
@@ -900,168 +884,6 @@ object NativeDownloadFinalizer {
|
||||
lowerName.endsWith(".ogg")
|
||||
}
|
||||
|
||||
private fun qualityVariantFilenameLabel(state: FinalizeState): String? {
|
||||
return NativeFinalizationPolicy.qualityVariantFilenameLabel(
|
||||
measuredQuality = state.quality,
|
||||
bitDepth = state.bitDepth,
|
||||
sampleRate = state.sampleRate,
|
||||
bitrateKbps = state.bitrateKbps,
|
||||
audioCodec = state.audioCodec,
|
||||
)
|
||||
}
|
||||
|
||||
private fun finalizeQualityVariantFilename(
|
||||
context: Context,
|
||||
input: FinalizeInput,
|
||||
state: FinalizeState,
|
||||
) {
|
||||
if (!input.request.optBoolean("allow_quality_variant", false)) return
|
||||
val stagingLabel = input.request.optString("quality_variant", "").trim()
|
||||
val qualityLabel = qualityVariantFilenameLabel(state)
|
||||
if (qualityLabel == null) {
|
||||
Log.w(TAG, "Keeping temporary quality label because final audio specifications are unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
val preferredName = applyQualityVariantFilenameLabel(
|
||||
fileName = state.fileName,
|
||||
stagingLabel = stagingLabel,
|
||||
qualityLabel = qualityLabel,
|
||||
)
|
||||
if (preferredName == state.fileName) return
|
||||
input.result.put("quality_variant_file_name", preferredName)
|
||||
if (isDeferredSafPublish(input)) {
|
||||
state.fileName = preferredName
|
||||
return
|
||||
}
|
||||
|
||||
if (state.filePath.startsWith("content://")) {
|
||||
val tempPath = SafDownloadHandler.copyContentUriToTemp(context, state.filePath) ?: return
|
||||
try {
|
||||
val writeResult = SafDownloadHandler.writeFileToSafUnique(
|
||||
context = context,
|
||||
treeUriStr = input.request.optString("saf_tree_uri", ""),
|
||||
relativeDir = input.request.optString("saf_relative_dir", ""),
|
||||
fileName = preferredName,
|
||||
mimeType = mimeTypeForExt(File(preferredName).extension),
|
||||
srcPath = tempPath,
|
||||
preservedSuffix = qualityLabel,
|
||||
) ?: return
|
||||
SafDownloadHandler.deleteContentUri(context, state.filePath)
|
||||
state.filePath = writeResult.uri
|
||||
state.fileName = writeResult.fileName
|
||||
} finally {
|
||||
File(tempPath).delete()
|
||||
}
|
||||
} else {
|
||||
val source = File(state.filePath)
|
||||
val target = uniqueLocalFile(source.parentFile, preferredName)
|
||||
if (!source.renameTo(target)) {
|
||||
Log.w(TAG, "Could not rename quality variant output: ${source.absolutePath}")
|
||||
return
|
||||
}
|
||||
state.filePath = target.absolutePath
|
||||
state.fileName = target.name
|
||||
}
|
||||
|
||||
input.result.put("file_path", state.filePath)
|
||||
input.result.put("file_name", state.fileName)
|
||||
input.result.optJSONObject("replaygain")?.let { replayGain ->
|
||||
replayGain.put("file_path", state.filePath)
|
||||
replayGain.put("file_name", state.fileName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun uniqueLocalFile(parent: File?, preferredName: String): File {
|
||||
val directory = parent ?: return File(preferredName)
|
||||
var candidate = File(directory, preferredName)
|
||||
if (!candidate.exists()) return candidate
|
||||
val dotIndex = preferredName.lastIndexOf('.')
|
||||
val hasExtension = dotIndex > 0
|
||||
val stem = if (hasExtension) preferredName.substring(0, dotIndex) else preferredName
|
||||
val extension = if (hasExtension) preferredName.substring(dotIndex) else ""
|
||||
var counter = 2
|
||||
while (candidate.exists()) {
|
||||
candidate = File(directory, "$stem ($counter)$extension")
|
||||
counter++
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -1144,13 +966,13 @@ object NativeDownloadFinalizer {
|
||||
}
|
||||
}
|
||||
|
||||
private fun materializeForFFmpeg(context: Context, input: FinalizeInput, state: FinalizeState): String {
|
||||
internal 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(
|
||||
internal fun replaceStatePath(
|
||||
context: Context,
|
||||
input: FinalizeInput,
|
||||
state: FinalizeState,
|
||||
@@ -1181,508 +1003,6 @@ object NativeDownloadFinalizer {
|
||||
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 lyricsMode = input.request.optString("lyrics_mode", "embed")
|
||||
val shouldResolveLyrics = input.request.optBoolean("embed_lyrics", false) &&
|
||||
(lyricsMode == "embed" || lyricsMode == "both")
|
||||
val lyrics = if (shouldResolveLyrics) resolveLyricsLrc(input) else ""
|
||||
val shouldEmbedLyrics = shouldResolveLyrics &&
|
||||
lyrics.isNotBlank() &&
|
||||
lyrics != "[instrumental:true]"
|
||||
// FLAC, MP3, Opus, and M4A all have native Go tag writers that edit the
|
||||
// tag block atomically without an ffmpeg remux (which drops foreign
|
||||
// frames and rewrites the whole container). The Go side answers
|
||||
// method=ffmpeg when it cannot handle the file natively.
|
||||
if (format == "flac" || format == "mp3" || format == "opus" || format == "m4a") {
|
||||
val nativeCover = downloadCoverForMetadata(context, input)
|
||||
val handledNatively = try {
|
||||
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 (nativeCover != null) fields.put("cover_path", nativeCover.absolutePath)
|
||||
if (shouldEmbedLyrics) {
|
||||
fields.put("lyrics", lyrics)
|
||||
fields.put("unsyncedlyrics", lyrics)
|
||||
}
|
||||
val response = Gobackend.editFileMetadata(path, fields.toString())
|
||||
val method = try {
|
||||
JSONObject(response).optString("method", "")
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
method != "ffmpeg"
|
||||
} catch (e: Exception) {
|
||||
if (format == "flac") throw e
|
||||
Log.w(TAG, "Native tag embed failed for $format: ${e.message}; falling back to ffmpeg")
|
||||
false
|
||||
} finally {
|
||||
nativeCover?.delete()
|
||||
}
|
||||
if (handledNatively) return
|
||||
}
|
||||
|
||||
val ext = normalizeExt(File(path).extension).ifBlank { ".tmp" }
|
||||
val inputFile = File(path)
|
||||
// ".partial<ext>" keeps the temp invisible to library scans while FFmpeg
|
||||
// still infers the muxer from the real trailing extension.
|
||||
val temp = File(inputFile.parentFile, "${inputFile.nameWithoutExtension}_tagged.partial$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
|
||||
|
||||
fun buildEmbedCommand(forceMov: Boolean): String {
|
||||
return 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 ${if (forceMov) "mov" else "mp4"} ${q(temp.absolutePath)} -y"
|
||||
} else {
|
||||
val movFlag = if (forceMov) "-f mov " else ""
|
||||
"-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags$movFlag${q(temp.absolutePath)} -y"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
var result = runFFmpeg(buildEmbedCommand(false))
|
||||
// MOV muxer fallback for codecs the MP4 muxer rejects (e.g. AC-4).
|
||||
if (!result.first && (isM4a || ext.equals(".mp4", ignoreCase = true))) {
|
||||
temp.delete()
|
||||
result = runFFmpeg(buildEmbedCommand(true))
|
||||
}
|
||||
if (result.first && temp.exists()) {
|
||||
fsyncQuietly(temp)
|
||||
// Rename directly over the original: a process kill between a
|
||||
// delete-first and the rename would lose the file entirely.
|
||||
adoptedTemp = temp.renameTo(inputFile)
|
||||
if (!adoptedTemp && inputFile.delete()) {
|
||||
originalDeleted = true
|
||||
adoptedTemp = temp.renameTo(inputFile)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!adoptedTemp && !originalDeleted) {
|
||||
temp.delete()
|
||||
}
|
||||
coverFile?.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort fsync so a file's bytes are durable before it is renamed
|
||||
* over another file; fsync on a fresh handle flushes the page cache pages
|
||||
* written earlier by ffmpeg in this process.
|
||||
*/
|
||||
private fun fsyncQuietly(file: File) {
|
||||
try {
|
||||
RandomAccessFile(file, "rw").use { it.fd.sync() }
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
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 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", ".aac" -> "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()) {
|
||||
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)
|
||||
// Every claimed command must get a result delivered to
|
||||
// the Go side, even on failure or cancellation: the
|
||||
// backend blocks until one arrives and never retries a
|
||||
// claimed id, so bailing out here would strand the
|
||||
// gomobile call the main thread is sitting in forever.
|
||||
val result = try {
|
||||
if (shouldCancel()) {
|
||||
Pair(false, "cancelled")
|
||||
} else {
|
||||
runFFmpeg(commandLine, shouldCancel)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Pair(false, e.message ?: "FFmpeg execution failed")
|
||||
}
|
||||
try {
|
||||
Gobackend.setFFmpegCommandResultByID(
|
||||
id,
|
||||
result.first,
|
||||
result.second,
|
||||
if (result.first) "" else result.second,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to deliver FFmpeg result for $id: ${e.message}")
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
try {
|
||||
Thread.sleep(100)
|
||||
} catch (_: InterruptedException) {
|
||||
// Keep pumping until `running` flips: on cancel the Go call
|
||||
// may still be waiting for a result for an in-flight
|
||||
// command, and it is delivered as failed above.
|
||||
}
|
||||
}
|
||||
}
|
||||
pump.isDaemon = true
|
||||
pump.start()
|
||||
return try {
|
||||
block()
|
||||
} finally {
|
||||
running.set(false)
|
||||
pump.interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Staged sibling name for conversion outputs: "song.flac" -> "song.partial.flac".
|
||||
* The ".partial<ext>" shape is ignored by library scans and duplicate checks,
|
||||
* while the real trailing extension still lets FFmpeg infer the muxer. A
|
||||
* process kill mid-conversion therefore never leaves a partial file under a
|
||||
* real audio name in the user's music folder.
|
||||
*/
|
||||
private fun stagedConversionPath(finalPath: String): String {
|
||||
val file = File(finalPath)
|
||||
val ext = file.extension
|
||||
val name = if (ext.isBlank()) "${file.name}.partial" else "${file.nameWithoutExtension}.partial.$ext"
|
||||
return File(file.parentFile, name).absolutePath
|
||||
}
|
||||
|
||||
private fun promoteStagedConversion(stagedPath: String, finalPath: String): Boolean {
|
||||
val staged = File(stagedPath)
|
||||
fsyncQuietly(staged)
|
||||
val final = File(finalPath)
|
||||
if (staged.renameTo(final)) return true
|
||||
return final.delete() && staged.renameTo(final)
|
||||
}
|
||||
|
||||
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.result.optString("quality_variant_file_name", "")
|
||||
.ifBlank { 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", ".aac", ".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 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) {
|
||||
@@ -1700,132 +1020,6 @@ object NativeDownloadFinalizer {
|
||||
}
|
||||
}
|
||||
|
||||
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 preserveQualityVariant = input.request.optBoolean("allow_quality_variant", false)
|
||||
val uniqueWrite = if (preserveQualityVariant) {
|
||||
SafDownloadHandler.writeFileToSafUnique(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
fileName = finalName,
|
||||
mimeType = mimeType,
|
||||
srcPath = outputFile.absolutePath,
|
||||
preservedSuffix = qualityVariantFilenameLabel(state).orEmpty(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val newUri = uniqueWrite?.uri ?: if (!preserveQualityVariant) {
|
||||
SafDownloadHandler.writeFileToSaf(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
fileName = finalName,
|
||||
mimeType = mimeType,
|
||||
srcPath = outputFile.absolutePath,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
} ?: throw IllegalStateException("failed to publish deferred SAF output")
|
||||
val publishedName = uniqueWrite?.fileName ?: finalName
|
||||
|
||||
Log.i(TAG, "Published deferred SAF output once: file=$publishedName bytes=${outputFile.length()}")
|
||||
outputFile.delete()
|
||||
state.filePath = newUri
|
||||
state.fileName = publishedName
|
||||
input.result.put("file_path", newUri)
|
||||
input.result.put("file_name", publishedName)
|
||||
input.result.optJSONObject("replaygain")?.let { replayGain ->
|
||||
replayGain.put("file_path", newUri)
|
||||
replayGain.put("file_name", publishedName)
|
||||
}
|
||||
if (state.pendingExternalLrc != null) {
|
||||
state.pendingExternalLrcFileName = "${publishedName.replace(Regex("\\.[^.]+$"), "")}.lrc"
|
||||
}
|
||||
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 decryptionKeyCandidates(raw: String): List<String> {
|
||||
val candidates = linkedSetOf<String>()
|
||||
fun add(value: String) {
|
||||
@@ -2413,19 +1607,19 @@ object NativeDownloadFinalizer {
|
||||
return json
|
||||
}
|
||||
|
||||
private fun trackString(input: FinalizeInput, key: String, fallback: String): String =
|
||||
internal 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 =
|
||||
internal fun requestString(input: FinalizeInput, key: String): String =
|
||||
cleanMetadataString(input.request.optString(key, ""))
|
||||
|
||||
private fun resultString(input: FinalizeInput, key: String): String =
|
||||
internal fun resultString(input: FinalizeInput, key: String): String =
|
||||
cleanMetadataString(input.result.optString(key, ""))
|
||||
|
||||
private fun metadataCoverUrl(input: FinalizeInput): String =
|
||||
internal fun metadataCoverUrl(input: FinalizeInput): String =
|
||||
trackString(input, "coverUrl", requestString(input, "cover_url"))
|
||||
|
||||
private fun trackInt(input: FinalizeInput, key: String, fallback: Int): Int {
|
||||
internal fun trackInt(input: FinalizeInput, key: String, fallback: Int): Int {
|
||||
val value = input.track.optInt(key, 0)
|
||||
return if (value > 0) value else fallback
|
||||
}
|
||||
@@ -2445,7 +1639,7 @@ object NativeDownloadFinalizer {
|
||||
return if (kbps >= 16) kbps else null
|
||||
}
|
||||
|
||||
private fun positiveOrNull(primary: Int, fallback: Int): Int? {
|
||||
internal fun positiveOrNull(primary: Int, fallback: Int): Int? {
|
||||
val value = if (primary > 0) primary else fallback
|
||||
return if (value > 0) value else null
|
||||
}
|
||||
@@ -2460,5 +1654,5 @@ object NativeDownloadFinalizer {
|
||||
return if (trimmed.equals("null", ignoreCase = true)) "" else trimmed
|
||||
}
|
||||
|
||||
private fun q(value: String): String = "\"${value.replace("\"", "\\\"")}\""
|
||||
internal fun q(value: String): String = "\"${value.replace("\"", "\\\"")}\""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
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 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
|
||||
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
|
||||
|
||||
|
||||
// FFmpeg execution, probing, and container helpers for NativeDownloadFinalizer.
|
||||
|
||||
internal fun NativeDownloadFinalizer.isMP4ContainerFile(path: String): Boolean {
|
||||
return try {
|
||||
File(path).inputStream().use { stream ->
|
||||
val header = ByteArray(12)
|
||||
val read = stream.read(header)
|
||||
read >= 8 &&
|
||||
header[4] == 'f'.code.toByte() &&
|
||||
header[5] == 't'.code.toByte() &&
|
||||
header[6] == 'y'.code.toByte() &&
|
||||
header[7] == 'p'.code.toByte()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.formatForPath(path: String): String {
|
||||
return when (normalizeExt(File(path).extension)) {
|
||||
".mp3" -> "mp3"
|
||||
".opus", ".ogg" -> "opus"
|
||||
".m4a", ".mp4", ".aac" -> "m4a"
|
||||
else -> "flac"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.scanReplayGain(path: String, shouldCancel: () -> Boolean = { false }): NativeDownloadFinalizer.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 NativeDownloadFinalizer.ReplayGainScan(
|
||||
trackGain = "${if (gain >= 0) "+" else ""}${"%.2f".format(Locale.US, gain)} dB",
|
||||
trackPeak = "%.6f".format(Locale.US, peak),
|
||||
integratedLufs = integrated,
|
||||
truePeakLinear = peak,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.installNativeFFmpegCallbackFilter() {
|
||||
synchronized(ffmpegCompleteCallbackLock) {
|
||||
val current = FFmpegKitConfig.getFFmpegSessionCompleteCallback()
|
||||
if (current !== nativeFilteringFFmpegCompleteCallback) {
|
||||
forwardedFFmpegCompleteCallback = current
|
||||
FFmpegKitConfig.enableFFmpegSessionCompleteCallback(nativeFilteringFFmpegCompleteCallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.withFFmpegCommandPump(
|
||||
shouldCancel: () -> Boolean = { false },
|
||||
block: () -> String,
|
||||
): String {
|
||||
val running = AtomicBoolean(true)
|
||||
val handled = mutableSetOf<String>()
|
||||
val pump = Thread {
|
||||
while (running.get()) {
|
||||
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)
|
||||
// Every claimed command must get a result delivered to
|
||||
// the Go side, even on failure or cancellation: the
|
||||
// backend blocks until one arrives and never retries a
|
||||
// claimed id, so bailing out here would strand the
|
||||
// gomobile call the main thread is sitting in forever.
|
||||
val result = try {
|
||||
if (shouldCancel()) {
|
||||
Pair(false, "cancelled")
|
||||
} else {
|
||||
runFFmpeg(commandLine, shouldCancel)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Pair(false, e.message ?: "FFmpeg execution failed")
|
||||
}
|
||||
try {
|
||||
Gobackend.setFFmpegCommandResultByID(
|
||||
id,
|
||||
result.first,
|
||||
result.second,
|
||||
if (result.first) "" else result.second,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to deliver FFmpeg result for $id: ${e.message}")
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
try {
|
||||
Thread.sleep(100)
|
||||
} catch (_: InterruptedException) {
|
||||
// Keep pumping until `running` flips: on cancel the Go call
|
||||
// may still be waiting for a result for an in-flight
|
||||
// command, and it is delivered as failed above.
|
||||
}
|
||||
}
|
||||
}
|
||||
pump.isDaemon = true
|
||||
pump.start()
|
||||
return try {
|
||||
block()
|
||||
} finally {
|
||||
running.set(false)
|
||||
pump.interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Staged sibling name for conversion outputs: "song.flac" -> "song.partial.flac".
|
||||
* The ".partial<ext>" shape is ignored by library scans and duplicate checks,
|
||||
* while the real trailing extension still lets FFmpeg infer the muxer. A
|
||||
* process kill mid-conversion therefore never leaves a partial file under a
|
||||
* real audio name in the user's music folder.
|
||||
*/
|
||||
internal fun NativeDownloadFinalizer.stagedConversionPath(finalPath: String): String {
|
||||
val file = File(finalPath)
|
||||
val ext = file.extension
|
||||
val name = if (ext.isBlank()) "${file.name}.partial" else "${file.nameWithoutExtension}.partial.$ext"
|
||||
return File(file.parentFile, name).absolutePath
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.promoteStagedConversion(stagedPath: String, finalPath: String): Boolean {
|
||||
val staged = File(stagedPath)
|
||||
fsyncQuietly(staged)
|
||||
val final = File(finalPath)
|
||||
if (staged.renameTo(final)) return true
|
||||
return final.delete() && staged.renameTo(final)
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.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
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.desiredFileName(input: NativeDownloadFinalizer.FinalizeInput, state: NativeDownloadFinalizer.FinalizeState, extension: String): String {
|
||||
val ext = normalizeExt(extension).ifBlank { normalizeExt(File(state.fileName).extension).ifBlank { ".flac" } }
|
||||
val rawName = input.result.optString("quality_variant_file_name", "")
|
||||
.ifBlank { 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", ".aac", ".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"
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.shouldForceContainerConversion(input: NativeDownloadFinalizer.FinalizeInput, state: NativeDownloadFinalizer.FinalizeState): Boolean {
|
||||
if (input.result.optBoolean("requires_container_conversion", false)) return true
|
||||
if (input.request.optBoolean("requires_container_conversion", false)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.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.
|
||||
*/
|
||||
internal fun NativeDownloadFinalizer.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
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.requestedDecryptionOutputExt(input: NativeDownloadFinalizer.FinalizeInput): String {
|
||||
val descriptor = input.result.optJSONObject("decryption")
|
||||
return normalizeExt(
|
||||
descriptor?.optString("output_extension", "")
|
||||
?.ifBlank { input.result.optString("output_extension", "") }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
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 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
|
||||
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
|
||||
|
||||
|
||||
// Metadata embedding, cover download, external LRC, and quality-variant
|
||||
// filename helpers for NativeDownloadFinalizer.
|
||||
|
||||
internal fun NativeDownloadFinalizer.qualityVariantFilenameLabel(state: NativeDownloadFinalizer.FinalizeState): String? {
|
||||
return NativeFinalizationPolicy.qualityVariantFilenameLabel(
|
||||
measuredQuality = state.quality,
|
||||
bitDepth = state.bitDepth,
|
||||
sampleRate = state.sampleRate,
|
||||
bitrateKbps = state.bitrateKbps,
|
||||
audioCodec = state.audioCodec,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.finalizeQualityVariantFilename(
|
||||
context: Context,
|
||||
input: NativeDownloadFinalizer.FinalizeInput,
|
||||
state: NativeDownloadFinalizer.FinalizeState,
|
||||
) {
|
||||
if (!input.request.optBoolean("allow_quality_variant", false)) return
|
||||
val stagingLabel = input.request.optString("quality_variant", "").trim()
|
||||
val qualityLabel = qualityVariantFilenameLabel(state)
|
||||
if (qualityLabel == null) {
|
||||
Log.w(TAG, "Keeping temporary quality label because final audio specifications are unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
val preferredName = applyQualityVariantFilenameLabel(
|
||||
fileName = state.fileName,
|
||||
stagingLabel = stagingLabel,
|
||||
qualityLabel = qualityLabel,
|
||||
)
|
||||
if (preferredName == state.fileName) return
|
||||
input.result.put("quality_variant_file_name", preferredName)
|
||||
if (isDeferredSafPublish(input)) {
|
||||
state.fileName = preferredName
|
||||
return
|
||||
}
|
||||
|
||||
if (state.filePath.startsWith("content://")) {
|
||||
val tempPath = SafDownloadHandler.copyContentUriToTemp(context, state.filePath) ?: return
|
||||
try {
|
||||
val writeResult = SafDownloadHandler.writeFileToSafUnique(
|
||||
context = context,
|
||||
treeUriStr = input.request.optString("saf_tree_uri", ""),
|
||||
relativeDir = input.request.optString("saf_relative_dir", ""),
|
||||
fileName = preferredName,
|
||||
mimeType = mimeTypeForExt(File(preferredName).extension),
|
||||
srcPath = tempPath,
|
||||
preservedSuffix = qualityLabel,
|
||||
) ?: return
|
||||
SafDownloadHandler.deleteContentUri(context, state.filePath)
|
||||
state.filePath = writeResult.uri
|
||||
state.fileName = writeResult.fileName
|
||||
} finally {
|
||||
File(tempPath).delete()
|
||||
}
|
||||
} else {
|
||||
val source = File(state.filePath)
|
||||
val target = uniqueLocalFile(source.parentFile, preferredName)
|
||||
if (!source.renameTo(target)) {
|
||||
Log.w(TAG, "Could not rename quality variant output: ${source.absolutePath}")
|
||||
return
|
||||
}
|
||||
state.filePath = target.absolutePath
|
||||
state.fileName = target.name
|
||||
}
|
||||
|
||||
input.result.put("file_path", state.filePath)
|
||||
input.result.put("file_name", state.fileName)
|
||||
input.result.optJSONObject("replaygain")?.let { replayGain ->
|
||||
replayGain.put("file_path", state.filePath)
|
||||
replayGain.put("file_name", state.fileName)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.uniqueLocalFile(parent: File?, preferredName: String): File {
|
||||
val directory = parent ?: return File(preferredName)
|
||||
var candidate = File(directory, preferredName)
|
||||
if (!candidate.exists()) return candidate
|
||||
val dotIndex = preferredName.lastIndexOf('.')
|
||||
val hasExtension = dotIndex > 0
|
||||
val stem = if (hasExtension) preferredName.substring(0, dotIndex) else preferredName
|
||||
val extension = if (hasExtension) preferredName.substring(dotIndex) else ""
|
||||
var counter = 2
|
||||
while (candidate.exists()) {
|
||||
candidate = File(directory, "$stem ($counter)$extension")
|
||||
counter++
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.writeExternalLrc(context: Context, input: NativeDownloadFinalizer.FinalizeInput, state: NativeDownloadFinalizer.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)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.resolveLyricsLrc(input: NativeDownloadFinalizer.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) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.lyricsDurationMs(input: NativeDownloadFinalizer.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
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.embedBasicMetadata(context: Context, path: String, input: NativeDownloadFinalizer.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 lyricsMode = input.request.optString("lyrics_mode", "embed")
|
||||
val shouldResolveLyrics = input.request.optBoolean("embed_lyrics", false) &&
|
||||
(lyricsMode == "embed" || lyricsMode == "both")
|
||||
val lyrics = if (shouldResolveLyrics) resolveLyricsLrc(input) else ""
|
||||
val shouldEmbedLyrics = shouldResolveLyrics &&
|
||||
lyrics.isNotBlank() &&
|
||||
lyrics != "[instrumental:true]"
|
||||
// FLAC, MP3, Opus, and M4A all have native Go tag writers that edit the
|
||||
// tag block atomically without an ffmpeg remux (which drops foreign
|
||||
// frames and rewrites the whole container). The Go side answers
|
||||
// method=ffmpeg when it cannot handle the file natively.
|
||||
if (format == "flac" || format == "mp3" || format == "opus" || format == "m4a") {
|
||||
val nativeCover = downloadCoverForMetadata(context, input)
|
||||
val handledNatively = try {
|
||||
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 (nativeCover != null) fields.put("cover_path", nativeCover.absolutePath)
|
||||
if (shouldEmbedLyrics) {
|
||||
fields.put("lyrics", lyrics)
|
||||
fields.put("unsyncedlyrics", lyrics)
|
||||
}
|
||||
val response = Gobackend.editFileMetadata(path, fields.toString())
|
||||
val method = try {
|
||||
JSONObject(response).optString("method", "")
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
method != "ffmpeg"
|
||||
} catch (e: Exception) {
|
||||
if (format == "flac") throw e
|
||||
Log.w(TAG, "Native tag embed failed for $format: ${e.message}; falling back to ffmpeg")
|
||||
false
|
||||
} finally {
|
||||
nativeCover?.delete()
|
||||
}
|
||||
if (handledNatively) return
|
||||
}
|
||||
|
||||
val ext = normalizeExt(File(path).extension).ifBlank { ".tmp" }
|
||||
val inputFile = File(path)
|
||||
// ".partial<ext>" keeps the temp invisible to library scans while FFmpeg
|
||||
// still infers the muxer from the real trailing extension.
|
||||
val temp = File(inputFile.parentFile, "${inputFile.nameWithoutExtension}_tagged.partial$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
|
||||
|
||||
fun buildEmbedCommand(forceMov: Boolean): String {
|
||||
return 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 ${if (forceMov) "mov" else "mp4"} ${q(temp.absolutePath)} -y"
|
||||
} else {
|
||||
val movFlag = if (forceMov) "-f mov " else ""
|
||||
"-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags$movFlag${q(temp.absolutePath)} -y"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
var result = runFFmpeg(buildEmbedCommand(false))
|
||||
// MOV muxer fallback for codecs the MP4 muxer rejects (e.g. AC-4).
|
||||
if (!result.first && (isM4a || ext.equals(".mp4", ignoreCase = true))) {
|
||||
temp.delete()
|
||||
result = runFFmpeg(buildEmbedCommand(true))
|
||||
}
|
||||
if (result.first && temp.exists()) {
|
||||
fsyncQuietly(temp)
|
||||
// Rename directly over the original: a process kill between a
|
||||
// delete-first and the rename would lose the file entirely.
|
||||
adoptedTemp = temp.renameTo(inputFile)
|
||||
if (!adoptedTemp && inputFile.delete()) {
|
||||
originalDeleted = true
|
||||
adoptedTemp = temp.renameTo(inputFile)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!adoptedTemp && !originalDeleted) {
|
||||
temp.delete()
|
||||
}
|
||||
coverFile?.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort fsync so a file's bytes are durable before it is renamed
|
||||
* over another file; fsync on a fresh handle flushes the page cache pages
|
||||
* written earlier by ffmpeg in this process.
|
||||
*/
|
||||
internal fun NativeDownloadFinalizer.fsyncQuietly(file: File) {
|
||||
try {
|
||||
RandomAccessFile(file, "rw").use { it.fd.sync() }
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.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
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.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"
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.downloadCoverForMetadata(context: Context, input: NativeDownloadFinalizer.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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
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 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
|
||||
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
|
||||
|
||||
|
||||
// Deferred SAF publish helpers for NativeDownloadFinalizer.
|
||||
|
||||
internal fun NativeDownloadFinalizer.promoteStagedSafOutputIfNeeded(
|
||||
context: Context,
|
||||
input: NativeDownloadFinalizer.FinalizeInput,
|
||||
state: NativeDownloadFinalizer.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()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.isDeferredSafPublish(input: NativeDownloadFinalizer.FinalizeInput): Boolean {
|
||||
return input.request.optBoolean("defer_saf_publish", false) &&
|
||||
input.result.optBoolean("saf_deferred_publish", false)
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.isDeferredSafRequest(input: NativeDownloadFinalizer.FinalizeInput): Boolean {
|
||||
return input.request.optString("storage_mode", "") == "saf" &&
|
||||
input.request.optBoolean("defer_saf_publish", false)
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.publishDeferredSafOutput(
|
||||
context: Context,
|
||||
input: NativeDownloadFinalizer.FinalizeInput,
|
||||
state: NativeDownloadFinalizer.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 preserveQualityVariant = input.request.optBoolean("allow_quality_variant", false)
|
||||
val uniqueWrite = if (preserveQualityVariant) {
|
||||
SafDownloadHandler.writeFileToSafUnique(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
fileName = finalName,
|
||||
mimeType = mimeType,
|
||||
srcPath = outputFile.absolutePath,
|
||||
preservedSuffix = qualityVariantFilenameLabel(state).orEmpty(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val newUri = uniqueWrite?.uri ?: if (!preserveQualityVariant) {
|
||||
SafDownloadHandler.writeFileToSaf(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
fileName = finalName,
|
||||
mimeType = mimeType,
|
||||
srcPath = outputFile.absolutePath,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
} ?: throw IllegalStateException("failed to publish deferred SAF output")
|
||||
val publishedName = uniqueWrite?.fileName ?: finalName
|
||||
|
||||
Log.i(TAG, "Published deferred SAF output once: file=$publishedName bytes=${outputFile.length()}")
|
||||
outputFile.delete()
|
||||
state.filePath = newUri
|
||||
state.fileName = publishedName
|
||||
input.result.put("file_path", newUri)
|
||||
input.result.put("file_name", publishedName)
|
||||
input.result.optJSONObject("replaygain")?.let { replayGain ->
|
||||
replayGain.put("file_path", newUri)
|
||||
replayGain.put("file_name", publishedName)
|
||||
}
|
||||
if (state.pendingExternalLrc != null) {
|
||||
state.pendingExternalLrcFileName = "${publishedName.replace(Regex("\\.[^.]+$"), "")}.lrc"
|
||||
}
|
||||
input.result.put("saf_deferred_published", true)
|
||||
publishPendingDeferredExternalLrc(context, input, state)
|
||||
}
|
||||
|
||||
internal fun NativeDownloadFinalizer.publishPendingDeferredExternalLrc(
|
||||
context: Context,
|
||||
input: NativeDownloadFinalizer.FinalizeInput,
|
||||
state: NativeDownloadFinalizer.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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user