diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt index d146c3d5..2ceb1833 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt @@ -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() 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() diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt index 4525a425..bee42d13 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -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 diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt index 27aa45cd..6ab54380 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt @@ -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") diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 8868f3bf..ff1cce35 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -4520,15 +4520,22 @@ class DownloadQueueNotifier extends Notifier { } final rawItems = snapshot['items']; - if (rawItems is! List || rawItems.isEmpty) { + final rawItemIds = snapshot['item_ids']; + final snapshotIds = rawItems is List + ? rawItems + .whereType>() + .map((item) => item['item_id']?.toString() ?? '') + .where((id) => id.isNotEmpty) + .toSet() + : rawItemIds is List + ? rawItemIds + .map((id) => id?.toString() ?? '') + .where((id) => id.isNotEmpty) + .toSet() + : {}; + if (snapshotIds.isEmpty) { return false; } - - final snapshotIds = rawItems - .whereType>() - .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 { safFileName: safFileName ?? '', safOutputExt: safOutputExt, stageSafOutput: isSafMode, + deferSafPublish: isSafMode, requiresContainerConversion: outputExt == '.flac' && _extensionRequiresNativeContainerConversion(item.service), @@ -4984,13 +4992,23 @@ class DownloadQueueNotifier extends Notifier { AppSettings settings, ) async { final rawItems = snapshot['items']; - if (rawItems is! List) { + final rawDelta = snapshot['item_delta']; + final itemSnapshots = >[]; + if (rawItems is List) { + for (final rawItem in rawItems) { + if (rawItem is Map) { + itemSnapshots.add(Map.from(rawItem)); + } + } + } + if (rawDelta is Map) { + itemSnapshots.add(Map.from(rawDelta)); + } + if (itemSnapshots.isEmpty) { return; } - for (final rawItem in rawItems) { - if (rawItem is! Map) continue; - final itemSnapshot = Map.from(rawItem); + for (final itemSnapshot in itemSnapshots) { final itemId = itemSnapshot['item_id']?.toString() ?? ''; if (itemId.isEmpty || reconciledIds.contains(itemId)) { continue; diff --git a/lib/services/download_request_payload.dart b/lib/services/download_request_payload.dart index 75b53471..7bd3aa8e 100644 --- a/lib/services/download_request_payload.dart +++ b/lib/services/download_request_payload.dart @@ -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, ); diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 418bd1f9..86367785 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -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', });