mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-08 00:51:07 +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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user