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()
}
}
@@ -0,0 +1,813 @@
package com.zarz.spotiflac
import android.content.Context
import android.net.Uri
import com.spotiflac.backend.CancellationDomain
import com.spotiflac.backend.CancellationRegistry
import com.spotiflac.backend.ExtensionManager
import com.spotiflac.backend.ExtensionRepository
import com.spotiflac.backend.LyricsRequest
import com.spotiflac.backend.RequestLease
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.security.MessageDigest
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
internal fun createCoreBackend(context: Context): CoreBackend = RustCoreBackend.initialize(context)
internal suspend fun MainActivity.dispatchBackendApplication(call: MethodCall, result: MethodChannel.Result): Boolean {
val response = withContext(Dispatchers.IO) {
when (call.method) {
"getLyricsLRC", "getLyricsLRCWithSource" -> {
val arguments = call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()
val path = arguments["file_path"] as? String ?: ""
readLyricsWithSafCopy(
path,
copyToTemp = { copyUriToTemp(Uri.parse(it))?.let(::File) },
read = { localPath ->
coreBackend.invokeApplication(call.method, arguments + ("file_path" to localPath))
},
) ?: if (call.method == "getLyricsLRC") "" else {
"""{"lyrics":"","source":"","sync_type":"","instrumental":false}"""
}
}
else -> coreBackend.invokeApplication(call.method, call.arguments)
}
}
result.success(response)
return true
}
internal object RustCoreBackend : CoreBackend {
override val implementation = "rust"
override val routesApplication = true
private lateinit var root: File
private var manager: ExtensionManager? = null
private var repository: ExtensionRepository? = null
private var requests: CancellationRegistry? = null
private var identity: Triple<String, String, List<Byte>>? = null
private var loggingEnabled = false
private var allowPrivateNetwork = false
private var allowHttpFallback = false
private var fallbackProviders: List<String>? = null
private var runtimeState: Pair<String, String>? = null
private val directoryScopes = mutableMapOf<String, AutoCloseable>()
private var libraryCoverScope: AutoCloseable? = null
@Synchronized
fun initialize(context: Context): RustCoreBackend {
if (!::root.isInitialized) root = File(context.applicationContext.cacheDir, "rust-core-pilot")
return this
}
@Synchronized
private fun owner(): ExtensionManager {
return checkNotNull(manager) { "Rust backend is not initialized" }
}
private fun directoryAliases(value: String, sources: String, data: String): List<String> {
require(File(value).isAbsolute) { "Output directories must be absolute" }
val path = File(value).canonicalPath
require(path != "/" && listOf(sources, data).none {
path == it || path.startsWith("$it/") || it.startsWith("$path/")
}) { "Output directory overlaps extension storage" }
return listOf(path, File(value).absolutePath).distinct()
}
@Synchronized
override fun openDownloadDirectory(path: String): AutoCloseable {
val current = owner()
val storage = checkNotNull(identity)
val scope = current.environment().use {
it.grantDownloadDirectories(directoryAliases(path, storage.first, storage.second))
}
return object : AutoCloseable {
private var released = false
@Synchronized
override fun close() {
if (released) return
released = true
try { scope.release() } finally { scope.close() }
}
}
}
@Synchronized
private fun setDownloadDirectory(path: String) {
val current = owner()
val storage = checkNotNull(identity)
val files = File(root, "files")
val allowed = listOf(files.canonicalPath, files.absolutePath) +
if (path.isEmpty()) emptyList() else directoryAliases(path, storage.first, storage.second)
current.environment().use { it.setAllowedDownloadDirectories(allowed.distinct()) }
}
@Synchronized
private fun requestRegistry(): CancellationRegistry = requests ?: CancellationRegistry(
CancellationDomain.EXTENSION_REQUEST,
).also { requests = it }
@Synchronized
private fun acquireRequest(id: String): Pair<ExtensionManager, RequestLease> =
owner() to requestRegistry().acquire(id)
@Synchronized
private fun repositoryOwner(): ExtensionRepository =
checkNotNull(repository) { "Extension repository is not initialized" }
@Synchronized
private fun initializeRepository(cachePath: String) {
val current = owner()
if (repository != null) return
val cache = File(cachePath)
require(cache.isAbsolute) { "Repository cache directory must be absolute" }
repository = ExtensionRepository(current, cache.canonicalPath)
}
@Synchronized
private fun initializeOwner(arguments: Map<*, *>) {
val sourcePath = File(arguments["extensions_dir"] as String)
val dataPath = File(arguments["data_dir"] as String)
require(sourcePath.isAbsolute && dataPath.isAbsolute) { "Extension storage directories must be absolute" }
val sources = sourcePath.canonicalPath
val data = dataPath.canonicalPath
val key = arguments["master_key"] as String
val requested = Triple(
sources,
data,
MessageDigest.getInstance("SHA-256").digest(key.toByteArray()).toList(),
)
val files = File(root, "files")
check(files.mkdirs() || files.isDirectory)
val rawDirectories = arguments["allowed_directories"]
require(rawDirectories == null || rawDirectories is List<*>) { "Invalid output directories" }
val allowedDirectories = listOf(files.canonicalPath, files.absolutePath) +
(rawDirectories as? List<*>).orEmpty().flatMap { value ->
require(value is String) { "Output directories must be strings" }
directoryAliases(value, sources, data)
}
if (manager != null) {
check(identity == requested) { "Rust backend is already initialized with different storage" }
manager!!.environment().use { it.setAllowedDownloadDirectories(allowedDirectories.distinct()) }
return
}
val preparedRuntime = runtimeState
require(preparedRuntime == null || preparedRuntime.first == data) {
"Runtime state directory does not match the initialized owner"
}
val created = ExtensionManager.withLyricsSettings(
sources,
data,
key,
BuildConfig.VERSION_NAME,
30_000uL,
arguments["lyrics_providers_json"] as? String ?: "[]",
arguments["lyrics_options_json"] as? String ?: "{}",
)
try {
created.environment().use { environment ->
environment.setAllowedDownloadDirectories(allowedDirectories.distinct())
environment.setAllowPrivateNetwork(allowPrivateNetwork)
environment.setNetworkCompatibilityOptions(allowHttpFallback, false)
environment.logBuffer().use { it.setEnabled(loggingEnabled) }
preparedRuntime?.let { environment.setRuntimeState(it.second) }
}
created.setFallbackProviders(fallbackProviders)
} catch (error: Exception) {
created.shutdown()
created.close()
throw error
}
identity = requested
manager = created
}
@Synchronized
private fun shutdownOwner() {
requests?.let {
it.shutdown()
it.close()
}
requests = null
repository?.let {
it.shutdown()
it.close()
}
repository = null
manager?.let {
it.shutdown()
it.close()
}
manager = null
directoryScopes.values.forEach { it.close() }
directoryScopes.clear()
libraryCoverScope?.close()
libraryCoverScope = null
identity = null
runtimeState = null
}
override fun fileMetadataImplementation(path: String): String = "rust"
override fun readFileMetadata(path: String, hint: String): String =
com.spotiflac.backend.readFileMetadata(path, hint, null)
override fun readAudioMetadata(path: String, hint: String, cacheKey: String): String =
owner().readAudioMetadata(File(path).canonicalPath, hint, cacheKey, null)
private fun <T> withLibraryDirectories(paths: List<String>, block: (ExtensionManager) -> T): T {
val (current, scope) = synchronized(this) {
val current = owner()
val storage = checkNotNull(identity)
val aliases = paths.flatMap { directoryAliases(it, storage.first, storage.second) }.distinct()
current to current.environment().use { it.grantDownloadDirectories(aliases) }
}
return scope.use {
try { block(current) } finally { it.release() }
}
}
@Synchronized
override fun setLibraryCoverCacheDirectory(path: String) {
val current = owner()
require(path.isEmpty() || File(path).isAbsolute) { "Library cover directory must be absolute" }
val directory = if (path.isEmpty()) null else File(path).canonicalFile
if (directory != null) check(directory.mkdirs() || directory.isDirectory)
val next = directory?.let { openDownloadDirectory(it.path) }
try { current.setLibraryCoverCacheDirectory(directory?.path.orEmpty()) }
catch (error: Exception) { next?.close(); throw error }
libraryCoverScope?.close()
libraryCoverScope = next
}
override fun scanLibraryFolder(folder: String): String = withLibraryDirectories(listOf(folder)) {
it.scanLibraryFolder(File(folder).canonicalPath, null)
}
override fun scanLibraryFolderToNdjsonFile(folder: String, output: String): Long =
withLibraryDirectories(listOf(folder)) { current ->
require(File(output).isAbsolute && File(output).extension.equals("ndjson", ignoreCase = true)) {
"Library scan output must be an absolute NDJSON path"
}
// The support directory also contains extension storage. Keep the
// Rust write inside its existing private staging root, then publish
// the completed result through the native app's file access.
val staged = File.createTempFile("library_scan_", ".ndjson", File(root, "files"))
try {
val count = current.scanLibraryFolderToNdjsonFile(File(folder).canonicalPath, staged.canonicalPath, null)
check(staged.renameTo(File(output))) { "Failed to publish library scan output" }
count.toLong()
} finally { staged.delete() }
}
override fun scanLibraryFolderIncremental(folder: String, existing: String): String =
withLibraryDirectories(listOf(folder)) {
it.scanLibraryFolderIncremental(File(folder).canonicalPath, existing, null)
}
override fun scanLibraryFolderIncrementalFromSnapshot(folder: String, snapshot: String): String =
withLibraryDirectories(listOf(folder)) { current ->
if (snapshot.isEmpty()) current.scanLibraryFolderIncremental(File(folder).canonicalPath, "{}", null)
else {
val staged = File.createTempFile("library_snapshot_", ".ndjson", File(root, "files"))
try {
File(snapshot).copyTo(staged, overwrite = true)
current.scanLibraryFolderIncrementalFromSnapshot(File(folder).canonicalPath, staged.canonicalPath, null)
} finally { staged.delete() }
}
}
override fun getLibraryScanProgress(): String =
synchronized(this) { manager }?.getLibraryScanProgress() ?: "{}"
override fun cancelLibraryScan() { synchronized(this) { manager }?.cancelLibraryScan() }
override fun parseCueSheet(path: String, audioDirectory: String): String {
val cue = File(path).canonicalFile
val audio = if (audioDirectory.isEmpty()) cue.parentFile!! else File(audioDirectory).canonicalFile
return withLibraryDirectories(listOf(cue.parent, audio.path)) {
it.parseCueFileJson(cue.path, audio.path, null)
}
}
override fun scanCueForLibrary(path: String, audioDirectory: String, virtualPrefix: String, modTime: Long, cacheKey: String): String {
val cue = File(path).canonicalFile
val audio = if (audioDirectory.isEmpty()) cue.parentFile!! else File(audioDirectory).canonicalFile
return withLibraryDirectories(listOf(cue.parent, audio.path)) {
it.scanCueFileForLibrary(cue.path, audio.path, virtualPrefix, modTime, cacheKey, java.time.Instant.now().toString(), null)
}
}
override fun editFileMetadata(path: String, metadataJson: String): String =
owner().editFileMetadata(File(path).canonicalPath, metadataJson, null)
private fun mediaPath(path: String): String = if (path.isEmpty()) "" else File(path).canonicalPath
override fun reEnrichFile(requestJson: String): String {
val request = JSONObject(requestJson)
val path = request.opt("file_path") as? String
if (!request.optBoolean("preview_only", false) && path?.startsWith("/") == true) {
request.put("file_path", mediaPath(path))
}
return owner().reenrichFile(request.toString(), null)
}
override fun rewriteSplitArtistTags(path: String, artist: String, albumArtist: String): String =
owner().rewriteSplitArtistTags(mediaPath(path), artist, albumArtist, null)
override fun extractCoverToFile(audioPath: String, outputPath: String) {
owner().extractCoverToFile(mediaPath(audioPath), mediaPath(outputPath), null)
}
override fun writeM4aFreeformTags(path: String, metadataJson: String): String =
owner().writeM4aFreeformTags(mediaPath(path), metadataJson, null)
override fun ensureAc4Config(path: String, reference: String): String =
owner().ensureAc4Config(mediaPath(path), mediaPath(reference), null)
override fun writeAc4Metadata(path: String, metadataJson: String, coverPath: String): String =
owner().writeAc4Metadata(mediaPath(path), metadataJson, mediaPath(coverPath), null)
override fun getLyricsLrc(
spotifyId: String,
trackName: String,
artistName: String,
filePath: String,
durationMs: Long,
): String = owner().getLyricsLrc(
LyricsRequest(spotifyId, trackName, artistName, filePath, durationMs),
null,
)
override fun downloadCoverToFileSized(
url: String,
outputPath: String,
maxDimension: Long,
) = owner().downloadCoverToFileSized(
url,
File(outputPath).canonicalPath,
maxDimension,
null,
)
override fun releaseIdleResources() {
owner().releaseMemory(false)
}
override fun releaseMemoryUnderPressure() {
owner().releaseMemory(true)
}
override fun createTemporaryMediaFile(
context: Context,
prefix: String,
suffix: String,
): File {
owner()
return File.createTempFile(prefix, suffix, File(root, "files"))
}
override fun openExtensionExecution(): CoreExtensionExecution {
val current = owner()
val commands = current.environment().use { it.ffmpegCommands() }
return object : CoreExtensionExecution {
override fun download(requestJson: String): String = current.downloadByStrategy(requestJson)
override fun postProcess(inputJson: String, metadataJson: String): String =
current.runPostProcessing(inputJson, metadataJson, 120_000uL)
override fun waitPending(timeoutMs: Long): List<CoreFFmpegCommand> =
parseCoreFFmpegCommands(commands.waitPending(timeoutMs))
override fun commandIsActive(commandId: String): Boolean = commands.getCommand(commandId).isNotEmpty()
override fun complete(commandId: String, success: Boolean, output: String, error: String) {
commands.complete(commandId, success, output, error)
}
override fun close() { commands.close() }
}
}
override fun waitForDownloadProgressDelta(since: Long, timeoutMs: Long): String {
val current = owner()
return current.environment().use { environment ->
environment.downloadState().use { state ->
state.waitProgressDelta(since, timeoutMs)
}
}
}
override fun openDownloadProgress(): CoreDownloadProgress {
val current = owner()
val subscription = current.environment().use { environment ->
environment.downloadState().use { it.subscribeProgress() }
}
return object : CoreDownloadProgress {
override fun waitDelta(since: Long, timeoutMs: Long): String = subscription.waitDelta(since, timeoutMs)
override fun close() {
subscription.stop()
subscription.close()
}
}
}
override fun initItemProgress(itemId: String) {
val current = owner()
current.environment().use { environment ->
environment.downloadState().use { state ->
state.initItemProgress(itemId)
}
}
}
override fun clearItemProgress(itemId: String) {
val current = owner()
current.environment().use { environment ->
environment.downloadState().use { state ->
state.clearItemProgress(itemId)
}
}
}
override fun cancelDownload(itemId: String) {
val current = owner()
current.environment().use { environment ->
environment.downloadState().use { state ->
state.cancelDownload(itemId)
}
}
}
override fun resetDownloadCancel(itemId: String) {
val current = owner()
current.environment().use { environment ->
environment.downloadState().use { state ->
state.resetDownloadCancel(itemId)
}
}
}
override fun completeAuthCallback(state: String, code: String, sessionGrant: Boolean, onResolved: (String) -> Unit) {
val current = owner()
current.environment().use { environment ->
val id = if (sessionGrant) environment.resolveCallbackState(state) else environment.consumeCallbackState(state)
onResolved(id)
if (sessionGrant) {
completeSessionGrant(current, id, code)
} else {
environment.setAuthCode(id, code)
current.invokeAction(id, "completeSpotifyLogin")
}
}
}
private fun completeSessionGrant(current: ExtensionManager, id: String, grant: String) {
current.environment().use { it.setSessionGrant(id, grant) }
requireSuccessfulExtensionAction(id, "completeGrant", current.invokeAction(id, "completeGrant"))
}
override fun invokeApplication(method: String, arguments: Any?): Any? {
val args = arguments as? Map<*, *> ?: emptyMap<Any, Any>()
fun string(key: String, default: String = "") = args[key] as? String ?: default
fun lyricsRequest(fileKey: String) = LyricsRequest(
string("spotify_id"),
string("track_name"),
string("artist_name"),
string(fileKey),
(args["duration_ms"] as? Number)?.toLong() ?: 0L,
)
fun ids(raw: String): List<String> {
val values = JSONArray(raw)
return (0 until values.length()).map { values.getString(it) }
}
when (method) {
"cancelExtensionRequest" -> synchronized(this) {
requestRegistry().cancel(string("request_id"))
return null
}
"customSearchWithExtension", "getExtensionHomeFeed" -> {
val (current, lease) = acquireRequest(string("request_id"))
return lease.use {
try {
if (method == "customSearchWithExtension") {
current.customSearchJson(string("extension_id"), string("query"), string("options"), it)
} else {
current.getExtensionHomeFeedJson(string("extension_id"), it)
}
} finally {
it.release()
}
}
}
"prepareRuntimeState" -> synchronized(this) {
val directory = File(string("data_dir"))
require(directory.isAbsolute) { "Extension data directory must be absolute" }
val data = directory.canonicalPath
check(manager == null || identity?.second == data) {
"Runtime state directory does not match the initialized owner"
}
val raw = string("runtime_state")
manager?.environment()?.use { it.setRuntimeState(raw) }
runtimeState = data to raw
return null
}
"initExtensionSystem" -> {
initializeOwner(args)
return null
}
"setDownloadDirectory" -> {
setDownloadDirectory(string("path"))
return null
}
"acquireDownloadDirectory" -> synchronized(this) {
val scope = openDownloadDirectory(string("path"))
val token = UUID.randomUUID().toString()
directoryScopes[token] = scope
return token
}
"releaseDownloadDirectory" -> synchronized(this) {
directoryScopes.remove(string("token"))?.close()
return null
}
"cleanupExtensions" -> {
shutdownOwner()
return null
}
"buildFilename" -> return buildFilename(string("template"), string("metadata", "{}"))
"sanitizeFilename" -> return sanitizeFilename(string("filename"))
"readFileMetadata", "editFileMetadata" -> return try {
if (method == "readFileMetadata") {
readFileMetadata(string("file_path"), string("display_name"))
} else {
editFileMetadata(string("file_path"), string("metadata_json", "{}"))
}
} catch (error: Exception) {
JSONObject().put("error", error.message ?: "File metadata operation failed").toString()
}
"getLogsSince" -> return synchronized(this) {
manager?.environment()?.use { environment ->
environment.logBuffer().use { it.since((args["index"] as? Number)?.toLong() ?: 0L) }
} ?: """{"logs":[],"next_index":0}"""
}
"clearLogs" -> return synchronized(this) {
manager?.environment()?.use { environment -> environment.logBuffer().use { it.clear() } }
null
}
"setLoggingEnabled", "setAllowPrivateNetwork", "setDownloadFallbackExtensionIds",
"setNetworkCompatibilityOptions", "setSongLinkNetworkOptions",
"setLyricsProviders", "setLyricsFetchOptions" -> synchronized(this) {
when (method) {
"setLoggingEnabled" -> {
val enabled = args["enabled"] as? Boolean ?: false
manager?.environment()?.use { environment ->
environment.logBuffer().use { it.setEnabled(enabled) }
}
loggingEnabled = enabled
}
"setAllowPrivateNetwork" -> {
val allowed = args["allowed"] as? Boolean ?: false
manager?.environment()?.use { it.setAllowPrivateNetwork(allowed) }
allowPrivateNetwork = allowed
}
"setNetworkCompatibilityOptions", "setSongLinkNetworkOptions" -> {
val allowed = args["allow_http"] as? Boolean ?: false
val insecureTls = args["insecure_tls"] as? Boolean ?: false
manager?.environment()?.use { it.setNetworkCompatibilityOptions(allowed, insecureTls) }
allowHttpFallback = allowed
}
"setDownloadFallbackExtensionIds" -> {
val raw = string("extension_ids")
val value = if (raw.isBlank() || raw.trim() == "null") null else ids(raw)
manager?.setFallbackProviders(value)
fallbackProviders = value
}
"setLyricsProviders" -> {
val raw = string("providers_json", "[]")
if (manager != null) manager!!.setLyricsProvidersJson(raw) else ids(raw)
return """{"success":true}"""
}
"setLyricsFetchOptions" -> {
val raw = string("options_json", "{}")
if (manager != null) manager!!.setLyricsFetchOptionsJson(raw) else JSONObject(raw)
return """{"success":true}"""
}
}
return null
}
"initExtensionRepo" -> {
initializeRepository(string("cache_dir"))
return null
}
"setRepoRegistryUrl" -> {
repositoryOwner().setRegistryUrl(string("registry_url"))
return null
}
"getRepoRegistryUrl" -> return repositoryOwner().registryUrl()
"clearRepoRegistryUrl" -> {
repositoryOwner().clearRegistryUrl()
return null
}
"getRepoExtensions" -> return repositoryOwner().extensions(
args["force_refresh"] as? Boolean ?: false,
)
"searchRepoExtensions" -> return repositoryOwner().search(
string("query"),
string("category"),
)
"getRepoCategories" -> return JSONArray(repositoryOwner().categories()).toString()
"downloadRepoExtension" -> return repositoryOwner().download(
string("extension_id"),
string("dest_dir"),
)
"clearRepoCache" -> {
repositoryOwner().clearCache()
return null
}
}
val current = owner()
return when (method) {
"loadExtensionsFromDir" -> {
synchronized(this) {
check(File(string("dir_path")).canonicalPath == identity?.first) { "Extension source directory does not match the initialized owner" }
}
current.loadAll()
}
"loadExtensionFromPath" -> current.install(string("file_path"))
"upgradeExtension" -> current.upgrade(string("file_path"))
"checkExtensionUpgrade" -> current.checkUpgrade(string("file_path"))
"getInstalledExtensions" -> current.installed()
"setExtensionEnabled" -> {
current.setEnabled(string("extension_id"), args["enabled"] as? Boolean ?: false)
null
}
"unloadExtension" -> {
current.unload(string("extension_id"))
null
}
"removeExtension" -> {
current.remove(string("extension_id"))
null
}
"getExtensionSettings" -> current.environment().use { it.settings(string("extension_id")) }
"setExtensionSettings" -> {
current.updateSettings(string("extension_id"), string("settings", "{}"))
null
}
"invokeExtensionAction" -> current.invokeAction(string("extension_id"), string("action"))
"checkExtensionHealth" -> current.checkExtensionHealthJson(string("extension_id"))
"setProviderPriority", "setMetadataProviderPriority" -> {
current.setProviderPriority(if (method == "setProviderPriority") "download" else "metadata", ids(string("priority", "[]")))
null
}
"getProviderPriority", "getMetadataProviderPriority" -> {
JSONObject(current.providerPriorities()).optJSONArray(if (method == "getProviderPriority") "download" else "metadata")?.toString() ?: "[]"
}
"getLyricsProviders" -> current.getLyricsProvidersJson()
"getLyricsFetchOptions" -> current.getLyricsFetchOptionsJson()
"getAvailableLyricsProviders" -> current.getAvailableLyricsProvidersJson()
"searchTracksWithMetadataProviders" -> current.searchMetadataProviders(
string("query"),
(args["limit"] as? Number)?.toLong() ?: 20L,
args["include_extensions"] as? Boolean ?: true,
"",
30_000uL,
)
"searchTracksWithMetadataProvider" -> current.searchMetadataProvider(
string("extension_id"),
string("query"),
(args["limit"] as? Number)?.toLong() ?: 20L,
30_000uL,
)
"getProviderMetadata" -> current.getProviderMetadataJson(
string("provider_id"),
string("resource_type"),
string("resource_id"),
null,
)
"searchDeezerByISRC" -> current.searchDeezerByIsrcForItemId(
string("isrc"),
string("item_id"),
null,
)
"getDeezerExtendedMetadata" -> current.getDeezerExtendedMetadata(
string("track_id"),
null,
)
"convertSpotifyToDeezer" -> current.convertSpotifyToDeezer(
string("resource_type"),
string("spotify_id"),
null,
)
"getSpotifyIDFromDeezerTrack" -> current.getSpotifyIdFromDeezerTrack(
string("deezer_track_id"),
null,
)
"getTidalURLFromDeezerTrack" -> current.getTidalUrlFromDeezerTrack(
string("deezer_track_id"),
null,
)
"getTrackPlatformLinks" -> current.getTrackPlatformLinksJson(
string("spotify_id"),
string("isrc"),
null,
)
"fetchMusicBrainzTags" -> {
val genre = try {
current.fetchMusicBrainzGenreByIsrc(string("isrc"), null)
} catch (_: Exception) {
""
}
val albumArtist = try {
current.fetchMusicBrainzAlbumArtistByIsrc(
string("isrc"),
string("album_name"),
null,
)
} catch (_: Exception) {
""
}
JSONObject()
.put("genre", genre)
.put("album_artist", albumArtist)
.toString()
}
"getTrackCacheSize" -> current.getTrackCacheSize().toInt()
"clearTrackCache" -> {
current.clearTrackIdCache()
null
}
"setMetadataLanguage" -> {
current.setMetadataLanguage(string("tag"))
null
}
"findURLHandler" -> current.findUrlHandler(string("url")) ?: ""
"handleURLWithExtension" -> current.handleUrlJson(string("url"))
"enrichTrackWithExtension" -> current.enrichTrackJson(string("extension_id"), string("track", "{}"))
"getExtensionPendingAuth" -> current.getExtensionPendingAuthJson(string("extension_id")).ifEmpty { null }
"setExtensionAuthCode" -> current.environment().use { it.setAuthCode(string("extension_id"), string("auth_code")); null }
"completeExtensionSessionGrant" -> {
completeSessionGrant(current, string("extension_id"), string("grant"))
true
}
"setExtensionTokens" -> current.environment().use {
it.setAuthTokens(string("extension_id"), string("access_token"), string("refresh_token"), (args["expires_in"] as? Int)?.toLong() ?: 0L)
null
}
"clearExtensionPendingAuth" -> current.environment().use { it.clearPendingAuth(string("extension_id")); null }
"isExtensionAuthenticated" -> current.environment().use { it.isAuthenticated(string("extension_id")) }
"getAllPendingAuthRequests" -> current.environment().use { it.allPendingAuth() }
"getAllDownloadProgress" -> current.environment().use { environment ->
environment.downloadState().use { state -> state.allProgress() }
}
"cleanupConnections" -> current.environment().use { it.cleanupConnections(); null }
"getPendingFFmpegCommand", "getAllPendingFFmpegCommands", "setFFmpegCommandResult" -> current.environment().use { environment ->
environment.ffmpegCommands().use { commands ->
when (method) {
"getPendingFFmpegCommand" -> commands.getCommand(string("command_id"))
"getAllPendingFFmpegCommands" -> commands.pending()
else -> {
commands.complete(string("command_id"), args["success"] as? Boolean ?: false, string("output"), string("error"))
null
}
}
}
}
"clearItemProgress", "cancelDownload", "resetDownloadCancel" -> current.environment().use { environment ->
environment.downloadState().use { state ->
when (method) {
"clearItemProgress" -> state.clearItemProgress(string("item_id"))
"cancelDownload" -> state.cancelDownload(string("item_id"))
else -> state.resetDownloadCancel(string("item_id"))
}
null
}
}
"getLyricsLRC" -> current.getLyricsLrc(lyricsRequest("file_path"), null)
"getLyricsLRCWithSource" -> current.getLyricsLrcWithSource(lyricsRequest("file_path"), null)
"embedLyricsToFile" -> current.embedLyricsToFile(
string("file_path"),
string("lyrics"),
null,
)
"fetchAndSaveLyrics" -> try {
current.fetchAndSaveLyrics(lyricsRequest("audio_file_path"), string("output_path"), null)
"""{"success":true}"""
} catch (error: Exception) {
JSONObject().put("success", false).put("error", error.message ?: "Lyrics operation failed").toString()
}
"findCollectionAcrossExtensions" -> current.findCollectionAcrossExtensionsJson(
arguments as? String ?: "{}",
null,
)
else -> error("Rust application method is not connected yet: $method")
}
}
override fun buildFilename(template: String, metadataJson: String): String =
com.spotiflac.backend.buildFilename(template, metadataJson)
override fun sanitizeFilename(filename: String): String =
com.spotiflac.backend.sanitizeFilename(filename)
}
@@ -0,0 +1,528 @@
package com.zarz.spotiflac
import java.io.File
import java.nio.file.Files
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CountDownLatch
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
private const val TIMEOUT_SECONDS = 2L
private fun createTempDirectory(): File {
val directory = File.createTempFile("core-ffmpeg-command-", "")
check(directory.delete()) { "failed to remove temporary file" }
check(directory.mkdir()) { "failed to create temporary directory" }
return directory
}
private fun awaitWorkerLatch(
latch: CountDownLatch,
name: String,
failures: AtomicReference<Throwable?>,
): Boolean {
val reached = latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)
if (!reached) {
failures.compareAndSet(null, AssertionError("worker timed out waiting for $name"))
}
return reached
}
private data class Completion(
val success: Boolean,
val output: String,
val error: String,
)
private class FakeCoreExtensionExecution : CoreExtensionExecution {
private class PendingBatch(val commands: List<CoreFFmpegCommand>)
private val batches = LinkedBlockingQueue<PendingBatch>()
private val closedBatch = PendingBatch(emptyList())
private val active = ConcurrentHashMap.newKeySet<String>()
private val completions = ConcurrentHashMap<String, Completion>()
private val completionLatches = ConcurrentHashMap<String, CountDownLatch>()
private val closed = AtomicBoolean(false)
val waitEntered = CountDownLatch(1)
val closedHandle = CountDownLatch(1)
val secondClose = CountDownLatch(1)
val closeCalls = AtomicInteger(0)
fun enqueue(vararg commands: CoreFFmpegCommand) {
commands.forEach { command ->
active.add(command.id)
completionLatches[command.id] = CountDownLatch(1)
}
batches.offer(PendingBatch(commands.toList()))
}
fun remove(commandId: String) {
active.remove(commandId)
}
fun isActive(commandId: String): Boolean = active.contains(commandId)
fun isClosed(): Boolean = closed.get()
fun awaitCompletion(commandId: String): Boolean =
completionLatches.getValue(commandId).await(TIMEOUT_SECONDS, TimeUnit.SECONDS)
fun hasCompletion(commandId: String): Boolean = completions.containsKey(commandId)
fun completion(commandId: String): Completion = completions.getValue(commandId)
override fun download(requestJson: String): String = error("unused fake download")
override fun postProcess(inputJson: String, metadataJson: String): String =
error("unused fake post-process")
override fun waitPending(timeoutMs: Long): List<CoreFFmpegCommand> {
waitEntered.countDown()
if (closed.get()) throw IllegalStateException("execution closed")
val batch = batches.poll(timeoutMs, TimeUnit.MILLISECONDS) ?: return emptyList()
if (batch === closedBatch) throw IllegalStateException("execution closed")
return batch.commands
}
override fun commandIsActive(commandId: String): Boolean = active.contains(commandId)
override fun complete(
commandId: String,
success: Boolean,
output: String,
error: String,
) {
completions[commandId] = Completion(success, output, error)
active.remove(commandId)
completionLatches.getValue(commandId).countDown()
}
override fun close() {
val calls = closeCalls.incrementAndGet()
if (calls >= 2) secondClose.countDown()
if (closed.compareAndSet(false, true)) {
closedHandle.countDown()
batches.offer(closedBatch)
}
}
}
class CoreFFmpegExecutionTest {
@Test
fun backendCancellationRemainsCancellationForNativeFinalization() {
val execution = FakeCoreExtensionExecution()
val failure = runCatching {
withCoreFFmpegExecution(execution) { throw IllegalStateException("download cancelled") }
}.exceptionOrNull()
assertTrue(failure is java.util.concurrent.CancellationException)
assertTrue(failure?.cause is IllegalStateException)
assertTrue(execution.closedHandle.await(TIMEOUT_SECONDS, TimeUnit.SECONDS))
}
@Test
fun callerFinishingDoesNotInterruptClaimedCommandOrCloseHandle() {
val execution = FakeCoreExtensionExecution()
val commandStarted = CountDownLatch(1)
val releaseCommand = CountDownLatch(1)
val workerFailure = AtomicReference<Throwable?>(null)
execution.enqueue(CoreFFmpegCommand("command-b", arrayOf("convert-b")))
try {
val result = withCoreFFmpegExecution(
execution,
execute = { arguments, cancelled ->
if (arguments.firstOrNull() != "convert-b") {
workerFailure.compareAndSet(
null,
AssertionError("unexpected command: ${arguments.toList()}"),
)
false to "unexpected command"
} else if (cancelled()) {
workerFailure.compareAndSet(
null,
AssertionError("claimed command B was cancelled"),
)
false to "cancelled"
} else {
commandStarted.countDown()
if (!awaitWorkerLatch(releaseCommand, "command B release", workerFailure)) {
false to "worker timed out"
} else {
true to "command B complete"
}
}
},
) {
assertTrue(commandStarted.await(TIMEOUT_SECONDS, TimeUnit.SECONDS))
"caller A complete"
}
assertEquals("caller A complete", result)
assertFalse(execution.isClosed())
assertFalse(execution.hasCompletion("command-b"))
releaseCommand.countDown()
assertTrue(execution.awaitCompletion("command-b"))
assertTrue(execution.closedHandle.await(TIMEOUT_SECONDS, TimeUnit.SECONDS))
assertEquals(true, execution.completion("command-b").success)
assertNull(workerFailure.get())
} finally {
releaseCommand.countDown()
execution.close()
execution.closedHandle.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)
}
}
@Test
fun registryRemovalCancelsOnlyCommandAWhileCommandBRemainsActive() {
val execution = FakeCoreExtensionExecution()
val commandAStarted = CountDownLatch(1)
val releaseCommandA = CountDownLatch(1)
val commandBStarted = CountDownLatch(1)
val releaseCommandB = CountDownLatch(1)
val workerFailure = AtomicReference<Throwable?>(null)
execution.enqueue(
CoreFFmpegCommand("command-a", arrayOf("convert-a")),
CoreFFmpegCommand("command-b", arrayOf("convert-b")),
)
try {
val result = withCoreFFmpegExecution(
execution,
execute = { arguments, cancelled ->
if (arguments.firstOrNull() == "convert-a") {
commandAStarted.countDown()
if (!awaitWorkerLatch(releaseCommandA, "command A release", workerFailure)) {
false to "worker timed out"
} else if (!cancelled()) {
workerFailure.compareAndSet(
null,
AssertionError("command A was not cancelled"),
)
false to "not cancelled"
} else {
false to "cancelled"
}
} else if (arguments.firstOrNull() != "convert-b") {
workerFailure.compareAndSet(
null,
AssertionError("unexpected command: ${arguments.toList()}"),
)
false to "unexpected command"
} else if (cancelled()) {
workerFailure.compareAndSet(
null,
AssertionError("command B was cancelled"),
)
false to "cancelled"
} else {
commandBStarted.countDown()
if (!awaitWorkerLatch(releaseCommandB, "command B release", workerFailure)) {
false to "worker timed out"
} else {
true to "command B complete"
}
}
},
) {
assertTrue(commandAStarted.await(TIMEOUT_SECONDS, TimeUnit.SECONDS))
execution.remove("command-a")
releaseCommandA.countDown()
assertTrue(commandBStarted.await(TIMEOUT_SECONDS, TimeUnit.SECONDS))
"caller A complete"
}
assertEquals("caller A complete", result)
assertFalse(execution.isClosed())
assertTrue(execution.isActive("command-b"))
assertTrue(execution.awaitCompletion("command-a"))
assertFalse(execution.completion("command-a").success)
assertEquals("cancelled", execution.completion("command-a").error)
releaseCommandB.countDown()
assertTrue(execution.awaitCompletion("command-b"))
assertTrue(execution.closedHandle.await(TIMEOUT_SECONDS, TimeUnit.SECONDS))
assertTrue(execution.completion("command-b").success)
assertNull(workerFailure.get())
} finally {
releaseCommandA.countDown()
releaseCommandB.countDown()
execution.close()
execution.closedHandle.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)
}
}
@Test
fun emptyArgumentsFailAndFollowingCommandStillRuns() {
val execution = FakeCoreExtensionExecution()
val validCommandStarted = CountDownLatch(1)
val releaseValidCommand = CountDownLatch(1)
val workerFailure = AtomicReference<Throwable?>(null)
val executed = ConcurrentLinkedQueue<String>()
execution.enqueue(
CoreFFmpegCommand("empty-command", emptyArray()),
CoreFFmpegCommand("valid-command", arrayOf("convert")),
)
try {
val result = withCoreFFmpegExecution(
execution,
execute = { arguments, _ ->
executed.add(arguments.firstOrNull() ?: "")
if (arguments.firstOrNull() != "convert") {
workerFailure.compareAndSet(
null,
AssertionError("empty command reached executor"),
)
false to "unexpected command"
} else {
validCommandStarted.countDown()
if (!awaitWorkerLatch(
releaseValidCommand,
"valid command release",
workerFailure,
)
) {
false to "worker timed out"
} else {
true to "valid command complete"
}
}
},
) {
assertTrue(validCommandStarted.await(TIMEOUT_SECONDS, TimeUnit.SECONDS))
"caller complete"
}
assertEquals("caller complete", result)
assertEquals(listOf("convert"), executed.toList())
assertTrue(execution.awaitCompletion("empty-command"))
assertFalse(execution.completion("empty-command").success)
assertEquals("FFmpeg arguments are empty", execution.completion("empty-command").error)
releaseValidCommand.countDown()
assertTrue(execution.awaitCompletion("valid-command"))
assertTrue(execution.closedHandle.await(TIMEOUT_SECONDS, TimeUnit.SECONDS))
assertTrue(execution.completion("valid-command").success)
assertNull(workerFailure.get())
} finally {
releaseValidCommand.countDown()
execution.close()
execution.closedHandle.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)
}
}
@Test
fun closedWaitExitsAndPumpReleasesHandle() {
val execution = FakeCoreExtensionExecution()
val workerFailure = AtomicReference<Throwable?>(null)
val result = withCoreFFmpegExecution(
execution,
execute = { _, _ ->
workerFailure.compareAndSet(null, AssertionError("unexpected command"))
false to "unexpected command"
},
) {
assertTrue(execution.waitEntered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS))
execution.close()
"caller complete"
}
assertEquals("caller complete", result)
assertTrue(execution.closedHandle.await(TIMEOUT_SECONDS, TimeUnit.SECONDS))
assertTrue(execution.secondClose.await(TIMEOUT_SECONDS, TimeUnit.SECONDS))
assertNull(workerFailure.get())
}
@Test
fun cancelledExecutionPreservesExistingTargetAndCleansStaging() {
val directory = createTempDirectory()
try {
val target = File(directory, "output.flac")
target.writeText("old output")
val arguments = arrayOf("-i", "input.wav", target.absolutePath)
val command = CoreFFmpegCommand("cancel", arguments, target.absolutePath)
val cancelled = AtomicBoolean(false)
var receivedArguments: Array<String>? = null
var staging: File? = null
val result = executeCoreFFmpegCommand(
command,
cancelled = { cancelled.get() },
) { received, _ ->
receivedArguments = received.copyOf()
staging = File(received.last()).also { it.writeText("partial output") }
cancelled.set(true)
true to "converted"
}
assertFalse(result.first)
assertEquals(arguments.dropLast(1).toList(), receivedArguments!!.dropLast(1).toList())
assertEquals("old output", target.readText())
assertFalse(staging!!.exists())
} finally {
directory.deleteRecursively()
}
}
@Test
fun failedExecutionLeavesMissingTargetAndCleansStaging() {
val directory = createTempDirectory()
try {
val target = File(directory, "output.flac")
val arguments = arrayOf("-i", "input.wav", target.absolutePath)
val command = CoreFFmpegCommand("failed", arguments, target.absolutePath)
var staging: File? = null
val result = executeCoreFFmpegCommand(command, cancelled = { false }) { received, _ ->
staging = File(received.last()).also { it.writeText("partial output") }
false to "conversion failed"
}
assertFalse(result.first)
assertFalse(target.exists())
assertFalse(staging!!.exists())
} finally {
directory.deleteRecursively()
}
}
@Test
fun successfulExecutionReplacesTargetPreservesInputArgumentsAndCleansStaging() {
val directory = createTempDirectory()
try {
val input = File(directory, "input.wav")
input.writeText("input")
val target = File(directory, "output.flac")
target.writeText("old output")
val arguments = arrayOf("-i", input.absolutePath, "-c:a", "flac", target.absolutePath)
val command = CoreFFmpegCommand("success", arguments, target.absolutePath)
var receivedArguments: Array<String>? = null
var staging: File? = null
val result = executeCoreFFmpegCommand(command, cancelled = { false }) { received, _ ->
receivedArguments = received.copyOf()
staging = File(received.last()).also { it.writeText("new complete output") }
true to "converted"
}
assertTrue(result.first)
assertEquals(arguments.dropLast(1).toList(), receivedArguments!!.dropLast(1).toList())
assertTrue(receivedArguments!!.last() != target.absolutePath)
assertEquals(target.parentFile.canonicalFile, staging!!.parentFile)
assertEquals(target.extension, staging!!.extension)
assertEquals("new complete output", target.readText())
assertFalse(staging!!.exists())
} finally {
directory.deleteRecursively()
}
}
@Test
fun firstExecutionSweepsOnlyOwnOrphanFilesAndPreservesOtherEntries() {
val directory = createTempDirectory()
try {
val prefix = ".spotiflac-ffmpeg-${BuildConfig.APPLICATION_ID}-"
val ownOrphan = File(directory, "$prefix${UUID.randomUUID()}.flac")
.also { it.writeText("orphan") }
val foreignFile = File(directory, ".spotiflac-ffmpeg-foreign-${UUID.randomUUID()}.flac")
.also { it.writeText("foreign") }
val malformedFile = File(directory, "${prefix}not-a-uuid.flac")
.also { it.writeText("malformed") }
val preservedDirectory = File(directory, "$prefix${UUID.randomUUID()}.flac")
check(preservedDirectory.mkdir()) { "failed to create preserved directory" }
val directoryContent = File(preservedDirectory, "content.txt")
.also { it.writeText("directory content") }
val symlinkTarget = File(directory, "symlink-target.txt")
.also { it.writeText("symlink content") }
val symlink = File(directory, "$prefix${UUID.randomUUID()}.flac")
Files.createSymbolicLink(symlink.toPath(), symlinkTarget.toPath())
val target = File(directory, "output.flac")
val command = CoreFFmpegCommand("sweep", arrayOf("convert", target.absolutePath), target.absolutePath)
var staged: File? = null
val result = executeCoreFFmpegCommand(command, cancelled = { false }) { arguments, _ ->
assertFalse(ownOrphan.exists())
assertEquals("foreign", foreignFile.readText())
assertEquals("malformed", malformedFile.readText())
assertTrue(preservedDirectory.isDirectory)
assertEquals("directory content", directoryContent.readText())
assertTrue(Files.isSymbolicLink(symlink.toPath()))
assertEquals("symlink content", symlink.readText())
staged = File(arguments.last()).also { it.writeText("published") }
true to "converted"
}
assertTrue(result.first)
assertEquals("published", target.readText())
assertFalse(staged!!.exists())
assertFalse(ownOrphan.exists())
assertEquals("foreign", foreignFile.readText())
assertEquals("malformed", malformedFile.readText())
assertEquals("directory content", directoryContent.readText())
assertTrue(Files.isSymbolicLink(symlink.toPath()))
assertEquals("symlink content", symlink.readText())
} finally {
directory.deleteRecursively()
}
}
@Test
fun overlappingExecutionsPreserveLiveStagingAndPublishBothOutputs() {
val directory = createTempDirectory()
try {
val firstTarget = File(directory, "first.flac")
val secondTarget = File(directory, "second.flac")
val firstCommand = CoreFFmpegCommand(
"first",
arrayOf("convert-first", firstTarget.absolutePath),
firstTarget.absolutePath,
)
val secondCommand = CoreFFmpegCommand(
"second",
arrayOf("convert-second", secondTarget.absolutePath),
secondTarget.absolutePath,
)
var firstStage: File? = null
var secondStage: File? = null
val result = executeCoreFFmpegCommand(firstCommand, cancelled = { false }) { arguments, _ ->
firstStage = File(arguments.last()).also { it.writeText("first staged") }
val nestedResult = executeCoreFFmpegCommand(secondCommand, cancelled = { false }) { nestedArguments, _ ->
secondStage = File(nestedArguments.last())
assertTrue(firstStage!!.exists())
assertEquals("first staged", firstStage!!.readText())
secondStage!!.writeText("second published")
true to "second complete"
}
assertTrue(nestedResult.first)
assertEquals("second published", secondTarget.readText())
assertTrue(firstStage!!.exists())
assertEquals("first staged", firstStage!!.readText())
true to "first complete"
}
assertTrue(result.first)
assertEquals("first staged", firstTarget.readText())
assertEquals("second published", secondTarget.readText())
assertFalse(firstStage!!.exists())
assertFalse(secondStage!!.exists())
} finally {
directory.deleteRecursively()
}
}
}
@@ -1,11 +1,61 @@
package com.zarz.spotiflac
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.assertNull
import org.junit.Test
import java.io.File
class SafMetadataReadPolicyTest {
@Test fun lyricsReadUsesSafCopyAndDeletesItAfterSuccessOrFailure() {
for (fails in listOf(false, true)) {
val temporary = File.createTempFile("lyrics_", ".flac")
temporary.writeText("embedded lyrics fixture")
try {
val result = runCatching {
readLyricsWithSafCopy(
"content://example.documents/track.flac",
copyToTemp = { uri ->
assertEquals("content://example.documents/track.flac", uri)
temporary
},
read = { path ->
assertEquals(temporary.absolutePath, path)
assertEquals("flac", File(path).extension)
assertTrue(File(path).exists())
if (fails) throw IllegalStateException("read failed")
File(path).readText()
},
)
}
assertEquals(fails, result.isFailure)
if (!fails) assertEquals("embedded lyrics fixture", result.getOrThrow())
assertFalse(temporary.exists())
} finally {
temporary.delete()
}
}
}
@Test fun revokedSafLyricsReadDoesNotFetchOnline() {
assertNull(readLyricsWithSafCopy<String>(
"content://example.documents/revoked.flac",
copyToTemp = { null },
read = { error("Unreadable document must not become an online request") },
))
}
@Test fun localAndOnlineLyricsReadsKeepOriginalPath() {
for (path in listOf("/example/track.flac", "")) {
assertEquals(path, readLyricsWithSafCopy(
path,
copyToTemp = { error("Unexpected SAF copy") },
read = { it },
))
}
}
@Test fun completeMetadataReadErrorUsesTemporaryCopy() {
val metadata = mapOf("lyrics" to "words", "comment" to "notes", "track_number" to 3)
assertEquals(metadata, readSafMetadataWithFallback(