mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
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:
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(
|
||||
|
||||
Reference in new issue
Block a user