feat(backend): migrate production backend to Rust

Replace the Go backend with the Rust workspace and route Android/iOS through
UniFFI bindings. Include the migrated extension runtime, network, providers,
media metadata, downloads and library operations, with version 5.0.0+147.

Remove Go sources, adapters, native build selection and CI dependencies.
Build Rust unconditionally and lock the iOS Rust pod using a relative path.
Retain legacy source and migration evidence in a local ignored archive.

Validation: Android Kotlin compile and 53 native tests; Swift Rust-branch
equivalence and syntax; CocoaPods install; workflow, shell, Ruby, plist and
diff checks. Reuse the preceding 100 Rust tests, fmt/Clippy and five-ABI
build checkpoint; no new APK or iOS application build for this cleanup.

Known follow-up: URL/URLSearchParams globals are missing from the Rust JS
runtime. A controlled extension replay confirms a URL-resolution regression;
this commit does not fix that runtime gap.
This commit is contained in:
zarzet committed 2026-09-14 22:58:27 +07:00
1 parent 1da0da18a6
commit aa99726439
452 files changed
+64417 -65088

No files matched your search

@@ -0,0 +1,243 @@
package com.zarz.spotiflac
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.nio.file.Files
import java.nio.file.LinkOption
import java.util.UUID
import java.util.concurrent.CancellationException
import java.util.concurrent.atomic.AtomicBoolean
internal interface CoreDownloadProgress : AutoCloseable {
fun waitDelta(since: Long, timeoutMs: Long): String
}
internal data class CoreFFmpegCommand(
val id: String,
val arguments: Array<String>,
val outputPath: String = "",
)
internal interface CoreExtensionExecution : AutoCloseable {
fun download(requestJson: String): String
fun postProcess(inputJson: String, metadataJson: String): String
fun waitPending(timeoutMs: Long): List<CoreFFmpegCommand>
fun commandIsActive(commandId: String): Boolean
fun complete(commandId: String, success: Boolean, output: String, error: String)
}
internal fun parseCoreFFmpegCommands(raw: String): List<CoreFFmpegCommand> {
val commands = JSONArray(raw)
return (0 until commands.length()).mapNotNull { index ->
val command = commands.optJSONObject(index) ?: return@mapNotNull null
val id = command.optString("command_id", "")
if (id.isBlank()) return@mapNotNull null
val arguments = command.optJSONArray("arguments")
CoreFFmpegCommand(
id,
Array(arguments?.length() ?: 0) { arguments!!.optString(it, "") },
command.optString("output_path", ""),
)
}
}
private object CoreFFmpegStaging {
private val directories = mutableSetOf<String>()
private val prefix = ".spotiflac-ffmpeg-${BuildConfig.APPLICATION_ID}-"
@Synchronized
fun create(target: File): File {
val directory = requireNotNull(target.parentFile).canonicalFile
// Sweep only before this process starts using the directory. Later
// commands can share it with an FFmpeg operation that is still active.
if (directory.path !in directories) {
val entries = checkNotNull(directory.listFiles()) { "Cannot read FFmpeg output directory" }
for (file in entries) {
if (!file.name.startsWith(prefix)) continue
val suffix = file.name.removePrefix(prefix)
if (suffix.length <= 36 || suffix[36] != '.') continue
if (runCatching { UUID.fromString(suffix.take(36)) }.isFailure) continue
if (Files.isRegularFile(file.toPath(), LinkOption.NOFOLLOW_LINKS)) {
check(file.delete()) { "Failed to remove interrupted FFmpeg staging" }
}
}
directories.add(directory.path)
}
val staged = File(directory, "$prefix${UUID.randomUUID()}.${target.extension}")
check(staged.createNewFile()) { "Failed to create FFmpeg staging" }
return staged
}
}
internal fun executeCoreFFmpegCommand(
command: CoreFFmpegCommand,
cancelled: () -> Boolean,
execute: (Array<String>, () -> Boolean) -> Pair<Boolean, String>,
): Pair<Boolean, String> {
if (cancelled()) return false to "cancelled"
if (command.outputPath.isEmpty()) return execute(command.arguments, cancelled)
require(command.arguments.lastOrNull() == command.outputPath) { "FFmpeg output does not match command" }
val target = File(command.outputPath)
val staged = CoreFFmpegStaging.create(target)
try {
val arguments = command.arguments.copyOf()
arguments[arguments.lastIndex] = staged.absolutePath
val result = execute(arguments, cancelled)
if (cancelled()) return false to "cancelled"
if (!result.first) return result
// Rename within the same filesystem publishes only a completed file.
// An interrupted FFmpeg process can never become an existing-library hit.
check(staged.renameTo(target)) { "Failed to publish FFmpeg output" }
return result
} finally {
staged.delete()
}
}
internal fun withCoreFFmpegExecution(
execution: CoreExtensionExecution,
execute: (Array<String>, () -> Boolean) -> Pair<Boolean, String> = { arguments, cancelled ->
NativeDownloadFinalizer.runFFmpegArguments(arguments, cancelled, trackFinalizerSession = false)
},
block: (CoreExtensionExecution) -> String,
): String {
val running = AtomicBoolean(true)
val pump = Thread {
try {
while (running.get()) {
val commands = try {
execution.waitPending(1_000L)
} catch (_: Exception) {
break
}
// Finish every claimed command, even if our caller finishes first.
// Another operation on this owner may be waiting for its result.
for (command in commands) {
val cancelled = {
try { !execution.commandIsActive(command.id) } catch (_: Exception) { true }
}
val result = try {
when {
cancelled() -> false to "cancelled"
command.arguments.isEmpty() -> false to "FFmpeg arguments are empty"
else -> executeCoreFFmpegCommand(command, cancelled, execute)
}
} catch (error: Exception) {
false to (error.message ?: "FFmpeg execution failed")
}
try {
execution.complete(command.id, result.first, result.second, if (result.first) "" else result.second)
} catch (_: Exception) {
// Owner shutdown removes commands; late results cannot revive them.
}
}
}
} finally {
execution.close()
}
}
pump.isDaemon = true
try {
pump.start()
} catch (error: Throwable) {
execution.close()
throw error
}
return try {
block(execution)
} catch (error: Exception) {
// Both bindings expose the backend's cancellation sentinel as an error.
// Preserve its meaning for the worker's pause/retry state machine.
if (error.message == "download cancelled") {
throw CancellationException("download cancelled").apply { initCause(error) }
}
throw error
} finally {
// Do not interrupt a claimed command belonging to another operation.
running.set(false)
}
}
/** Native migration boundary; each process selects one stateful backend. */
internal interface CoreBackend {
val implementation: String
val routesApplication: Boolean get() = false
val supportsOutputDescriptors: Boolean get() = false
fun invokeApplication(method: String, arguments: Any?): Any? =
error("Application routing is unavailable for $method")
fun cleanupExtensions() {
invokeApplication("cleanupExtensions", null)
}
fun setRuntimeState(dataDirectory: String, payload: String) {
invokeApplication(
"prepareRuntimeState",
mapOf("data_dir" to dataDirectory, "runtime_state" to payload),
)
}
fun completeAuthCallback(state: String, code: String, sessionGrant: Boolean, onResolved: (String) -> Unit)
fun downloadByStrategy(requestJson: String): String =
withCoreFFmpegExecution(openExtensionExecution()) { it.download(requestJson) }
fun waitForDownloadProgressDelta(since: Long, timeoutMs: Long): String
fun openDownloadProgress(): CoreDownloadProgress = object : CoreDownloadProgress {
override fun waitDelta(since: Long, timeoutMs: Long) = waitForDownloadProgressDelta(since, timeoutMs)
override fun close() {}
}
fun initItemProgress(itemId: String)
fun clearItemProgress(itemId: String)
fun cancelDownload(itemId: String)
fun resetDownloadCancel(itemId: String)
fun openExtensionExecution(): CoreExtensionExecution
fun runPostProcessing(inputJson: String, metadataJson: String): String =
withCoreFFmpegExecution(openExtensionExecution()) { it.postProcess(inputJson, metadataJson) }
fun buildFilename(template: String, metadataJson: String): String
fun sanitizeFilename(filename: String): String
fun fileMetadataImplementation(path: String): String
fun readFileMetadata(path: String, hint: String): String
fun readAudioMetadata(path: String, hint: String, cacheKey: String): String
fun setLibraryCoverCacheDirectory(path: String)
fun scanLibraryFolder(folder: String): String
fun scanLibraryFolderToNdjsonFile(folder: String, output: String): Long
fun scanLibraryFolderIncremental(folder: String, existing: String): String
fun scanLibraryFolderIncrementalFromSnapshot(folder: String, snapshot: String): String
fun getLibraryScanProgress(): String
fun cancelLibraryScan()
fun parseCueSheet(path: String, audioDirectory: String): String
fun scanCueForLibrary(path: String, audioDirectory: String, virtualPrefix: String, modTime: Long, cacheKey: String): String
fun editFileMetadata(path: String, metadataJson: String): String
fun reEnrichFile(requestJson: String): String
fun rewriteSplitArtistTags(path: String, artist: String, albumArtist: String): String
fun extractCoverToFile(audioPath: String, outputPath: String)
fun writeM4aFreeformTags(path: String, metadataJson: String): String
fun ensureAc4Config(path: String, reference: String): String
fun writeAc4Metadata(path: String, metadataJson: String, coverPath: String): String
fun getLyricsLrc(spotifyId: String, trackName: String, artistName: String, filePath: String, durationMs: Long): String
fun downloadCoverToFileSized(url: String, outputPath: String, maxDimension: Long)
fun createTemporaryMediaFile(context: Context, prefix: String, suffix: String): File
fun openDownloadDirectory(path: String): AutoCloseable
fun openDownloadDirectoryForRequest(requestJson: String): AutoCloseable {
val request = JSONObject(requestJson)
return if (request.optString("storage_mode") == "saf") AutoCloseable {}
else openDownloadDirectory(request.getString("output_dir"))
}
fun releaseIdleResources()
fun releaseMemoryUnderPressure()
}
internal fun requireSuccessfulExtensionAction(extensionId: String, actionName: String, response: String) {
val obj = try {
JSONObject(response)
} catch (e: Exception) {
throw IllegalStateException(
"Extension $actionName for $extensionId returned invalid JSON: ${response.take(240)}"
)
}
if (obj.optBoolean("success", false)) {
return
}
val error = obj.optString("error")
.ifBlank { obj.optString("message") }
.ifBlank { response.take(240) }
throw IllegalStateException("Extension $actionName failed for $extensionId: $error")
}
@@ -18,7 +18,6 @@ import android.os.PowerManager
import android.os.SystemClock
import android.util.AtomicFile
import androidx.core.app.NotificationCompat
import gobackend.Gobackend
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -39,6 +38,7 @@ import org.json.JSONObject
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
/**
* Foreground service to keep downloads running when app is in background.
@@ -48,6 +48,7 @@ import java.util.concurrent.atomic.AtomicLong
* The service will be stopped automatically after 6 hours of cumulative runtime in 24 hours.
*/
class DownloadService : Service() {
internal val coreBackend: CoreBackend by lazy { createCoreBackend(applicationContext) }
companion object {
private const val CHANNEL_ID = "download_channel"
@@ -368,11 +369,14 @@ class DownloadService : Service() {
internal var nativeWorkerProgressSeq = 0L
internal val nativeWorkerProgressEpoch = AtomicLong(0L)
@Volatile internal var nativeWorkerProgressJob: Job? = null
@Volatile internal var nativeWorkerProgressConnection: AtomicReference<CoreDownloadProgress?>? = null
internal val snapshotWriteLock = Any()
internal val snapshotWriteSerial = AtomicLong(0L)
internal var latestCommittedStateSnapshotSerial = 0L
internal var latestCommittedProgressSnapshotSerial = 0L
@Volatile private var nativeWorkerPaused = false
private val nativeWorkerPausePendingIds = mutableSetOf<String>()
private var nativeWorkerResumePending = false
@Volatile internal var nativeWorkerNetworkPaused = false
@Volatile internal var nativeWorkerVerificationPaused = false
@Volatile private var nativeWorkerCancelRequested = false
@@ -383,7 +387,7 @@ class DownloadService : Service() {
// Bumped every time a new native queue replaces the current one. A worker
// coroutine that observes a different generation than its own must stop
// without touching the snapshot or the service lifecycle: cancel() alone
// cannot interrupt the blocking gomobile call it may be sitting in, and
// cannot interrupt the blocking native call it may be sitting in, and
// the shared pause/cancel flags get reset for the new run.
@Volatile private var nativeWorkerGeneration = 0L
@@ -458,7 +462,10 @@ class DownloadService : Service() {
)
}
ACTION_PAUSE_NATIVE_QUEUE -> {
nativeWorkerPaused = true
synchronized(nativeWorkerItems) {
nativeWorkerResumePending = false
nativeWorkerPaused = true
}
cancelActiveNativeItemForPause()
writeNativeWorkerSnapshotAsync(
isRunning = nativeWorkerJob?.isActive == true,
@@ -469,7 +476,10 @@ class DownloadService : Service() {
)
}
ACTION_RESUME_NATIVE_QUEUE -> {
nativeWorkerPaused = false
synchronized(nativeWorkerItems) {
nativeWorkerResumePending = nativeWorkerPausePendingIds.isNotEmpty()
nativeWorkerPaused = nativeWorkerResumePending
}
val stillPaused = isNativeWorkerPaused()
writeNativeWorkerSnapshotAsync(
isRunning = nativeWorkerJob?.isActive == true,
@@ -481,6 +491,10 @@ class DownloadService : Service() {
}
ACTION_CANCEL_NATIVE_QUEUE -> {
nativeWorkerCancelRequested = true
synchronized(nativeWorkerItems) {
nativeWorkerResumePending = false
nativeWorkerPausePendingIds.clear()
}
nativeWorkerVerificationPaused = false
nativeWorkerPreparationComplete = true
nativeWorkerRequestChannel?.close()
@@ -494,7 +508,7 @@ class DownloadService : Service() {
) {
item.status = "skipped"
try {
Gobackend.cancelDownload(item.itemId)
coreBackend.cancelDownload(item.itemId)
} catch (_: Exception) {
}
}
@@ -589,7 +603,7 @@ class DownloadService : Service() {
}
for (itemId in activeItemIds) {
try {
Gobackend.cancelDownload(itemId)
coreBackend.cancelDownload(itemId)
} catch (_: Exception) {
}
}
@@ -689,7 +703,7 @@ class DownloadService : Service() {
cancelNativeVerificationNotification()
// Abort the previous run's in-flight work before the shared flags are
// reset for the new run: the coroutine cancel below cannot interrupt a
// blocking gomobile download by itself.
// blocking native download by itself.
synchronized(nativeWorkerItems) {
for (item in nativeWorkerItems) {
if (item.status == "preparing" ||
@@ -697,7 +711,7 @@ class DownloadService : Service() {
item.status == "finalizing"
) {
try {
Gobackend.cancelDownload(item.itemId)
coreBackend.cancelDownload(item.itemId)
} catch (_: Exception) {
}
}
@@ -710,7 +724,11 @@ class DownloadService : Service() {
nativeWorkerGeneration++
val generation = nativeWorkerGeneration
nativeWorkerJob?.cancel(CancellationException("Native queue replaced"))
nativeWorkerPaused = false
synchronized(nativeWorkerItems) {
nativeWorkerPaused = false
nativeWorkerResumePending = false
nativeWorkerPausePendingIds.clear()
}
nativeWorkerNetworkPaused = false
nativeWorkerVerificationPaused = false
nativeWorkerCancelRequested = false
@@ -1016,10 +1034,13 @@ class DownloadService : Service() {
nativeWorkerItems
.filter {
it.itemId != excludeItemId &&
(it.status == "downloading" ||
it.itemId !in nativeWorkerPausePendingIds &&
(it.status == "preparing" ||
it.status == "downloading" ||
it.status == "finalizing")
}
.map { item ->
nativeWorkerPausePendingIds.add(item.itemId)
item.status = "queued"
item.progress = 0.0
item.bytesReceived = 0L
@@ -1030,7 +1051,7 @@ class DownloadService : Service() {
}
for (itemId in ids) {
try {
Gobackend.cancelDownload(itemId)
coreBackend.cancelDownload(itemId)
} catch (_: Exception) {
}
}
@@ -1039,6 +1060,19 @@ class DownloadService : Service() {
}
}
private fun finishNativePauseCancellation(itemId: String, generation: Long) {
synchronized(nativeWorkerItems) {
if (generation != nativeWorkerGeneration) return
nativeWorkerPausePendingIds.remove(itemId)
// Resume may arrive before the blocking download unwinds. Keep the
// pause reason until every cancelled attempt has decided to retry.
if (nativeWorkerResumePending && nativeWorkerPausePendingIds.isEmpty()) {
nativeWorkerResumePending = false
nativeWorkerPaused = false
}
}
}
private suspend fun processConcurrentNativeRequest(
request: NativeDownloadRequest,
settingsJson: String,
@@ -1059,7 +1093,9 @@ class DownloadService : Service() {
var progressInitialized = false
var retryCurrentRequest = false
var directoryScope: AutoCloseable? = null
try {
directoryScope = coreBackend.openDownloadDirectoryForRequest(request.requestJson)
// Acquire the provider permit first. If several requests from
// one provider are queued, they must not occupy every global
// network slot while waiting for that provider's lower limit.
@@ -1094,20 +1130,18 @@ class DownloadService : Service() {
settingsJson = settingsJson,
includeItems = true,
)
Gobackend.initItemProgress(request.itemId)
coreBackend.initItemProgress(request.itemId)
progressInitialized = true
currentStatus = "downloading"
updateNativeWorkerItem(request.itemId) {
it.status = "downloading"
}
try {
SafDownloadHandler.handle(this, request.requestJson) { json ->
Gobackend.downloadByStrategy(json)
}
SafDownloadHandler.handle(this, request.requestJson, coreBackend)
} finally {
updateNativeWorkerItemProgress(request.itemId)
try {
Gobackend.clearItemProgress(request.itemId)
coreBackend.clearItemProgress(request.itemId)
} catch (_: Exception) {
}
progressInitialized = false
@@ -1169,22 +1203,6 @@ class DownloadService : Service() {
} else {
val errorType = result.optString("error_type")
val errorMessage = result.optString("error")
if (errorType == "cancelled" &&
!isNativeWorkerPaused() &&
!nativeWorkerCancelRequested &&
generation == nativeWorkerGeneration
) {
var waitedMs = 0L
while (waitedMs < 1500 &&
!isNativeWorkerPaused() &&
!nativeWorkerCancelRequested &&
generation == nativeWorkerGeneration
) {
delay(100)
waitedMs += 100
}
}
if (errorType == "cancelled" &&
isNativeWorkerPaused() &&
!nativeWorkerCancelRequested
@@ -1315,10 +1333,12 @@ class DownloadService : Service() {
includeItems = true,
)
} finally {
directoryScope?.close()
finishNativePauseCancellation(request.itemId, generation)
if (progressInitialized) {
updateNativeWorkerItemProgress(request.itemId)
try {
Gobackend.clearItemProgress(request.itemId)
coreBackend.clearItemProgress(request.itemId)
} catch (_: Exception) {
}
}
@@ -1466,11 +1486,11 @@ class DownloadService : Service() {
includeItems = true
)
var directoryScope: AutoCloseable? = null
try {
Gobackend.initItemProgress(request.itemId)
val response = SafDownloadHandler.handle(this, request.requestJson) { json ->
Gobackend.downloadByStrategy(json)
}
directoryScope = coreBackend.openDownloadDirectoryForRequest(request.requestJson)
coreBackend.initItemProgress(request.itemId)
val response = SafDownloadHandler.handle(this, request.requestJson, coreBackend)
if (generation != nativeWorkerGeneration) {
// Superseded while blocked in the download call; the
// new run owns the shared state now.
@@ -1521,27 +1541,6 @@ class DownloadService : Service() {
} else {
val errorType = result.optString("error_type")
val errorMessage = result.optString("error")
if (errorType == "cancelled" &&
!isNativeWorkerPaused() &&
!nativeWorkerCancelRequested &&
generation == nativeWorkerGeneration
) {
// A pause from Dart cancels the in-flight Go
// download directly but delivers the pause flag
// via a startService intent through the main
// looper; the download can unwind first. Give the
// flag a moment to settle before classifying this
// cancellation as a permanent skip.
var waitedMs = 0L
while (waitedMs < 1500 &&
!isNativeWorkerPaused() &&
!nativeWorkerCancelRequested &&
generation == nativeWorkerGeneration
) {
delay(100)
waitedMs += 100
}
}
if (errorType == "cancelled" &&
isNativeWorkerPaused() &&
!nativeWorkerCancelRequested
@@ -1675,9 +1674,11 @@ class DownloadService : Service() {
includeItems = true
)
} finally {
directoryScope?.close()
finishNativePauseCancellation(request.itemId, generation)
updateNativeWorkerItemProgress(request.itemId)
try {
Gobackend.clearItemProgress(request.itemId)
coreBackend.clearItemProgress(request.itemId)
} catch (_: Exception) {
}
}
@@ -1722,9 +1723,9 @@ class DownloadService : Service() {
private fun releaseIdleDownloadMemory() {
try {
// All workers and album tagging have finished. Return unused Go
// heap without forcing a collection between individual tracks.
Gobackend.releaseMemory()
// All workers and album tagging have finished. Release idle backend
// resources once for the batch, preserving caches between tracks.
coreBackend.releaseIdleResources()
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "Failed to release idle download memory: ${e.message}")
}
@@ -17,7 +17,6 @@ import android.os.IBinder
import android.os.PowerManager
import android.util.AtomicFile
import androidx.core.app.NotificationCompat
import gobackend.Gobackend
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -17,7 +17,6 @@ import android.os.IBinder
import android.os.PowerManager
import android.util.AtomicFile
import androidx.core.app.NotificationCompat
import gobackend.Gobackend
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -17,7 +17,6 @@ import android.os.IBinder
import android.os.PowerManager
import android.util.AtomicFile
import androidx.core.app.NotificationCompat
import gobackend.Gobackend
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -31,6 +30,7 @@ import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
// Native-worker item state snapshots for the Flutter side.
@@ -243,24 +243,26 @@ internal fun DownloadService.updateNativeWorkerItem(itemId: String, updater: (Do
}
/**
* Polls the exported Go delta API once for the whole native queue. Workers
* Polls the selected backend delta API once for the whole native queue. Workers
* update their item state from [nativeWorkerProgressItems] instead of making
* independent full-payload calls. The generation check prevents a delayed
* gomobile call from an old queue from contaminating a replacement queue.
* native call from an old queue from contaminating a replacement queue.
*/
internal fun DownloadService.startNativeWorkerProgressCoordinator(generation: Long): Job {
nativeWorkerProgressJob?.cancel()
stopNativeWorkerProgressCoordinator()
val coordinatorEpoch = nativeWorkerProgressEpoch.incrementAndGet()
synchronized(nativeWorkerProgressLock) {
nativeWorkerProgressItems.clear()
nativeWorkerProgressSeq = 0L
}
val connection = AtomicReference<CoreDownloadProgress?>(null)
nativeWorkerProgressConnection = connection
val job = serviceScope.launch {
val lastSignatures = mutableMapOf<String, String?>()
while (isActive && isNativeWorkerProgressActive(generation)) {
maintainNativeWorkerWakeLock()
val changedItemIds = pollNativeWorkerProgress(generation)
val changedItemIds = pollNativeWorkerProgress(generation, connection)
val snapshotItemIds = mutableListOf<String>()
for (itemId in changedItemIds) {
if (!updateNativeWorkerItemProgress(itemId, emitNotification = false)) {
@@ -305,6 +307,7 @@ internal fun DownloadService.startNativeWorkerProgressCoordinator(generation: Lo
delay(1000)
}
}
job.invokeOnCompletion { connection.getAndSet(null)?.close() }
nativeWorkerProgressJob = job
return job
}
@@ -313,6 +316,8 @@ internal fun DownloadService.stopNativeWorkerProgressCoordinator(job: Job? = nat
if (job == null) return
if (nativeWorkerProgressJob === job) {
nativeWorkerProgressJob = null
nativeWorkerProgressConnection?.getAndSet(null)?.close()
nativeWorkerProgressConnection = null
nativeWorkerProgressEpoch.incrementAndGet()
}
job.cancel()
@@ -326,14 +331,29 @@ internal fun DownloadService.cancelNativeWorkerProgressCoordinator() {
}
}
private fun DownloadService.pollNativeWorkerProgress(generation: Long): Set<String> {
private fun DownloadService.pollNativeWorkerProgress(
generation: Long,
connection: AtomicReference<CoreDownloadProgress?>,
): Set<String> {
val sinceSeq = synchronized(nativeWorkerProgressLock) { nativeWorkerProgressSeq }
val raw = try {
Gobackend.waitForAllDownloadProgressDelta(sinceSeq, 5_000L)
val reader = connection.get() ?: coreBackend.openDownloadProgress().also { connection.set(it) }
if (nativeWorkerProgressConnection !== connection || !isNativeWorkerProgressActive(generation)) {
connection.getAndSet(null)?.close()
return emptySet()
}
reader.waitDelta(sinceSeq, 5_000L)
} catch (_: Exception) {
connection.getAndSet(null)?.close()
synchronized(nativeWorkerProgressLock) {
if (nativeWorkerProgressConnection === connection) {
nativeWorkerProgressItems.clear()
nativeWorkerProgressSeq = 0L
}
}
return emptySet()
}
if (raw.isBlank() || !isNativeWorkerProgressActive(generation)) return emptySet()
if (raw.isBlank() || nativeWorkerProgressConnection !== connection || !isNativeWorkerProgressActive(generation)) return emptySet()
return try {
val root = JSONObject(raw)
@@ -366,11 +386,11 @@ private fun DownloadService.pollNativeWorkerProgress(generation: Long): Set<Stri
}
}
synchronized(nativeWorkerProgressLock) {
if (!isNativeWorkerProgressActive(generation)) return emptySet()
if (nativeWorkerProgressConnection !== connection || !isNativeWorkerProgressActive(generation)) return emptySet()
if (reset) nativeWorkerProgressItems.clear()
nativeWorkerProgressItems.putAll(updated)
removed.forEach { nativeWorkerProgressItems.remove(it) }
if (nextSeq > nativeWorkerProgressSeq) {
if (reset || nextSeq > nativeWorkerProgressSeq) {
nativeWorkerProgressSeq = nextSeq
}
}
File diff suppressed because it is too large. Load diff
@@ -22,7 +22,6 @@ import io.flutter.embedding.engine.FlutterShellArgs
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import com.ryanheise.audioservice.AudioServicePlugin
import gobackend.Gobackend
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -135,8 +134,8 @@ internal fun MainActivity.copyUriToTemp(uri: Uri, fallbackExt: String? = null):
val extFromName = extFromFileName(nameHint)
val extFromMime = extFromMimeType(mime)
val ext = if (extFromName.isNotBlank()) extFromName else if (extFromMime.isNotBlank()) extFromMime else (fallbackExt ?: "")
val suffix: String? = if (ext.isNotBlank()) ext else null
tempFile = File.createTempFile("saf_", suffix, cacheDir)
val suffix = ext.ifBlank { ".tmp" }
tempFile = coreBackend.createTemporaryMediaFile(this, "saf_", suffix)
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(tempFile).use { output ->
@@ -156,7 +155,6 @@ internal fun MainActivity.copyUriToTemp(uri: Uri, fallbackExt: String? = null):
)
val result = copyMediaStoreUriToTemp(uri, fallbackExt)
if (result != null) {
success = true
return result
}
}
@@ -190,8 +188,8 @@ internal fun MainActivity.copyMediaStoreUriToTemp(uri: Uri, fallbackExt: String?
var tempFile: File? = null
try {
val ext = resolveMediaStoreExt(uri, fallbackExt)
val suffix: String? = if (ext.isNotBlank()) ext else null
tempFile = File.createTempFile("ms_", suffix, cacheDir)
val suffix = ext.ifBlank { ".tmp" }
tempFile = coreBackend.createTemporaryMediaFile(this, "ms_", suffix)
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(tempFile).use { output ->
@@ -295,7 +293,7 @@ internal fun MainActivity.readAudioMetadataFromUri(
uri, displayNameHint, fallbackExt,
acceptDirect = { !it.optBoolean("metadataFromFilename", false) },
) { path, name ->
val obj = JSONObject(Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON(
val obj = JSONObject(coreBackend.readAudioMetadata(
path, name, coverCacheKey,
))
obj.takeUnless { it.has("error") }
@@ -305,7 +303,7 @@ internal fun MainActivity.readCompleteMetadataFromUri(
uri: Uri,
displayNameHint: String? = null,
): JSONObject? = readMetadataFromUri(uri, displayNameHint) { path, name ->
JSONObject(Gobackend.readFileMetadataWithHint(path, name)).takeUnless { it.has("error") }
JSONObject(coreBackend.readFileMetadata(path, name)).takeUnless { it.has("error") }
}
internal fun MainActivity.writeUriFromPath(uri: Uri, srcPath: String): Boolean {
@@ -388,20 +386,14 @@ internal fun MainActivity.writeSafSidecarLrc(audioUri: Uri, lrcContent: String):
}
}
internal fun MainActivity.runPostProcessingSafV2(fileUriStr: String, metadataJson: String): String {
internal fun MainActivity.runPostProcessingSafV2(fileUriStr: String, metadataJson: String, itemId: String): String {
val uri = Uri.parse(fileUriStr)
val doc = DocumentFile.fromSingleUri(this, uri)
?: return errorJson("SAF file not found")
val tempInput = copyUriToTemp(uri) ?: return errorJson("Failed to copy SAF file to temp")
val tempDir = File(tempInput).parentFile?.absolutePath ?: ""
if (tempDir.isNotBlank()) {
try {
Gobackend.allowDownloadDir(tempDir)
} catch (_: Exception) {}
}
val inputObj = JSONObject()
inputObj.put("item_id", itemId)
inputObj.put("path", tempInput)
inputObj.put("uri", fileUriStr)
inputObj.put("name", doc.name ?: File(tempInput).name)
@@ -409,7 +401,7 @@ internal fun MainActivity.runPostProcessingSafV2(fileUriStr: String, metadataJso
inputObj.put("size", doc.length())
inputObj.put("is_saf", true)
val response = Gobackend.runPostProcessingV2JSON(inputObj.toString(), metadataJson)
val response = coreBackend.runPostProcessing(inputObj.toString(), metadataJson)
val respObj = JSONObject(response)
if (!respObj.optBoolean("success", false)) {
try {
@@ -22,7 +22,6 @@ import io.flutter.embedding.engine.FlutterShellArgs
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import com.ryanheise.audioservice.AudioServicePlugin
import gobackend.Gobackend
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -81,7 +80,7 @@ internal fun MainActivity.readLibraryScanProgressJsonForStream(): String {
return if (safScanActive) {
safProgressToJson()
} else {
Gobackend.getLibraryScanProgressJSON()
coreBackend.getLibraryScanProgress()
}
}
@@ -533,11 +532,8 @@ internal fun MainActivity.extractCueAudioFileName(cueTempPath: String): String?
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac"
)
// Audio file extensions that the local library scanner accepts. Must stay in
// sync with supportedAudioFormats in go_backend/library_scan.go so that every
// format the Go engine can read (FLAC, M4A/MP4/AAC, MP3, Opus/OGG, APE/WV/MPC,
// WAV, AIFF) is also enumerated here during the SAF folder walk. (.cue is
// handled separately.)
// Keep the SAF folder walk aligned with the backend's supported audio formats.
// CUE files are handled separately.
private val libraryScanAudioExtensions = setOf(
".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg",
".ape", ".wv", ".mpc", ".wav", ".aiff", ".aif"
@@ -972,7 +968,7 @@ internal fun MainActivity.scanSafTree(
val cueLastModified = cue.lastModified
val cueResultsJson = Gobackend.scanCueSheetForLibraryWithCoverCacheKey(
val cueResultsJson = coreBackend.scanCueForLibrary(
tempCuePath,
tempDir,
cueDoc.uri.toString(),
@@ -1404,7 +1400,7 @@ internal fun MainActivity.scanSafTreeIncremental(
tempAudioPath = renamedAudio.absolutePath
}
val cueResultsJson = Gobackend.scanCueSheetForLibraryWithCoverCacheKey(
val cueResultsJson = coreBackend.scanCueForLibrary(
tempCuePath,
tempDir,
cueDoc.uri.toString(),
@@ -20,7 +20,6 @@ import com.zarz.spotiflac.NativeFinalizationPolicy.displayAudioQuality
import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag
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
@@ -289,6 +288,14 @@ object NativeDownloadFinalizer {
promoteStagedSafOutputIfNeeded(context, effectiveInput, state)
}
outputPublished = true
} else {
// Match the Dart queue: an existing download still runs enabled
// extension hooks. It may be the input left by an interrupted
// finalizer, so file existence does not prove the hook finished.
// outputPublished keeps this pre-existing input out of cleanup.
checkCancelled(shouldCancel)
runPostProcessing(context, effectiveInput, state, shouldCancel)
checkCancelled(shouldCancel)
}
if (!qualityMetadataRefreshed) {
try {
@@ -917,14 +924,14 @@ object NativeDownloadFinalizer {
private fun writeReplayGainFields(context: Context, path: String, fields: JSONObject) {
if (!path.startsWith("content://")) {
writeLocalReplayGainFields(path, fields)
writeLocalReplayGainFields(context, path, fields)
return
}
val tempPath = SafDownloadHandler.copyContentUriToTemp(context, path)
?: throw IllegalStateException("failed to copy SAF file for ReplayGain write")
try {
writeLocalReplayGainFields(tempPath, fields)
writeLocalReplayGainFields(context, tempPath, fields)
val uri = Uri.parse(path)
context.contentResolver.openOutputStream(uri, "wt")?.use { output ->
File(tempPath).inputStream().use { input -> input.copyTo(output) }
@@ -935,8 +942,9 @@ object NativeDownloadFinalizer {
}
}
private fun writeLocalReplayGainFields(path: String, fields: JSONObject) {
val result = parseObject(Gobackend.editFileMetadata(path, fields.toString()))
private fun writeLocalReplayGainFields(context: Context, path: String, fields: JSONObject) {
val backend = createCoreBackend(context)
val result = parseObject(backend.editFileMetadata(path, fields.toString()))
val method = result.optString("method", "")
check(
result.optBoolean("success", false) &&
@@ -944,7 +952,7 @@ object NativeDownloadFinalizer {
(method == "native" || method.startsWith("native_")),
) { "ReplayGain native write did not complete: $result" }
val metadata = parseObject(Gobackend.readFileMetadata(path))
val metadata = parseObject(backend.readFileMetadata(path, ""))
check(!metadata.has("error")) { "ReplayGain verification failed: $metadata" }
val isOpus = metadata.optString("audio_codec", "") == "opus"
for (key in fields.keys()) {
@@ -970,7 +978,7 @@ object NativeDownloadFinalizer {
val deleteProbePath = probePath != state.filePath
try {
val metadata = parseObject(Gobackend.readFileMetadata(probePath))
val metadata = parseObject(createCoreBackend(context).readFileMetadata(probePath, state.fileName))
if (metadata.has("error")) return
if (metadata.has("lyrics") || metadata.has("hasLyrics")) {
@@ -1080,18 +1088,15 @@ object NativeDownloadFinalizer {
?: throw IllegalStateException("failed to copy SAF file for post-processing")
try {
val inputObj = JSONObject()
.put("item_id", input.itemId)
.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)
val response = JSONObject(createCoreBackend(context).runPostProcessing(inputObj.toString(), metadata.toString()))
checkCancelled(shouldCancel)
if (!response.optBoolean("success", false)) return
val newPath = response.optString("new_file_path", "")
@@ -1118,19 +1123,25 @@ object NativeDownloadFinalizer {
}
val inputObj = JSONObject()
.put("item_id", input.itemId)
.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)
val response = JSONObject(createCoreBackend(context).runPostProcessing(inputObj.toString(), metadata.toString()))
checkCancelled(shouldCancel)
if (response.optBoolean("success", false)) {
val newPath = response.optString("new_file_path", "")
if (newPath.isNotBlank() && newPath != state.filePath) {
if (isDeferredSafPublish(input)) {
val output = File(newPath)
check(output.isFile && output.length() > 0L) {
"post-processing output missing or empty"
}
// This input is an owned staging file; publication later
// removes the replacement, so retire the old stage now.
File(state.filePath).delete()
}
state.filePath = newPath
state.fileName = File(newPath).name
}
@@ -22,7 +22,6 @@ 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
@@ -31,7 +30,6 @@ 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
@@ -99,7 +97,7 @@ internal fun NativeDownloadFinalizer.runFFmpeg(command: String, shouldCancel: ()
return runFFmpegArguments(FFmpegKitConfig.parseArguments(command), shouldCancel)
}
internal fun NativeDownloadFinalizer.runFFmpegArguments(arguments: Array<String>, shouldCancel: () -> Boolean = { false }): Pair<Boolean, String> {
internal fun NativeDownloadFinalizer.runFFmpegArguments(arguments: Array<String>, shouldCancel: () -> Boolean = { false }, trackFinalizerSession: Boolean = true): Pair<Boolean, String> {
checkCancelled(shouldCancel)
installNativeFFmpegCallbackFilter()
val latch = CountDownLatch(1)
@@ -116,7 +114,7 @@ internal fun NativeDownloadFinalizer.runFFmpegArguments(arguments: Array<String>
)
val sessionId = session.sessionId
synchronized(activeFFmpegSessionLock) {
activeFFmpegSessionIds.add(sessionId)
if (trackFinalizerSession) activeFFmpegSessionIds.add(sessionId)
}
nativeFFmpegSessionIds.add(sessionId)
FFmpegKitConfig.asyncFFmpegExecute(session)
@@ -157,71 +155,6 @@ internal fun NativeDownloadFinalizer.installNativeFFmpegCallbackFilter() {
}
}
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.waitForPendingFFmpegCommandsJSON(1_000L)
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 rawArguments = command.optJSONArray("arguments")
val arguments = if (rawArguments == null) {
emptyArray()
} else {
Array(rawArguments.length()) { argumentIndex ->
rawArguments.optString(argumentIndex, "")
}
}
if (id.isBlank() || arguments.isEmpty() || arguments.any { it.isEmpty() } || 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 {
runFFmpegArguments(arguments, 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) {
}
}
}
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,
@@ -26,7 +26,6 @@ import com.zarz.spotiflac.NativeFinalizationPolicy.removeQualityVariantStagingLa
import com.zarz.spotiflac.NativeFinalizationPolicy.resolveQualityVariantFilename
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
@@ -176,7 +175,7 @@ internal fun NativeDownloadFinalizer.writeExternalLrc(context: Context, input: N
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)
val lrc = resolveLyricsLrc(context, input)
if (
!NativeFinalizationPolicy.hasUsableLyricsContent(lrc) ||
lrc.trim().equals("[instrumental:true]", ignoreCase = true)
@@ -217,7 +216,7 @@ internal fun NativeDownloadFinalizer.writeExternalLrc(context: Context, input: N
}
}
internal fun NativeDownloadFinalizer.resolveLyricsLrc(input: NativeDownloadFinalizer.FinalizeInput): String {
internal fun NativeDownloadFinalizer.resolveLyricsLrc(context: Context, input: NativeDownloadFinalizer.FinalizeInput): String {
val existing = input.result.optString("lyrics_lrc", "").trim()
if (existing.isNotEmpty()) return existing
@@ -227,7 +226,7 @@ internal fun NativeDownloadFinalizer.resolveLyricsLrc(input: NativeDownloadFinal
if (trackName.isBlank() || artistName.isBlank()) return ""
return try {
val fetched = Gobackend.getLyricsLRC(
val fetched = createCoreBackend(context).getLyricsLrc(
spotifyId,
trackName,
artistName,
@@ -303,13 +302,13 @@ internal fun NativeDownloadFinalizer.embedBasicMetadata(context: Context, path:
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 lyrics = if (shouldResolveLyrics) resolveLyricsLrc(context, input) else ""
val shouldEmbedLyrics = shouldResolveLyrics &&
NativeFinalizationPolicy.hasUsableLyricsContent(lyrics) &&
!lyrics.trim().equals("[instrumental:true]", ignoreCase = true)
// FLAC, MP3, Opus, and M4A all have native Go tag writers that edit the
// FLAC, MP3, Opus, and M4A all have backend 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
// frames and rewrites the whole container). The backend answers
// method=ffmpeg when it cannot handle the file natively.
if (format == "flac" || format == "mp3" || format == "opus" || format == "m4a") {
val nativeCover = downloadCoverForMetadata(context, input)
@@ -339,7 +338,7 @@ internal fun NativeDownloadFinalizer.embedBasicMetadata(context: Context, path:
fields.put("lyrics", lyrics)
fields.put("unsyncedlyrics", lyrics)
}
val response = Gobackend.editFileMetadata(path, fields.toString())
val response = createCoreBackend(context).editFileMetadata(path, fields.toString())
val method = try {
JSONObject(response).optString("method", "")
} catch (_: Exception) {
@@ -509,9 +508,10 @@ internal fun NativeDownloadFinalizer.downloadCoverForMetadata(context: Context,
val safeItemId = input.itemId.ifBlank { "item" }.replace(Regex("[^A-Za-z0-9._-]"), "_")
val maxDimension = input.request.optLong("cover_max_dimension", 0L).coerceAtLeast(0L)
val output = File.createTempFile("native_cover_${safeItemId}_", ".jpg", context.cacheDir)
val backend = createCoreBackend(context)
val output = backend.createTemporaryMediaFile(context, "native_cover_${safeItemId}_", ".jpg")
return try {
Gobackend.downloadCoverToFileSized(
backend.downloadCoverToFileSized(
coverUrl,
output.absolutePath,
maxDimension
@@ -23,7 +23,6 @@ import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec
import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec
import com.zarz.spotiflac.NativeFinalizationPolicy.removeQualityVariantStagingLabel
import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension
import gobackend.Gobackend
import org.json.JSONObject
import java.io.File
import java.io.RandomAccessFile
@@ -55,26 +55,26 @@ object SafDownloadHandler {
}
}
fun handle(context: Context, requestJson: String, downloader: (String) -> String): String {
internal fun handle(context: Context, requestJson: String, backend: CoreBackend): String {
val req = JSONObject(requestJson)
val storageMode = req.optString("storage_mode", "")
val treeUriStr = req.optString("saf_tree_uri", "")
if (storageMode != "saf" || treeUriStr.isBlank()) {
return downloader(requestJson)
return backend.downloadByStrategy(requestJson)
}
val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", ""))
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
val fileName = buildSafFileName(req, outputExt)
return withSafNameLock(treeUriStr, relativeDir, fileName) {
handleSafLocked(context, req, downloader, treeUriStr, relativeDir, outputExt, fileName)
handleSafLocked(context, req, backend, treeUriStr, relativeDir, outputExt, fileName)
}
}
private fun handleSafLocked(
context: Context,
req: JSONObject,
downloader: (String) -> String,
backend: CoreBackend,
treeUriStr: String,
relativeDir: String,
outputExt: String,
@@ -112,12 +112,12 @@ object SafDownloadHandler {
if (deferSafPublish) {
existingDir?.let { deleteStaleStagedFiles(it, fileName, outputExt) }
val workingExt = outputExt.ifBlank { ".tmp" }
val workingFile = File.createTempFile("native_saf_work_", workingExt, context.cacheDir)
val workingFile = backend.createTemporaryMediaFile(context, "native_saf_work_", workingExt)
return try {
req.put("output_path", workingFile.absolutePath)
req.put("output_ext", outputExt)
req.remove("output_fd")
val response = downloader(req.toString())
val response = backend.downloadByStrategy(req.toString())
val respObj = JSONObject(response)
if (respObj.optBoolean("success", false)) {
val resolvedFileName = respObj.optString("resolved_file_name", "")
@@ -162,16 +162,32 @@ object SafDownloadHandler {
var document = createOrReuseDocumentFile(targetDir, stagedMimeType, stagedFileName)
?: return errorJson("Failed to create SAF file")
val pfd = context.contentResolver.openFileDescriptor(document.uri, "rw")
?: return errorJson("Failed to open SAF file")
var pfd: android.os.ParcelFileDescriptor? = null
var detachedFd: Int? = null
var workingFile: File? = null
try {
detachedFd = pfd.detachFd()
req.put("output_path", "")
req.put("output_fd", detachedFd)
if (backend.supportsOutputDescriptors) {
val descriptor = context.contentResolver.openFileDescriptor(document.uri, "rw")
?: throw IllegalStateException("Failed to open SAF file")
pfd = descriptor
detachedFd = descriptor.detachFd()
req.put("output_path", "")
req.put("output_fd", detachedFd)
} else {
// The OS adapter retains descriptor ownership. A path-based
// backend writes into its granted staging directory, then the
// existing SAF copy/promotion publishes the completed output.
val staged = backend.createTemporaryMediaFile(
context,
"native_saf_work_",
outputExt.ifBlank { ".tmp" },
)
workingFile = staged
req.put("output_path", staged.absolutePath)
req.remove("output_fd")
}
req.put("output_ext", outputExt)
val response = downloader(req.toString())
val response = backend.downloadByStrategy(req.toString())
val respObj = JSONObject(response)
if (respObj.optBoolean("success", false)) {
val resolvedFileName = respObj.optString("resolved_file_name", "").trim()
@@ -180,15 +196,16 @@ object SafDownloadHandler {
} else {
fileName
}
val goFilePath = respObj.optString("file_path", "")
if (goFilePath.isNotEmpty() &&
!goFilePath.startsWith("content://") &&
!goFilePath.startsWith("/proc/self/fd/")
) {
val backendFilePath = respObj.optString("file_path", "")
val localFilePath = backendFilePath.takeIf {
it.isNotEmpty() && !it.startsWith("content://") &&
!it.startsWith("/proc/self/fd/")
} ?: workingFile?.absolutePath
if (localFilePath != null) {
try {
val srcFile = File(goFilePath)
val srcFile = File(localFilePath)
if (!srcFile.exists() || srcFile.length() <= 0) {
throw IllegalStateException("extension output missing or empty: $goFilePath")
throw IllegalStateException("extension output missing or empty: $localFilePath")
}
val actualExt = normalizeExt(srcFile.extension)
if (actualExt.isNotBlank()) {
@@ -259,9 +276,10 @@ object SafDownloadHandler {
document.delete()
return errorJson("SAF download failed: ${e.message}")
} finally {
workingFile?.delete()
if (detachedFd == null) {
try {
pfd.close()
pfd?.close()
} catch (_: Exception) {
}
}
@@ -315,7 +333,7 @@ object SafDownloadHandler {
?.takeIf { it.isNotBlank() }
?.let { ".$it" }
?: ".tmp"
val createdTemp = File.createTempFile("native_saf_", extension, context.cacheDir)
val createdTemp = createCoreBackend(context).createTemporaryMediaFile(context, "native_saf_", extension)
temp = createdTemp
context.contentResolver.openInputStream(uri)?.use { input ->
createdTemp.outputStream().use { output ->
@@ -1,5 +1,7 @@
package com.zarz.spotiflac
import java.io.File
/** Failure belongs to this read, never to a different document or provider.
* The direct callback owns and closes its descriptor before fallback starts. */
internal fun <T> readSafMetadataWithFallback(
@@ -10,3 +12,17 @@ internal fun <T> readSafMetadataWithFallback(
if (direct != null) return direct
return try { fallbackRead() } catch (_: Exception) { null }
}
internal fun <T> readLyricsWithSafCopy(
path: String,
copyToTemp: (String) -> File?,
read: (String) -> T,
): T? {
if (!path.startsWith("content://")) return read(path)
val temporary = copyToTemp(path) ?: return null
return try {
read(temporary.absolutePath)
} finally {
temporary.delete()
}
}