mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-13 12:34:59 +02:00
perf: optimize native worker snapshot writes with delta mode
Replace full-queue snapshot writes on every progress tick with a lightweight delta mode that only includes the active item. - Progress tick (1s loop) now writes item_delta with only the active item instead of serializing the entire queue - Full compact_items snapshots are reserved for important events: start, pause/resume/cancel, item boundaries, finish, and service stop/destroy - Compact items omit large static fields (item_json, track_name, artist_name) that Dart already has from queue restore - Snapshot always carries item_ids for adoption correlation - Dart-side _applyAndroidNativeWorkerSnapshot handles item_delta as a single-item fallback when items array is absent - Dart-side _tryAdoptAndroidNativeWorkerSnapshot reads item_ids as fallback when items is not present - Add deferred SAF publish: native worker writes to cache, runs all finalization locally, then publishes once to SAF at the end - Forward defer_saf_publish through DownloadRequestPayload
This commit is contained in:
@@ -58,6 +58,7 @@ class DownloadService : Service() {
|
||||
const val EXTRA_REQUESTS_JSON = "requests_json"
|
||||
const val EXTRA_SETTINGS_JSON = "settings_json"
|
||||
private const val NATIVE_WORKER_STATE_FILE = "native_download_worker_state.json"
|
||||
private const val NATIVE_WORKER_PROGRESS_FILE = "native_download_worker_progress.json"
|
||||
private const val NATIVE_REPLAYGAIN_JOURNAL_FILE = "native_replaygain_journal.json"
|
||||
private const val NATIVE_WORKER_CONTRACT_VERSION = NativeDownloadFinalizer.NATIVE_WORKER_CONTRACT_VERSION
|
||||
private val NATIVE_WORKER_STATE_FILE_LOCK = Any()
|
||||
@@ -137,8 +138,8 @@ class DownloadService : Service() {
|
||||
|
||||
fun getNativeWorkerSnapshot(context: Context): String {
|
||||
synchronized(NATIVE_WORKER_STATE_FILE_LOCK) {
|
||||
val file = File(context.filesDir, NATIVE_WORKER_STATE_FILE)
|
||||
if (!file.exists()) {
|
||||
val stateFile = File(context.filesDir, NATIVE_WORKER_STATE_FILE)
|
||||
if (!stateFile.exists()) {
|
||||
return JSONObject()
|
||||
.put("run_id", "")
|
||||
.put("is_running", false)
|
||||
@@ -150,10 +151,50 @@ class DownloadService : Service() {
|
||||
.put("items", JSONArray())
|
||||
.toString()
|
||||
}
|
||||
return AtomicFile(file).openRead().bufferedReader(Charsets.UTF_8).use {
|
||||
val state = AtomicFile(stateFile).openRead().bufferedReader(Charsets.UTF_8).use {
|
||||
it.readText()
|
||||
}.let { JSONObject(it) }
|
||||
val progressFile = File(context.filesDir, NATIVE_WORKER_PROGRESS_FILE)
|
||||
if (progressFile.exists()) {
|
||||
try {
|
||||
val progress = AtomicFile(progressFile).openRead().bufferedReader(Charsets.UTF_8).use {
|
||||
it.readText()
|
||||
}.let { JSONObject(it) }
|
||||
if (progress.optString("run_id", "") == state.optString("run_id", "") &&
|
||||
progress.optLong("snapshot_serial", 0L) > state.optLong("snapshot_serial", 0L)
|
||||
) {
|
||||
mergeNativeWorkerProgressSnapshot(state, progress)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
return state.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun mergeNativeWorkerProgressSnapshot(state: JSONObject, progress: JSONObject) {
|
||||
val dynamicKeys = listOf(
|
||||
"is_running",
|
||||
"is_paused",
|
||||
"total",
|
||||
"completed",
|
||||
"failed",
|
||||
"skipped",
|
||||
"current_item_id",
|
||||
"message",
|
||||
"updated_at",
|
||||
"snapshot_serial",
|
||||
"item_ids"
|
||||
)
|
||||
for (key in dynamicKeys) {
|
||||
if (progress.has(key)) {
|
||||
state.put(key, progress.get(key))
|
||||
}
|
||||
}
|
||||
if (progress.has("item_delta")) {
|
||||
state.put("item_delta", progress.get("item_delta"))
|
||||
}
|
||||
state.put("snapshot_mode", "compact_with_delta")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +219,13 @@ class DownloadService : Service() {
|
||||
var resultJson: JSONObject? = null
|
||||
)
|
||||
|
||||
private data class NativeWorkerCounts(
|
||||
val total: Int,
|
||||
val completed: Int,
|
||||
val failed: Int,
|
||||
val skipped: Int
|
||||
)
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var nativeWorkerJob: Job? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
@@ -194,7 +242,8 @@ class DownloadService : Service() {
|
||||
private val nativeReplayGainRequestAlbumKeys = mutableMapOf<String, String>()
|
||||
private val snapshotWriteLock = Any()
|
||||
private val snapshotWriteSerial = AtomicLong(0L)
|
||||
private var latestCommittedSnapshotSerial = 0L
|
||||
private var latestCommittedStateSnapshotSerial = 0L
|
||||
private var latestCommittedProgressSnapshotSerial = 0L
|
||||
@Volatile private var nativeWorkerPaused = false
|
||||
@Volatile private var nativeWorkerCancelRequested = false
|
||||
|
||||
@@ -210,7 +259,8 @@ class DownloadService : Service() {
|
||||
isRunning = false,
|
||||
isPaused = false,
|
||||
currentItemId = "",
|
||||
message = "Service restart ignored"
|
||||
message = "Service restart ignored",
|
||||
includeItems = true
|
||||
)
|
||||
stopForegroundService(cancelNativeWorker = false)
|
||||
return START_NOT_STICKY
|
||||
@@ -260,7 +310,8 @@ class DownloadService : Service() {
|
||||
isRunning = nativeWorkerJob?.isActive == true,
|
||||
isPaused = true,
|
||||
currentItemId = "",
|
||||
message = "Paused"
|
||||
message = "Paused",
|
||||
includeItems = true
|
||||
)
|
||||
}
|
||||
ACTION_RESUME_NATIVE_QUEUE -> {
|
||||
@@ -269,7 +320,8 @@ class DownloadService : Service() {
|
||||
isRunning = nativeWorkerJob?.isActive == true,
|
||||
isPaused = false,
|
||||
currentItemId = "",
|
||||
message = "Resumed"
|
||||
message = "Resumed",
|
||||
includeItems = true
|
||||
)
|
||||
}
|
||||
ACTION_CANCEL_NATIVE_QUEUE -> {
|
||||
@@ -294,7 +346,8 @@ class DownloadService : Service() {
|
||||
isRunning = false,
|
||||
isPaused = false,
|
||||
currentItemId = "",
|
||||
message = "Cancelled"
|
||||
message = "Cancelled",
|
||||
includeItems = true
|
||||
)
|
||||
}
|
||||
ACTION_UPDATE_PROGRESS -> {
|
||||
@@ -360,7 +413,8 @@ class DownloadService : Service() {
|
||||
isPaused = false,
|
||||
currentItemId = "",
|
||||
message = "Invalid native queue payload: ${e.message}",
|
||||
settingsJson = settingsJson
|
||||
settingsJson = settingsJson,
|
||||
includeItems = true
|
||||
)
|
||||
stopForegroundService(cancelNativeWorker = false)
|
||||
return
|
||||
@@ -412,7 +466,8 @@ class DownloadService : Service() {
|
||||
isPaused = false,
|
||||
currentItemId = "",
|
||||
message = "Starting",
|
||||
settingsJson = settingsJson
|
||||
settingsJson = settingsJson,
|
||||
includeItems = true
|
||||
)
|
||||
|
||||
nativeWorkerJob = serviceScope.launch {
|
||||
@@ -495,7 +550,8 @@ class DownloadService : Service() {
|
||||
isPaused = true,
|
||||
currentItemId = request.itemId,
|
||||
message = "Paused",
|
||||
settingsJson = settingsJson
|
||||
settingsJson = settingsJson,
|
||||
includeItems = true
|
||||
)
|
||||
delay(500)
|
||||
}
|
||||
@@ -524,7 +580,8 @@ class DownloadService : Service() {
|
||||
isPaused = false,
|
||||
currentItemId = request.itemId,
|
||||
message = "Downloading",
|
||||
settingsJson = settingsJson
|
||||
settingsJson = settingsJson,
|
||||
includeItems = true
|
||||
)
|
||||
|
||||
var progressJob: Job? = null
|
||||
@@ -606,7 +663,8 @@ class DownloadService : Service() {
|
||||
isPaused = true,
|
||||
currentItemId = request.itemId,
|
||||
message = "Paused",
|
||||
settingsJson = settingsJson
|
||||
settingsJson = settingsJson,
|
||||
includeItems = true
|
||||
)
|
||||
retryCurrentRequest = true
|
||||
} else {
|
||||
@@ -630,7 +688,8 @@ class DownloadService : Service() {
|
||||
currentItemId = request.itemId,
|
||||
message = if (result.optBoolean("success", false)) "Completed" else "Failed",
|
||||
lastResult = result,
|
||||
settingsJson = settingsJson
|
||||
settingsJson = settingsJson,
|
||||
includeItems = true
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
@@ -653,7 +712,8 @@ class DownloadService : Service() {
|
||||
isPaused = false,
|
||||
currentItemId = request.itemId,
|
||||
message = e.message ?: "Native download failed",
|
||||
settingsJson = settingsJson
|
||||
settingsJson = settingsJson,
|
||||
includeItems = true
|
||||
)
|
||||
} finally {
|
||||
progressJob?.cancel()
|
||||
@@ -680,7 +740,8 @@ class DownloadService : Service() {
|
||||
isPaused = false,
|
||||
currentItemId = "",
|
||||
message = if (nativeWorkerCancelRequested) "Cancelled" else "Finished",
|
||||
settingsJson = settingsJson
|
||||
settingsJson = settingsJson,
|
||||
includeItems = true
|
||||
)
|
||||
stopForegroundService(cancelNativeWorker = false)
|
||||
}
|
||||
@@ -913,37 +974,41 @@ class DownloadService : Service() {
|
||||
message: String,
|
||||
lastResult: JSONObject? = null,
|
||||
settingsJson: String = "",
|
||||
includeItems: Boolean = false,
|
||||
snapshotSerial: Long = snapshotWriteSerial.incrementAndGet()
|
||||
) {
|
||||
try {
|
||||
synchronized(snapshotWriteLock) {
|
||||
if (snapshotSerial < latestCommittedSnapshotSerial) return
|
||||
|
||||
val itemsSnapshot = nativeWorkerItemsSnapshot()
|
||||
var completed = 0
|
||||
var failed = 0
|
||||
var skipped = 0
|
||||
for (index in 0 until itemsSnapshot.length()) {
|
||||
when (itemsSnapshot.optJSONObject(index)?.optString("status")) {
|
||||
"completed" -> completed++
|
||||
"failed" -> failed++
|
||||
"skipped" -> skipped++
|
||||
}
|
||||
if (includeItems) {
|
||||
if (snapshotSerial < latestCommittedStateSnapshotSerial) return
|
||||
} else {
|
||||
if (snapshotSerial < latestCommittedProgressSnapshotSerial) return
|
||||
}
|
||||
|
||||
val counts = nativeWorkerCounts()
|
||||
val snapshot = JSONObject()
|
||||
.put("contract_version", NATIVE_WORKER_CONTRACT_VERSION)
|
||||
.put("run_id", nativeWorkerRunId.ifBlank { readNativeWorkerRunIdFromSnapshotFile() })
|
||||
.put("is_running", isRunning)
|
||||
.put("is_paused", isPaused)
|
||||
.put("total", itemsSnapshot.length())
|
||||
.put("completed", completed)
|
||||
.put("failed", failed)
|
||||
.put("skipped", skipped)
|
||||
.put("total", counts.total)
|
||||
.put("completed", counts.completed)
|
||||
.put("failed", counts.failed)
|
||||
.put("skipped", counts.skipped)
|
||||
.put("current_item_id", currentItemId)
|
||||
.put("message", message)
|
||||
.put("updated_at", System.currentTimeMillis())
|
||||
.put("items", itemsSnapshot)
|
||||
if (settingsJson.isNotBlank()) {
|
||||
.put("snapshot_serial", snapshotSerial)
|
||||
.put("item_ids", nativeWorkerItemIds())
|
||||
.put("snapshot_mode", if (includeItems) "compact_items" else "delta")
|
||||
if (includeItems) {
|
||||
snapshot.put("items", nativeWorkerItemsSnapshot(includeStatic = false))
|
||||
} else {
|
||||
nativeWorkerItemSnapshot(currentItemId, includeStatic = false)?.let {
|
||||
snapshot.put("item_delta", it)
|
||||
}
|
||||
}
|
||||
if (settingsJson.isNotBlank() && includeItems) {
|
||||
snapshot.put("settings_json", settingsJson)
|
||||
}
|
||||
if (lastResult != null) {
|
||||
@@ -951,14 +1016,23 @@ class DownloadService : Service() {
|
||||
}
|
||||
|
||||
synchronized(NATIVE_WORKER_STATE_FILE_LOCK) {
|
||||
val file = AtomicFile(File(filesDir, NATIVE_WORKER_STATE_FILE))
|
||||
val targetFileName = if (includeItems) {
|
||||
NATIVE_WORKER_STATE_FILE
|
||||
} else {
|
||||
NATIVE_WORKER_PROGRESS_FILE
|
||||
}
|
||||
val file = AtomicFile(File(filesDir, targetFileName))
|
||||
var stream: java.io.FileOutputStream? = null
|
||||
try {
|
||||
stream = file.startWrite()
|
||||
stream.write(snapshot.toString().toByteArray(Charsets.UTF_8))
|
||||
file.finishWrite(stream)
|
||||
stream = null
|
||||
latestCommittedSnapshotSerial = snapshotSerial
|
||||
if (includeItems) {
|
||||
latestCommittedStateSnapshotSerial = snapshotSerial
|
||||
} else {
|
||||
latestCommittedProgressSnapshotSerial = snapshotSerial
|
||||
}
|
||||
} finally {
|
||||
if (stream != null) {
|
||||
file.failWrite(stream)
|
||||
@@ -977,7 +1051,8 @@ class DownloadService : Service() {
|
||||
currentItemId: String,
|
||||
message: String,
|
||||
lastResult: JSONObject? = null,
|
||||
settingsJson: String = ""
|
||||
settingsJson: String = "",
|
||||
includeItems: Boolean = false
|
||||
) {
|
||||
val snapshotSerial = snapshotWriteSerial.incrementAndGet()
|
||||
serviceScope.launch {
|
||||
@@ -988,6 +1063,7 @@ class DownloadService : Service() {
|
||||
message = message,
|
||||
lastResult = lastResult,
|
||||
settingsJson = settingsJson,
|
||||
includeItems = includeItems,
|
||||
snapshotSerial = snapshotSerial
|
||||
)
|
||||
}
|
||||
@@ -1042,29 +1118,76 @@ class DownloadService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun nativeWorkerItemsSnapshot(): JSONArray {
|
||||
private fun nativeWorkerCounts(): NativeWorkerCounts {
|
||||
var total = 0
|
||||
var completed = 0
|
||||
var failed = 0
|
||||
var skipped = 0
|
||||
synchronized(nativeWorkerItems) {
|
||||
total = nativeWorkerItems.size
|
||||
for (item in nativeWorkerItems) {
|
||||
when (item.status) {
|
||||
"completed" -> completed++
|
||||
"failed" -> failed++
|
||||
"skipped" -> skipped++
|
||||
}
|
||||
}
|
||||
}
|
||||
return NativeWorkerCounts(
|
||||
total = total,
|
||||
completed = completed,
|
||||
failed = failed,
|
||||
skipped = skipped
|
||||
)
|
||||
}
|
||||
|
||||
private fun nativeWorkerItemSnapshot(itemId: String, includeStatic: Boolean): JSONObject? {
|
||||
if (itemId.isBlank()) return null
|
||||
synchronized(nativeWorkerItems) {
|
||||
val item = nativeWorkerItems.firstOrNull { it.itemId == itemId } ?: return null
|
||||
return nativeWorkerItemSnapshotLocked(item, includeStatic)
|
||||
}
|
||||
}
|
||||
|
||||
private fun nativeWorkerItemIds(): JSONArray {
|
||||
val array = JSONArray()
|
||||
synchronized(nativeWorkerItems) {
|
||||
for (item in nativeWorkerItems) {
|
||||
val json = JSONObject()
|
||||
.put("item_id", item.itemId)
|
||||
.put("track_name", item.trackName)
|
||||
.put("artist_name", item.artistName)
|
||||
.put("item_json", item.itemJson)
|
||||
.put("status", item.status)
|
||||
.put("progress", item.progress)
|
||||
.put("bytes_received", item.bytesReceived)
|
||||
.put("bytes_total", item.bytesTotal)
|
||||
if (item.error.isNotBlank()) {
|
||||
json.put("error", item.error)
|
||||
}
|
||||
item.resultJson?.let { json.put("result", it) }
|
||||
array.put(json)
|
||||
array.put(item.itemId)
|
||||
}
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
private fun nativeWorkerItemsSnapshot(includeStatic: Boolean): JSONArray {
|
||||
val array = JSONArray()
|
||||
synchronized(nativeWorkerItems) {
|
||||
for (item in nativeWorkerItems) {
|
||||
array.put(nativeWorkerItemSnapshotLocked(item, includeStatic))
|
||||
}
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
private fun nativeWorkerItemSnapshotLocked(item: NativeWorkerItem, includeStatic: Boolean): JSONObject {
|
||||
val json = JSONObject()
|
||||
.put("item_id", item.itemId)
|
||||
.put("status", item.status)
|
||||
.put("progress", item.progress)
|
||||
.put("bytes_received", item.bytesReceived)
|
||||
.put("bytes_total", item.bytesTotal)
|
||||
if (includeStatic) {
|
||||
json.put("track_name", item.trackName)
|
||||
.put("artist_name", item.artistName)
|
||||
.put("item_json", item.itemJson)
|
||||
}
|
||||
if (item.error.isNotBlank()) {
|
||||
json.put("error", item.error)
|
||||
}
|
||||
item.resultJson?.let { json.put("result", it) }
|
||||
return json
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun ensureWakeLock() {
|
||||
val existingWakeLock = wakeLock
|
||||
@@ -1112,7 +1235,8 @@ class DownloadService : Service() {
|
||||
isRunning = false,
|
||||
isPaused = false,
|
||||
currentItemId = "",
|
||||
message = "Service stopped"
|
||||
message = "Service stopped",
|
||||
includeItems = true
|
||||
)
|
||||
}
|
||||
nativeWorkerJob = null
|
||||
@@ -1199,7 +1323,8 @@ class DownloadService : Service() {
|
||||
isRunning = false,
|
||||
isPaused = false,
|
||||
currentItemId = "",
|
||||
message = "Service destroyed"
|
||||
message = "Service destroyed",
|
||||
includeItems = true
|
||||
)
|
||||
}
|
||||
serviceScope.cancel()
|
||||
|
||||
@@ -91,6 +91,8 @@ object NativeDownloadFinalizer {
|
||||
var quality: String,
|
||||
var bitDepth: Int?,
|
||||
var sampleRate: Int?,
|
||||
var pendingExternalLrc: String? = null,
|
||||
var pendingExternalLrcFileName: String? = null,
|
||||
)
|
||||
|
||||
private data class ReplayGainScan(
|
||||
@@ -152,6 +154,7 @@ object NativeDownloadFinalizer {
|
||||
)
|
||||
|
||||
try {
|
||||
var qualityMetadataRefreshed = false
|
||||
if (!result.optBoolean("already_exists", false)) {
|
||||
checkCancelled(shouldCancel)
|
||||
currentStatus("finalizing")
|
||||
@@ -170,10 +173,18 @@ object NativeDownloadFinalizer {
|
||||
val replayGain = writeReplayGain(context, effectiveInput, state, shouldCancel)
|
||||
if (replayGain != null) result.put("replaygain", replayGain)
|
||||
checkCancelled(shouldCancel)
|
||||
promoteStagedSafOutputIfNeeded(context, effectiveInput, state)
|
||||
if (isDeferredSafPublish(effectiveInput)) {
|
||||
refreshFinalAudioQualityMetadata(context, result, state)
|
||||
qualityMetadataRefreshed = true
|
||||
publishDeferredSafOutput(context, effectiveInput, state)
|
||||
} else {
|
||||
promoteStagedSafOutputIfNeeded(context, effectiveInput, state)
|
||||
}
|
||||
}
|
||||
checkCancelled(shouldCancel)
|
||||
refreshFinalAudioQualityMetadata(context, result, state)
|
||||
if (!qualityMetadataRefreshed) {
|
||||
refreshFinalAudioQualityMetadata(context, result, state)
|
||||
}
|
||||
|
||||
val history = buildHistoryRow(effectiveInput, state)
|
||||
upsertHistory(context, history)
|
||||
@@ -658,7 +669,17 @@ object NativeDownloadFinalizer {
|
||||
if (lyricsMode != "external" && lyricsMode != "both") return
|
||||
val lrc = resolveLyricsLrc(input)
|
||||
if (lrc.isBlank() || lrc == "[instrumental:true]") return
|
||||
val baseName = state.fileName.replace(Regex("\\.[^.]+$"), "")
|
||||
val audioFileName = if (isDeferredSafRequest(input)) {
|
||||
desiredFileName(input, state, File(state.filePath).extension)
|
||||
} else {
|
||||
state.fileName
|
||||
}
|
||||
val baseName = audioFileName.replace(Regex("\\.[^.]+$"), "")
|
||||
if (isDeferredSafRequest(input)) {
|
||||
state.pendingExternalLrc = lrc
|
||||
state.pendingExternalLrcFileName = "$baseName.lrc"
|
||||
return
|
||||
}
|
||||
if (state.filePath.startsWith("content://")) {
|
||||
val treeUri = input.request.optString("saf_tree_uri", "")
|
||||
val relativeDir = input.request.optString("saf_relative_dir", "")
|
||||
@@ -1142,6 +1163,92 @@ object NativeDownloadFinalizer {
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDeferredSafPublish(input: FinalizeInput): Boolean {
|
||||
return input.request.optBoolean("defer_saf_publish", false) &&
|
||||
input.result.optBoolean("saf_deferred_publish", false)
|
||||
}
|
||||
|
||||
private fun isDeferredSafRequest(input: FinalizeInput): Boolean {
|
||||
return input.request.optString("storage_mode", "") == "saf" &&
|
||||
input.request.optBoolean("defer_saf_publish", false)
|
||||
}
|
||||
|
||||
private fun publishDeferredSafOutput(
|
||||
context: Context,
|
||||
input: FinalizeInput,
|
||||
state: FinalizeState,
|
||||
) {
|
||||
if (!isDeferredSafPublish(input)) return
|
||||
if (state.filePath.startsWith("content://")) return
|
||||
|
||||
val outputFile = File(state.filePath)
|
||||
if (!outputFile.exists() || outputFile.length() <= 0L) {
|
||||
throw IllegalStateException("deferred SAF output missing or empty")
|
||||
}
|
||||
|
||||
val finalName = desiredFileName(input, state, outputFile.extension)
|
||||
val treeUri = input.result.optString("saf_tree_uri", "")
|
||||
.ifBlank { input.request.optString("saf_tree_uri", "") }
|
||||
val relativeDir = input.result.optString("saf_relative_dir", "")
|
||||
.ifBlank { input.request.optString("saf_relative_dir", "") }
|
||||
val mimeType = mimeTypeForExt(outputFile.extension)
|
||||
val newUri = SafDownloadHandler.writeFileToSaf(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
fileName = finalName,
|
||||
mimeType = mimeType,
|
||||
srcPath = outputFile.absolutePath,
|
||||
) ?: throw IllegalStateException("failed to publish deferred SAF output")
|
||||
|
||||
Log.i(TAG, "Published deferred SAF output once: file=$finalName bytes=${outputFile.length()}")
|
||||
outputFile.delete()
|
||||
state.filePath = newUri
|
||||
state.fileName = finalName
|
||||
input.result.put("file_path", newUri)
|
||||
input.result.put("file_name", finalName)
|
||||
input.result.optJSONObject("replaygain")?.let { replayGain ->
|
||||
replayGain.put("file_path", newUri)
|
||||
replayGain.put("file_name", finalName)
|
||||
}
|
||||
input.result.put("saf_deferred_published", true)
|
||||
publishPendingDeferredExternalLrc(context, input, state)
|
||||
}
|
||||
|
||||
private fun publishPendingDeferredExternalLrc(
|
||||
context: Context,
|
||||
input: FinalizeInput,
|
||||
state: FinalizeState,
|
||||
) {
|
||||
val lrc = state.pendingExternalLrc ?: return
|
||||
val fileName = state.pendingExternalLrcFileName ?: return
|
||||
val treeUri = input.result.optString("saf_tree_uri", "")
|
||||
.ifBlank { input.request.optString("saf_tree_uri", "") }
|
||||
val relativeDir = input.result.optString("saf_relative_dir", "")
|
||||
.ifBlank { input.request.optString("saf_relative_dir", "") }
|
||||
val temp = File(context.cacheDir, "native_lrc_${System.nanoTime()}.lrc")
|
||||
try {
|
||||
temp.writeText(lrc)
|
||||
val newUri = SafDownloadHandler.writeFileToSaf(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
fileName = fileName,
|
||||
mimeType = "application/octet-stream",
|
||||
srcPath = temp.absolutePath,
|
||||
)
|
||||
if (newUri == null) {
|
||||
Log.w(TAG, "Failed to publish deferred external LRC: $fileName")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to publish deferred external LRC: ${e.message}")
|
||||
} finally {
|
||||
temp.delete()
|
||||
state.pendingExternalLrc = null
|
||||
state.pendingExternalLrcFileName = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolvePreferredDecryptionExtension(inputPath: String, requested: String): String {
|
||||
val req = normalizeExt(requested)
|
||||
if (req.isNotBlank()) return req
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.zarz.spotiflac
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
@@ -27,15 +28,17 @@ object SafDownloadHandler {
|
||||
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
|
||||
val mimeType = mimeTypeForExt(outputExt)
|
||||
val fileName = buildSafFileName(req, outputExt)
|
||||
val useStagedOutput = req.optBoolean("stage_saf_output", false)
|
||||
val deferSafPublish = req.optBoolean("defer_saf_publish", false)
|
||||
val useStagedOutput = req.optBoolean("stage_saf_output", false) && !deferSafPublish
|
||||
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName, outputExt) else fileName
|
||||
val staleStagedFileName = buildStagedSafFileName(fileName, outputExt)
|
||||
|
||||
val existingDir = findDocumentDir(context, treeUri, relativeDir)
|
||||
if (existingDir != null) {
|
||||
val existing = existingDir.findFile(fileName)
|
||||
if (existing != null && existing.isFile && existing.length() > 0) {
|
||||
if (useStagedOutput) {
|
||||
existingDir.findFile(stagedFileName)?.delete()
|
||||
if (useStagedOutput || deferSafPublish) {
|
||||
existingDir.findFile(staleStagedFileName)?.delete()
|
||||
}
|
||||
val obj = JSONObject()
|
||||
obj.put("success", true)
|
||||
@@ -50,6 +53,41 @@ object SafDownloadHandler {
|
||||
val targetDir = ensureDocumentDir(context, treeUri, relativeDir)
|
||||
?: return errorJson("Failed to access SAF directory")
|
||||
|
||||
if (deferSafPublish) {
|
||||
targetDir.findFile(staleStagedFileName)?.delete()
|
||||
val workingExt = outputExt.ifBlank { ".tmp" }
|
||||
val workingFile = File.createTempFile("native_saf_work_", workingExt, context.cacheDir)
|
||||
Log.i("SpotiFLAC", "SAF deferred native output: target=$fileName working=${workingFile.name}")
|
||||
return try {
|
||||
req.put("output_path", workingFile.absolutePath)
|
||||
req.put("output_ext", outputExt)
|
||||
req.remove("output_fd")
|
||||
val response = downloader(req.toString())
|
||||
val respObj = JSONObject(response)
|
||||
if (respObj.optBoolean("success", false)) {
|
||||
val reportedPath = respObj.optString("file_path", "").trim()
|
||||
if (reportedPath.isEmpty() || reportedPath.startsWith("/proc/self/fd/")) {
|
||||
respObj.put("file_path", workingFile.absolutePath)
|
||||
} else if (reportedPath != workingFile.absolutePath) {
|
||||
workingFile.delete()
|
||||
}
|
||||
respObj.put("file_name", respObj.optString("file_name", "").ifBlank { fileName })
|
||||
respObj.put("saf_deferred_publish", true)
|
||||
respObj.put("saf_final_file_name", fileName)
|
||||
respObj.put("saf_relative_dir", relativeDir)
|
||||
respObj.put("saf_tree_uri", treeUriStr)
|
||||
respObj.put("saf_output_ext", outputExt)
|
||||
respObj.put("saf_final_mime_type", mimeType)
|
||||
} else {
|
||||
workingFile.delete()
|
||||
}
|
||||
respObj.toString()
|
||||
} catch (e: Exception) {
|
||||
workingFile.delete()
|
||||
errorJson("SAF deferred download failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
var document = createOrReuseDocumentFile(targetDir, mimeType, stagedFileName)
|
||||
?: return errorJson("Failed to create SAF file")
|
||||
|
||||
|
||||
@@ -4520,15 +4520,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
final rawItems = snapshot['items'];
|
||||
if (rawItems is! List || rawItems.isEmpty) {
|
||||
final rawItemIds = snapshot['item_ids'];
|
||||
final snapshotIds = rawItems is List
|
||||
? rawItems
|
||||
.whereType<Map<Object?, Object?>>()
|
||||
.map((item) => item['item_id']?.toString() ?? '')
|
||||
.where((id) => id.isNotEmpty)
|
||||
.toSet()
|
||||
: rawItemIds is List
|
||||
? rawItemIds
|
||||
.map((id) => id?.toString() ?? '')
|
||||
.where((id) => id.isNotEmpty)
|
||||
.toSet()
|
||||
: <String>{};
|
||||
if (snapshotIds.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final snapshotIds = rawItems
|
||||
.whereType<Map<Object?, Object?>>()
|
||||
.map((item) => item['item_id']?.toString() ?? '')
|
||||
.where((id) => id.isNotEmpty)
|
||||
.toSet();
|
||||
if (!restoredItems.any((item) => snapshotIds.contains(item.id))) {
|
||||
return false;
|
||||
}
|
||||
@@ -4958,6 +4965,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
safFileName: safFileName ?? '',
|
||||
safOutputExt: safOutputExt,
|
||||
stageSafOutput: isSafMode,
|
||||
deferSafPublish: isSafMode,
|
||||
requiresContainerConversion:
|
||||
outputExt == '.flac' &&
|
||||
_extensionRequiresNativeContainerConversion(item.service),
|
||||
@@ -4984,13 +4992,23 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
AppSettings settings,
|
||||
) async {
|
||||
final rawItems = snapshot['items'];
|
||||
if (rawItems is! List) {
|
||||
final rawDelta = snapshot['item_delta'];
|
||||
final itemSnapshots = <Map<String, dynamic>>[];
|
||||
if (rawItems is List) {
|
||||
for (final rawItem in rawItems) {
|
||||
if (rawItem is Map) {
|
||||
itemSnapshots.add(Map<String, dynamic>.from(rawItem));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rawDelta is Map) {
|
||||
itemSnapshots.add(Map<String, dynamic>.from(rawDelta));
|
||||
}
|
||||
if (itemSnapshots.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (final rawItem in rawItems) {
|
||||
if (rawItem is! Map) continue;
|
||||
final itemSnapshot = Map<String, dynamic>.from(rawItem);
|
||||
for (final itemSnapshot in itemSnapshots) {
|
||||
final itemId = itemSnapshot['item_id']?.toString() ?? '';
|
||||
if (itemId.isEmpty || reconciledIds.contains(itemId)) {
|
||||
continue;
|
||||
|
||||
@@ -44,6 +44,7 @@ class DownloadRequestPayload {
|
||||
final String safFileName;
|
||||
final String safOutputExt;
|
||||
final bool stageSafOutput;
|
||||
final bool deferSafPublish;
|
||||
final bool requiresContainerConversion;
|
||||
final String songLinkRegion;
|
||||
|
||||
@@ -91,6 +92,7 @@ class DownloadRequestPayload {
|
||||
this.safFileName = '',
|
||||
this.safOutputExt = '',
|
||||
this.stageSafOutput = false,
|
||||
this.deferSafPublish = false,
|
||||
this.requiresContainerConversion = false,
|
||||
this.songLinkRegion = 'US',
|
||||
});
|
||||
@@ -140,6 +142,7 @@ class DownloadRequestPayload {
|
||||
'saf_file_name': safFileName,
|
||||
'saf_output_ext': safOutputExt,
|
||||
'stage_saf_output': stageSafOutput,
|
||||
'defer_saf_publish': deferSafPublish,
|
||||
'requires_container_conversion': requiresContainerConversion,
|
||||
'songlink_region': songLinkRegion,
|
||||
};
|
||||
@@ -193,6 +196,7 @@ class DownloadRequestPayload {
|
||||
safFileName: safFileName,
|
||||
safOutputExt: safOutputExt,
|
||||
stageSafOutput: stageSafOutput,
|
||||
deferSafPublish: deferSafPublish,
|
||||
requiresContainerConversion: requiresContainerConversion,
|
||||
songLinkRegion: songLinkRegion,
|
||||
);
|
||||
|
||||
@@ -375,6 +375,7 @@ void main() {
|
||||
'saf_file_name': 'Song.flac',
|
||||
'saf_output_ext': 'flac',
|
||||
'stage_saf_output': false,
|
||||
'defer_saf_publish': false,
|
||||
'requires_container_conversion': false,
|
||||
'songlink_region': 'ID',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user