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:
zarzet
2026-05-05 03:17:38 +07:00
parent 6b342aeac6
commit cfc8e699f3
6 changed files with 364 additions and 71 deletions
@@ -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")
+29 -11
View 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,
);
+1
View File
@@ -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',
});