From 6b342aeac64a7da3a7223d95df1796b827fcca64 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 5 May 2026 02:38:51 +0700 Subject: [PATCH] feat: add experimental Android native download worker Introduce a service-owned download worker that offloads the full download-and-finalize pipeline to DownloadService on Android, keeping downloads alive independently of the Flutter UI process. Key changes: - Extract SAF download logic from MainActivity into SafDownloadHandler - Add NativeDownloadFinalizer for Kotlin-side decryption, format conversion, metadata embedding, ReplayGain, post-processing, and history persistence - Extend DownloadService with native queue management (start, pause, resume, cancel) using coroutine-based worker with AtomicFile snapshots - Add Dart-side orchestration: snapshot polling, run-id correlation, adoption on app restart, and fallback to Dart queue - Forward embedReplayGain, tidalHighFormat, and postProcessingEnabled through Go backend DownloadRequest struct - Add nativeDownloadWorkerEnabled setting with UI toggle - Make DownloadQueueLookup collections unmodifiable --- android/app/build.gradle.kts | 1 + .../com/zarz/spotiflac/DownloadService.kt | 1005 ++++++++++- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 154 +- .../zarz/spotiflac/NativeDownloadFinalizer.kt | 1479 +++++++++++++++++ .../com/zarz/spotiflac/SafDownloadHandler.kt | 390 +++++ go_backend/exports.go | 234 +-- go_backend/extension_providers.go | 167 +- lib/l10n/app_localizations.dart | 2 +- lib/models/settings.dart | 6 + lib/models/settings.g.dart | 3 + lib/providers/download_queue_provider.dart | 1440 +++++++++++++++- lib/providers/extension_provider.dart | 3 + lib/providers/settings_provider.dart | 5 + .../settings/download_settings_page.dart | 42 + lib/services/download_request_payload.dart | 26 + lib/services/platform_bridge.dart | 29 + lib/widgets/settings_group.dart | 87 +- test/models_and_utils_test.dart | 11 + 18 files changed, 4716 insertions(+), 368 deletions(-) create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6c3126d9..c26d90de 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -124,4 +124,5 @@ dependencies { implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0") implementation("androidx.documentfile:documentfile:1.1.0") implementation("androidx.activity:activity-ktx:1.13.0") + implementation("com.antonkarpenko:ffmpeg-kit-full:2.1.0") } 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 5d924e0a..d146c3d5 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt @@ -10,7 +10,21 @@ import android.content.Intent import android.os.Build 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 +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.util.concurrent.atomic.AtomicLong /** * Foreground service to keep downloads running when app is in background. @@ -25,10 +39,15 @@ class DownloadService : Service() { private const val CHANNEL_ID = "download_channel" private const val NOTIFICATION_ID = 1001 private const val WAKELOCK_TAG = "SpotiFLAC:DownloadWakeLock" + private const val WAKELOCK_RENEW_MS = 30 * 60 * 1000L const val ACTION_START = "com.zarz.spotiflac.action.START_DOWNLOAD" const val ACTION_STOP = "com.zarz.spotiflac.action.STOP_DOWNLOAD" const val ACTION_UPDATE_PROGRESS = "com.zarz.spotiflac.action.UPDATE_PROGRESS" + const val ACTION_START_NATIVE_QUEUE = "com.zarz.spotiflac.action.START_NATIVE_QUEUE" + const val ACTION_PAUSE_NATIVE_QUEUE = "com.zarz.spotiflac.action.PAUSE_NATIVE_QUEUE" + const val ACTION_RESUME_NATIVE_QUEUE = "com.zarz.spotiflac.action.RESUME_NATIVE_QUEUE" + const val ACTION_CANCEL_NATIVE_QUEUE = "com.zarz.spotiflac.action.CANCEL_NATIVE_QUEUE" const val EXTRA_TRACK_NAME = "track_name" const val EXTRA_ARTIST_NAME = "artist_name" @@ -36,6 +55,13 @@ class DownloadService : Service() { const val EXTRA_TOTAL = "total" const val EXTRA_QUEUE_COUNT = "queue_count" const val EXTRA_STATUS = "status" + 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_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() + private val NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK = Any() private var isRunning = false @@ -74,13 +100,103 @@ class DownloadService : Service() { } context.startService(intent) } + + fun startNativeQueue(context: Context, requestsJson: String, settingsJson: String = "") { + val intent = Intent(context, DownloadService::class.java).apply { + action = ACTION_START_NATIVE_QUEUE + putExtra(EXTRA_REQUESTS_JSON, requestsJson) + putExtra(EXTRA_SETTINGS_JSON, settingsJson) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun pauseNativeQueue(context: Context) { + val intent = Intent(context, DownloadService::class.java).apply { + action = ACTION_PAUSE_NATIVE_QUEUE + } + context.startService(intent) + } + + fun resumeNativeQueue(context: Context) { + val intent = Intent(context, DownloadService::class.java).apply { + action = ACTION_RESUME_NATIVE_QUEUE + } + context.startService(intent) + } + + fun cancelNativeQueue(context: Context) { + val intent = Intent(context, DownloadService::class.java).apply { + action = ACTION_CANCEL_NATIVE_QUEUE + } + context.startService(intent) + } + + fun getNativeWorkerSnapshot(context: Context): String { + synchronized(NATIVE_WORKER_STATE_FILE_LOCK) { + val file = File(context.filesDir, NATIVE_WORKER_STATE_FILE) + if (!file.exists()) { + return JSONObject() + .put("run_id", "") + .put("is_running", false) + .put("is_paused", false) + .put("total", 0) + .put("completed", 0) + .put("failed", 0) + .put("skipped", 0) + .put("items", JSONArray()) + .toString() + } + return AtomicFile(file).openRead().bufferedReader(Charsets.UTF_8).use { + it.readText() + } + } + } } + private data class NativeDownloadRequest( + val itemId: String, + val requestJson: String, + val trackName: String, + val artistName: String, + val itemJson: String + ) + + private data class NativeWorkerItem( + val itemId: String, + val trackName: String, + val artistName: String, + val itemJson: String = "", + var status: String = "queued", + var progress: Double = 0.0, + var bytesReceived: Long = 0L, + var bytesTotal: Long = 0L, + var error: String = "", + var resultJson: JSONObject? = null + ) + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var nativeWorkerJob: Job? = null private var wakeLock: PowerManager.WakeLock? = null private var currentTrackName = "" private var currentArtistName = "" private var currentStatus = "preparing" private var queueCount = 0 + private var lastProgress = 0L + private var lastTotal = 0L + private var nativeWorkerRunId = "" + @Volatile private var nativeWorkerCurrentItemId = "" + private val nativeWorkerItems = mutableListOf() + private val nativeReplayGainEntries = mutableListOf() + private val nativeReplayGainRequestAlbumKeys = mutableMapOf() + private val snapshotWriteLock = Any() + private val snapshotWriteSerial = AtomicLong(0L) + private var latestCommittedSnapshotSerial = 0L + @Volatile private var nativeWorkerPaused = false + @Volatile private var nativeWorkerCancelRequested = false override fun onCreate() { super.onCreate() @@ -88,17 +204,99 @@ class DownloadService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - when (intent?.action) { + if (intent == null) { + flushNativeAlbumReplayGainJournalIfComplete() + writeNativeWorkerSnapshot( + isRunning = false, + isPaused = false, + currentItemId = "", + message = "Service restart ignored" + ) + stopForegroundService(cancelNativeWorker = false) + return START_NOT_STICKY + } + + when (intent.action) { ACTION_START -> { currentTrackName = intent.getStringExtra(EXTRA_TRACK_NAME) ?: "" currentArtistName = intent.getStringExtra(EXTRA_ARTIST_NAME) ?: "" currentStatus = "preparing" queueCount = intent.getIntExtra(EXTRA_QUEUE_COUNT, 0) + lastProgress = 0L + lastTotal = 0L startForegroundService() } ACTION_STOP -> { stopForegroundService() } + ACTION_START_NATIVE_QUEUE -> { + val requestsJson = intent.getStringExtra(EXTRA_REQUESTS_JSON) ?: "[]" + val settingsJson = intent.getStringExtra(EXTRA_SETTINGS_JSON) ?: "{}" + startNativeWorker(requestsJson, settingsJson) + } + ACTION_PAUSE_NATIVE_QUEUE -> { + nativeWorkerPaused = true + var itemIdToCancel = "" + synchronized(nativeWorkerItems) { + val activeItem = nativeWorkerItems.firstOrNull { + it.status == "downloading" || it.status == "finalizing" + } ?: nativeWorkerItems.firstOrNull { + it.itemId == nativeWorkerCurrentItemId && it.status == "queued" + } + activeItem?.let { + it.status = "queued" + itemIdToCancel = it.itemId + } + } + if (itemIdToCancel.isBlank()) itemIdToCancel = nativeWorkerCurrentItemId + if (itemIdToCancel.isNotBlank()) { + try { + Gobackend.cancelDownload(itemIdToCancel) + } catch (_: Exception) { + } + } + NativeDownloadFinalizer.cancelActiveWork() + writeNativeWorkerSnapshotAsync( + isRunning = nativeWorkerJob?.isActive == true, + isPaused = true, + currentItemId = "", + message = "Paused" + ) + } + ACTION_RESUME_NATIVE_QUEUE -> { + nativeWorkerPaused = false + writeNativeWorkerSnapshotAsync( + isRunning = nativeWorkerJob?.isActive == true, + isPaused = false, + currentItemId = "", + message = "Resumed" + ) + } + ACTION_CANCEL_NATIVE_QUEUE -> { + nativeWorkerCancelRequested = true + synchronized(nativeWorkerItems) { + for (item in nativeWorkerItems) { + if (item.status == "queued" || + item.status == "downloading" || + item.status == "finalizing" + ) { + item.status = "skipped" + try { + Gobackend.cancelDownload(item.itemId) + } catch (_: Exception) { + } + } + } + } + NativeDownloadFinalizer.cancelActiveWork() + nativeWorkerJob?.cancel(CancellationException("Native queue cancelled")) + writeNativeWorkerSnapshotAsync( + isRunning = false, + isPaused = false, + currentItemId = "", + message = "Cancelled" + ) + } ACTION_UPDATE_PROGRESS -> { currentTrackName = intent.getStringExtra(EXTRA_TRACK_NAME) ?: currentTrackName currentArtistName = intent.getStringExtra(EXTRA_ARTIST_NAME) ?: currentArtistName @@ -106,6 +304,8 @@ class DownloadService : Service() { val total = intent.getLongExtra(EXTRA_TOTAL, 0) currentStatus = intent.getStringExtra(EXTRA_STATUS) ?: currentStatus queueCount = intent.getIntExtra(EXTRA_QUEUE_COUNT, queueCount) + lastProgress = progress + lastTotal = total updateNotification(progress, total) } } @@ -143,32 +343,795 @@ class DownloadService : Service() { private fun startForegroundService() { isRunning = true + ensureWakeLock() + + val notification = buildNotification(0, 0) + startForeground(NOTIFICATION_ID, notification) + } + + private fun startNativeWorker(requestsJson: String, settingsJson: String) { + flushNativeAlbumReplayGainJournalIfComplete() + nativeWorkerRunId = parseNativeWorkerRunId(settingsJson) + val requests = try { + parseNativeDownloadRequests(requestsJson) + } catch (e: Exception) { + writeNativeWorkerSnapshot( + isRunning = false, + isPaused = false, + currentItemId = "", + message = "Invalid native queue payload: ${e.message}", + settingsJson = settingsJson + ) + stopForegroundService(cancelNativeWorker = false) + return + } + nativeWorkerJob?.cancel(CancellationException("Native queue replaced")) + nativeWorkerPaused = false + nativeWorkerCancelRequested = false + queueCount = requests.size + synchronized(nativeReplayGainEntries) { + nativeReplayGainEntries.clear() + } + synchronized(nativeReplayGainRequestAlbumKeys) { + nativeReplayGainRequestAlbumKeys.clear() + for (request in requests) { + try { + val key = NativeDownloadFinalizer.replayGainAlbumKey( + request.requestJson, + request.itemJson + ) + if (key.isNotBlank()) { + nativeReplayGainRequestAlbumKeys[request.itemId] = key + } + } catch (_: Exception) { + } + } + } + synchronized(nativeWorkerItems) { + nativeWorkerItems.clear() + nativeWorkerItems.addAll( + requests.map { + NativeWorkerItem( + itemId = it.itemId, + trackName = it.trackName, + artistName = it.artistName, + itemJson = it.itemJson + ) + } + ) + } + writeNativeReplayGainJournal() + currentStatus = "preparing" + currentTrackName = requests.firstOrNull()?.trackName ?: "" + currentArtistName = requests.firstOrNull()?.artistName ?: "" + lastProgress = 0L + lastTotal = 0L + startForegroundService() + writeNativeWorkerSnapshot( + isRunning = true, + isPaused = false, + currentItemId = "", + message = "Starting", + settingsJson = settingsJson + ) + + nativeWorkerJob = serviceScope.launch { + runNativeWorker(requests, settingsJson) + } + } + + private fun parseNativeWorkerRunId(settingsJson: String): String { + return try { + JSONObject(settingsJson).optString("run_id", "") + } catch (_: Exception) { + "" + } + } + + private fun parseNativeDownloadRequests(requestsJson: String): List { + val array = JSONArray(requestsJson) + val requests = ArrayList(array.length()) + for (index in 0 until array.length()) { + val item = array.optJSONObject(index) ?: continue + val wrapperVersion = item.optInt("contract_version", -1) + if (wrapperVersion != NATIVE_WORKER_CONTRACT_VERSION) { + throw IllegalArgumentException( + "unsupported native worker item contract v$wrapperVersion at index $index" + ) + } + val itemId = item.optString("item_id").trim() + val requestJson = item.optString("request_json").trim() + if (itemId.isEmpty() || requestJson.isEmpty()) { + continue + } + val request = JSONObject(requestJson) + validateNativeDownloadRequest(itemId, request) + val itemJson = item.optString("item_json").trim() + requests.add( + NativeDownloadRequest( + itemId = itemId, + requestJson = requestJson, + trackName = item.optString("track_name"), + artistName = item.optString("artist_name"), + itemJson = itemJson + ) + ) + } + return requests + } + + private fun validateNativeDownloadRequest(itemId: String, request: JSONObject) { + val requestVersion = request.optInt("contract_version", -1) + if (requestVersion != NATIVE_WORKER_CONTRACT_VERSION) { + throw IllegalArgumentException( + "unsupported native worker request contract v$requestVersion for $itemId" + ) + } + + val requestItemId = request.optString("item_id", "").trim() + if (requestItemId != itemId) { + throw IllegalArgumentException("native worker item id mismatch for $itemId") + } + + val required = listOf("service", "track_name", "quality", "storage_mode") + val missing = required.filter { request.optString(it, "").trim().isEmpty() } + if (missing.isNotEmpty()) { + throw IllegalArgumentException( + "native worker request for $itemId missing fields: ${missing.joinToString()}" + ) + } + } + + private suspend fun runNativeWorker(requests: List, settingsJson: String) { + var completed = 0 + var failed = 0 + try { + var requestIndex = 0 + while (requestIndex < requests.size) { + val request = requests[requestIndex] + while (nativeWorkerPaused && !nativeWorkerCancelRequested) { + writeNativeWorkerSnapshot( + isRunning = true, + isPaused = true, + currentItemId = request.itemId, + message = "Paused", + settingsJson = settingsJson + ) + delay(500) + } + if (nativeWorkerCancelRequested) { + break + } + + var retryCurrentRequest = false + nativeWorkerCurrentItemId = request.itemId + currentTrackName = request.trackName + currentArtistName = request.artistName + currentStatus = "downloading" + lastProgress = 0L + lastTotal = 0L + updateNotification(0, 0) + updateNativeWorkerItem(request.itemId) { + it.status = "downloading" + it.progress = 0.0 + it.bytesReceived = 0L + it.bytesTotal = 0L + it.error = "" + it.resultJson = null + } + writeNativeWorkerSnapshot( + isRunning = true, + isPaused = false, + currentItemId = request.itemId, + message = "Downloading", + settingsJson = settingsJson + ) + + var progressJob: Job? = null + try { + Gobackend.initItemProgress(request.itemId) + progressJob = serviceScope.launch { + while (true) { + updateNativeWorkerItemProgress(request.itemId) + writeNativeWorkerSnapshot( + isRunning = true, + isPaused = false, + currentItemId = request.itemId, + message = "Downloading", + settingsJson = settingsJson + ) + delay(1000) + } + } + val response = SafDownloadHandler.handle(this, request.requestJson) { json -> + Gobackend.downloadByStrategy(json) + } + progressJob.cancel() + progressJob = null + var result = JSONObject(response) + if (result.optBoolean("success", false)) { + currentStatus = "finalizing" + updateNativeWorkerItem(request.itemId) { + it.status = "finalizing" + it.progress = 0.95 + it.error = "" + } + writeNativeWorkerSnapshot( + isRunning = true, + isPaused = false, + currentItemId = request.itemId, + message = "Finalizing", + settingsJson = settingsJson + ) + result = NativeDownloadFinalizer.finalize( + this, + request.itemId, + request.requestJson, + request.itemJson, + result + ) { + nativeWorkerCancelRequested || + nativeWorkerPaused || + nativeWorkerJob?.isActive == false + } + } + if (result.optBoolean("success", false)) { + result.optJSONObject("replaygain")?.let { replayGain -> + synchronized(nativeReplayGainEntries) { + nativeReplayGainEntries.add(JSONObject(replayGain.toString())) + } + } + completed++ + updateNativeWorkerItem(request.itemId) { + it.status = "completed" + it.progress = 1.0 + it.error = "" + it.resultJson = result + } + writeNativeReplayGainJournal() + writeNativeAlbumReplayGainIfComplete() + } else { + val errorType = result.optString("error_type") + if (errorType == "cancelled" && nativeWorkerPaused && !nativeWorkerCancelRequested) { + updateNativeWorkerItem(request.itemId) { + it.status = "queued" + it.progress = 0.0 + it.bytesReceived = 0L + it.bytesTotal = 0L + it.error = "" + it.resultJson = null + } + writeNativeWorkerSnapshot( + isRunning = true, + isPaused = true, + currentItemId = request.itemId, + message = "Paused", + settingsJson = settingsJson + ) + retryCurrentRequest = true + } else { + failed++ + updateNativeWorkerItem(request.itemId) { + it.status = if (errorType == "cancelled") { + "skipped" + } else { + "failed" + } + it.error = result.optString("error") + it.resultJson = result + } + writeNativeReplayGainJournal() + } + } + if (!retryCurrentRequest) { + writeNativeWorkerSnapshot( + isRunning = true, + isPaused = false, + currentItemId = request.itemId, + message = if (result.optBoolean("success", false)) "Completed" else "Failed", + lastResult = result, + settingsJson = settingsJson + ) + } + } catch (e: CancellationException) { + if (nativeWorkerCancelRequested) { + updateNativeWorkerItem(request.itemId) { + it.status = "skipped" + it.error = "Cancelled" + } + } + throw e + } catch (e: Exception) { + failed++ + updateNativeWorkerItem(request.itemId) { + it.status = "failed" + it.error = e.message ?: "Native download failed" + } + writeNativeReplayGainJournal() + writeNativeWorkerSnapshot( + isRunning = true, + isPaused = false, + currentItemId = request.itemId, + message = e.message ?: "Native download failed", + settingsJson = settingsJson + ) + } finally { + progressJob?.cancel() + updateNativeWorkerItemProgress(request.itemId) + try { + Gobackend.clearItemProgress(request.itemId) + } catch (_: Exception) { + } + } + if (!retryCurrentRequest) { + if (nativeWorkerCurrentItemId == request.itemId) { + nativeWorkerCurrentItemId = "" + } + requestIndex++ + } + } + } finally { + if (!nativeWorkerCancelRequested) { + flushNativeAlbumReplayGainJournalIfComplete() + } + currentStatus = "finalizing" + writeNativeWorkerSnapshot( + isRunning = false, + isPaused = false, + currentItemId = "", + message = if (nativeWorkerCancelRequested) "Cancelled" else "Finished", + settingsJson = settingsJson + ) + stopForegroundService(cancelNativeWorker = false) + } + } + + private fun writeNativeAlbumReplayGainIfComplete(): Boolean { + val entries = synchronized(nativeReplayGainEntries) { + nativeReplayGainEntries.map { JSONObject(it.toString()) } + } + if (entries.size <= 1) return true + + val statuses = synchronized(nativeWorkerItems) { + nativeWorkerItems.associate { it.itemId to it.status } + } + val requestKeys = synchronized(nativeReplayGainRequestAlbumKeys) { + nativeReplayGainRequestAlbumKeys.toMap() + } + val eligible = buildEligibleNativeAlbumReplayGain(entries, statuses, requestKeys) + if (eligible.length() <= 1) { + return !hasPendingNativeAlbumReplayGainWork(statuses) + } + return writeNativeAlbumReplayGainEntries(eligible) + } + + private fun buildEligibleNativeAlbumReplayGain( + entries: List, + statuses: Map, + requestKeys: Map + ): JSONArray { + val blockedKeys = mutableSetOf() + val expectedCompletedByKey = mutableMapOf() + for ((itemId, key) in requestKeys) { + when (statuses[itemId]) { + "completed" -> expectedCompletedByKey[key] = (expectedCompletedByKey[key] ?: 0) + 1 + "failed", "skipped", "queued", "downloading", "finalizing" -> blockedKeys.add(key) + } + } + + val entriesByKey = entries.groupBy { it.optString("album_key", "") } + val eligible = JSONArray() + for ((key, group) in entriesByKey) { + if (key.isBlank() || blockedKeys.contains(key) || group.size <= 1) continue + val expected = expectedCompletedByKey[key] ?: continue + if (group.size != expected) continue + for (entry in group) eligible.put(entry) + } + return eligible + } + + private fun writeNativeAlbumReplayGainEntries(eligible: JSONArray): Boolean { + if (eligible.length() <= 1) return true + try { + val result = JSONObject(NativeDownloadFinalizer.writeAlbumReplayGain(this, eligible.toString())) + return result.optBoolean("success", false) + } catch (e: Exception) { + android.util.Log.w("DownloadService", "Native album ReplayGain failed: ${e.message}") + return false + } + } + + private fun hasPendingNativeAlbumReplayGainWork(statuses: Map): Boolean { + return statuses.values.any { + it == "queued" || it == "downloading" || it == "finalizing" + } + } + + private fun writeNativeReplayGainJournal() { + val requestKeys = synchronized(nativeReplayGainRequestAlbumKeys) { + nativeReplayGainRequestAlbumKeys.toMap() + } + if (requestKeys.isEmpty()) return + + val entries = synchronized(nativeReplayGainEntries) { + nativeReplayGainEntries.map { JSONObject(it.toString()) } + } + val statuses = synchronized(nativeWorkerItems) { + nativeWorkerItems.associate { it.itemId to it.status } + } + synchronized(NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK) { + val file = AtomicFile(File(filesDir, NATIVE_REPLAYGAIN_JOURNAL_FILE)) + val existing = readNativeReplayGainJournalLocked(file) + val mergedEntries = mergeNativeReplayGainJournalEntries( + existing?.optJSONArray("entries"), + entries, + ) + val mergedRequestKeys = mergeJsonObjectStringMap( + existing?.optJSONObject("request_album_keys"), + requestKeys, + ) + val mergedStatuses = mergeJsonObjectStringMap( + existing?.optJSONObject("statuses"), + statuses, + ) + val root = JSONObject() + .put("run_id", nativeWorkerRunId) + .put("updated_at", System.currentTimeMillis()) + .put("entries", mergedEntries) + .put("request_album_keys", JSONObject(mergedRequestKeys)) + .put("statuses", JSONObject(mergedStatuses)) + + var stream: java.io.FileOutputStream? = null + try { + stream = file.startWrite() + stream.write(root.toString().toByteArray(Charsets.UTF_8)) + file.finishWrite(stream) + stream = null + } catch (e: Exception) { + android.util.Log.w("DownloadService", "Failed to write native ReplayGain journal: ${e.message}") + } finally { + if (stream != null) { + file.failWrite(stream) + } + } + } + } + + private fun readNativeReplayGainJournalLocked(file: AtomicFile): JSONObject? { + return try { + if (!file.baseFile.exists()) return null + val text = file.openRead().bufferedReader(Charsets.UTF_8).use { + it.readText() + } + JSONObject(text) + } catch (e: Exception) { + android.util.Log.w("DownloadService", "Failed to merge native ReplayGain journal: ${e.message}") + null + } + } + + private fun mergeNativeReplayGainJournalEntries( + existingEntries: JSONArray?, + currentEntries: List + ): JSONArray { + val byKey = linkedMapOf() + + fun add(entry: JSONObject) { + val trackId = entry.optString("track_id", "") + val path = entry.optString("file_path", "") + val key = if (trackId.isNotBlank()) { + "track:$trackId" + } else { + "path:$path" + } + if (key != "path:") { + byKey[key] = JSONObject(entry.toString()) + } + } + + if (existingEntries != null) { + for (index in 0 until existingEntries.length()) { + existingEntries.optJSONObject(index)?.let(::add) + } + } + for (entry in currentEntries) add(entry) + + return JSONArray().apply { + for (entry in byKey.values) put(entry) + } + } + + private fun mergeJsonObjectStringMap( + existing: JSONObject?, + current: Map + ): Map { + val merged = linkedMapOf() + if (existing != null) { + for (key in existing.keys()) { + merged[key] = existing.optString(key, "") + } + } + for ((key, value) in current) { + merged[key] = value + } + return merged + } + + private fun clearNativeReplayGainJournal() { + synchronized(NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK) { + try { + AtomicFile(File(filesDir, NATIVE_REPLAYGAIN_JOURNAL_FILE)).delete() + } catch (_: Exception) { + } + } + } + + private fun flushNativeAlbumReplayGainJournalIfComplete() { + val root = synchronized(NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK) { + try { + val file = File(filesDir, NATIVE_REPLAYGAIN_JOURNAL_FILE) + if (!file.exists()) return + val text = AtomicFile(file).openRead().bufferedReader(Charsets.UTF_8).use { + it.readText() + } + JSONObject(text) + } catch (e: Exception) { + android.util.Log.w("DownloadService", "Failed to read native ReplayGain journal: ${e.message}") + return + } + } + + val entriesArray = root.optJSONArray("entries") ?: return + val entries = mutableListOf() + for (index in 0 until entriesArray.length()) { + entriesArray.optJSONObject(index)?.let { entries.add(JSONObject(it.toString())) } + } + val statusesJson = root.optJSONObject("statuses") ?: JSONObject() + val statuses = mutableMapOf() + for (key in statusesJson.keys()) { + statuses[key] = statusesJson.optString(key, "") + } + val requestKeysJson = root.optJSONObject("request_album_keys") ?: JSONObject() + val requestKeys = mutableMapOf() + for (key in requestKeysJson.keys()) { + requestKeys[key] = requestKeysJson.optString(key, "") + } + + val eligible = buildEligibleNativeAlbumReplayGain(entries, statuses, requestKeys) + if (eligible.length() <= 1 && hasPendingNativeAlbumReplayGainWork(statuses)) { + return + } + if (writeNativeAlbumReplayGainEntries(eligible)) { + clearNativeReplayGainJournal() + } + } + + private fun writeNativeWorkerSnapshot( + isRunning: Boolean, + isPaused: Boolean, + currentItemId: String, + message: String, + lastResult: JSONObject? = null, + settingsJson: String = "", + 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++ + } + } + 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("current_item_id", currentItemId) + .put("message", message) + .put("updated_at", System.currentTimeMillis()) + .put("items", itemsSnapshot) + if (settingsJson.isNotBlank()) { + snapshot.put("settings_json", settingsJson) + } + if (lastResult != null) { + snapshot.put("last_result", lastResult) + } + + synchronized(NATIVE_WORKER_STATE_FILE_LOCK) { + val file = AtomicFile(File(filesDir, NATIVE_WORKER_STATE_FILE)) + 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 + } finally { + if (stream != null) { + file.failWrite(stream) + } + } + } + } + } catch (e: Exception) { + android.util.Log.w("DownloadService", "Failed to write native worker snapshot: ${e.message}") + } + } + + private fun writeNativeWorkerSnapshotAsync( + isRunning: Boolean, + isPaused: Boolean, + currentItemId: String, + message: String, + lastResult: JSONObject? = null, + settingsJson: String = "" + ) { + val snapshotSerial = snapshotWriteSerial.incrementAndGet() + serviceScope.launch { + writeNativeWorkerSnapshot( + isRunning = isRunning, + isPaused = isPaused, + currentItemId = currentItemId, + message = message, + lastResult = lastResult, + settingsJson = settingsJson, + snapshotSerial = snapshotSerial + ) + } + } + + private fun readNativeWorkerRunIdFromSnapshotFile(): String { + return try { + synchronized(NATIVE_WORKER_STATE_FILE_LOCK) { + val file = File(filesDir, NATIVE_WORKER_STATE_FILE) + if (!file.exists()) { + "" + } else { + val text = AtomicFile(file).openRead().bufferedReader(Charsets.UTF_8).use { + it.readText() + } + JSONObject(text).optString("run_id", "") + } + } + } catch (_: Exception) { + "" + } + } + + private fun updateNativeWorkerItem(itemId: String, updater: (NativeWorkerItem) -> Unit) { + synchronized(nativeWorkerItems) { + nativeWorkerItems.firstOrNull { it.itemId == itemId }?.let(updater) + } + } + + private fun updateNativeWorkerItemProgress(itemId: String) { + try { + val raw = Gobackend.getAllDownloadProgress() + val root = JSONObject(raw) + val items = root.optJSONObject("items") ?: return + val progress = items.optJSONObject(itemId) ?: return + val bytesReceived = progress.optLong("bytes_received", 0L) + val bytesTotal = progress.optLong("bytes_total", 0L) + val progressValue = if (bytesTotal > 0L) { + bytesReceived.toDouble() / bytesTotal.toDouble() + } else { + progress.optDouble("progress", 0.0) + }.coerceIn(0.0, 1.0) + updateNativeWorkerItem(itemId) { + it.progress = progressValue + it.bytesReceived = bytesReceived + it.bytesTotal = bytesTotal + } + lastProgress = bytesReceived + lastTotal = bytesTotal + updateNotification(bytesReceived, bytesTotal) + } catch (_: Exception) { + } + } + + private fun nativeWorkerItemsSnapshot(): 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) + } + } + return array + } + + @Synchronized + private fun ensureWakeLock() { + val existingWakeLock = wakeLock + if (existingWakeLock?.isHeld == true) { + existingWakeLock.acquire(WAKELOCK_RENEW_MS) + return + } + if (existingWakeLock != null) { + wakeLock = null + } + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager wakeLock = powerManager.newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, WAKELOCK_TAG ).apply { - acquire(60 * 60 * 1000L) + setReferenceCounted(false) + acquire(WAKELOCK_RENEW_MS) } - - val notification = buildNotification(0, 0) - startForeground(NOTIFICATION_ID, notification) } - - private fun stopForegroundService() { - isRunning = false - wakeLock?.let { - if (it.isHeld) { - it.release() + + @Synchronized + private fun releaseWakeLock() { + val existingWakeLock = wakeLock + wakeLock = null + if (existingWakeLock?.isHeld == true) { + try { + existingWakeLock.release() + } catch (e: RuntimeException) { + android.util.Log.w("DownloadService", "WakeLock release failed: ${e.message}") } } - wakeLock = null + } + + @Synchronized + private fun stopForegroundService(cancelNativeWorker: Boolean = true) { + if (cancelNativeWorker) { + nativeWorkerCancelRequested = true + NativeDownloadFinalizer.cancelActiveWork() + nativeWorkerJob?.cancel(CancellationException("Download service stopped")) + nativeWorkerPaused = false + } + if (cancelNativeWorker && hasNativeWorkerState()) { + writeNativeWorkerSnapshot( + isRunning = false, + isPaused = false, + currentItemId = "", + message = "Service stopped" + ) + } + nativeWorkerJob = null + isRunning = false + releaseWakeLock() stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } + + private fun hasNativeWorkerState(): Boolean { + if (nativeWorkerRunId.isNotBlank()) return true + synchronized(nativeWorkerItems) { + return nativeWorkerItems.isNotEmpty() + } + } private fun updateNotification(progress: Long, total: Long) { if (!isRunning) return + ensureWakeLock() val notification = buildNotification(progress, total) val manager = getSystemService(NotificationManager::class.java) @@ -228,12 +1191,20 @@ class DownloadService : Service() { } override fun onDestroy() { - isRunning = false - wakeLock?.let { - if (it.isHeld) { - it.release() - } + nativeWorkerCancelRequested = true + NativeDownloadFinalizer.cancelActiveWork() + nativeWorkerJob?.cancel(CancellationException("Download service destroyed")) + if (hasNativeWorkerState()) { + writeNativeWorkerSnapshot( + isRunning = false, + isPaused = false, + currentItemId = "", + message = "Service destroyed" + ) } + serviceScope.cancel() + isRunning = false + releaseWakeLock() super.onDestroy() } } diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 54ee3543..f762b0a3 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -311,21 +311,6 @@ class MainActivity: FlutterFragmentActivity() { } } - private fun forceFilenameExt(name: String, outputExt: String): String { - val normalizedExt = normalizeExt(outputExt) - if (normalizedExt.isBlank()) return sanitizeFilename(name) - - val safeName = sanitizeFilename(name) - val lower = safeName.lowercase(Locale.ROOT) - val knownExts = listOf(".flac", ".m4a", ".mp4", ".mp3", ".opus", ".lrc") - for (knownExt in knownExts) { - if (lower.endsWith(knownExt)) { - return safeName.dropLast(knownExt.length) + normalizedExt - } - } - return safeName + normalizedExt - } - private fun sanitizeFilename(name: String): String { var sanitized = name .replace("/", " ") @@ -700,16 +685,6 @@ class MainActivity: FlutterFragmentActivity() { return obj.toString() } - private fun buildSafFileName(req: JSONObject, outputExt: String): String { - val provided = req.optString("saf_file_name", "") - if (provided.isNotBlank()) return forceFilenameExt(provided, outputExt) - - val trackName = req.optString("track_name", "track") - val artistName = req.optString("artist_name", "") - val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName - return forceFilenameExt(baseName, outputExt) - } - private fun errorJson(message: String): String { val obj = JSONObject() obj.put("success", false) @@ -991,112 +966,6 @@ class MainActivity: FlutterFragmentActivity() { return true } - private fun handleSafDownload(requestJson: String, downloader: (String) -> String): 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) - } - - val treeUri = Uri.parse(treeUriStr) - val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", "")) - val outputExt = normalizeExt(req.optString("saf_output_ext", "")) - val mimeType = mimeTypeForExt(outputExt) - val fileName = buildSafFileName(req, outputExt) - - val existingDir = findDocumentDir(treeUri, relativeDir) - if (existingDir != null) { - val existing = existingDir.findFile(fileName) - if (existing != null && existing.isFile && existing.length() > 0) { - val obj = JSONObject() - obj.put("success", true) - obj.put("message", "File already exists") - obj.put("file_path", existing.uri.toString()) - obj.put("file_name", existing.name ?: fileName) - obj.put("already_exists", true) - return obj.toString() - } - } - - val targetDir = ensureDocumentDir(treeUri, relativeDir) - ?: return errorJson("Failed to access SAF directory") - - var document = createOrReuseDocumentFile(targetDir, mimeType, fileName) - ?: return errorJson("Failed to create SAF file") - - val pfd = contentResolver.openFileDescriptor(document.uri, "rw") - ?: return errorJson("Failed to open SAF file") - - var detachedFd: Int? = null - try { - // Prefer handing off a detached FD directly to Go. - // Some devices/providers reject re-opening /proc/self/fd/* with permission denied. - detachedFd = pfd.detachFd() - req.put("output_path", "") - req.put("output_fd", detachedFd) - req.put("output_ext", outputExt) - val response = downloader(req.toString()) - val respObj = JSONObject(response) - if (respObj.optBoolean("success", false)) { - // Extension providers write to a local temp path instead of the SAF FD. - val goFilePath = respObj.optString("file_path", "") - if (goFilePath.isNotEmpty() && - !goFilePath.startsWith("content://") && - !goFilePath.startsWith("/proc/self/fd/") - ) { - try { - val srcFile = java.io.File(goFilePath) - if (!srcFile.exists() || srcFile.length() <= 0) { - throw IllegalStateException("extension output missing or empty: $goFilePath") - } - val actualExt = normalizeExt(srcFile.extension) - if (actualExt.isNotBlank() && actualExt != outputExt) { - val actualFileName = buildSafFileName(req, actualExt) - val actualMimeType = mimeTypeForExt(actualExt) - val replacement = createOrReuseDocumentFile( - targetDir, - actualMimeType, - actualFileName, - ) - ?: throw IllegalStateException("failed to create SAF output with actual extension") - if (replacement.uri != document.uri) { - document.delete() - document = replacement - } - } - contentResolver.openOutputStream(document.uri, "wt")?.use { output -> - srcFile.inputStream().use { input -> - input.copyTo(output) - } - } ?: throw IllegalStateException("failed to open SAF output stream") - srcFile.delete() - } catch (e: Exception) { - document.delete() - android.util.Log.w("SpotiFLAC", "Failed to copy extension output to SAF: ${e.message}") - return errorJson("Failed to copy extension output to SAF: ${e.message}") - } - } - respObj.put("file_path", document.uri.toString()) - respObj.put("file_name", document.name ?: fileName) - } else { - document.delete() - } - return respObj.toString() - } catch (e: Exception) { - document.delete() - return errorJson("SAF download failed: ${e.message}") - } finally { - // If detachFd() failed before handoff, close original ParcelFileDescriptor. - // Otherwise Go owns the detached raw FD and is responsible for closing it. - if (detachedFd == null) { - try { - pfd.close() - } catch (_: Exception) {} - } - } - } - /** * Get the parent DocumentFile directory for a SAF document URI. * The child URI must be a tree-based document URI (e.g. from SAF tree scan). @@ -2195,7 +2064,7 @@ class MainActivity: FlutterFragmentActivity() { "downloadByStrategy" -> { val requestJson = call.arguments as String val response = withContext(Dispatchers.IO) { - handleSafDownload(requestJson) { json -> + SafDownloadHandler.handle(this@MainActivity, requestJson) { json -> Gobackend.downloadByStrategy(json) } } @@ -2886,6 +2755,27 @@ class MainActivity: FlutterFragmentActivity() { "isDownloadServiceRunning" -> { result.success(DownloadService.isServiceRunning()) } + "startNativeDownloadWorker" -> { + val requestsJson = call.argument("requests_json") ?: "[]" + val settingsJson = call.argument("settings_json") ?: "{}" + DownloadService.startNativeQueue(this@MainActivity, requestsJson, settingsJson) + result.success(null) + } + "pauseNativeDownloadWorker" -> { + DownloadService.pauseNativeQueue(this@MainActivity) + result.success(null) + } + "resumeNativeDownloadWorker" -> { + DownloadService.resumeNativeQueue(this@MainActivity) + result.success(null) + } + "cancelNativeDownloadWorker" -> { + DownloadService.cancelNativeQueue(this@MainActivity) + result.success(null) + } + "getNativeDownloadWorkerSnapshot" -> { + result.success(parseJsonPayload(DownloadService.getNativeWorkerSnapshot(this@MainActivity))) + } "preWarmTrackCache" -> { val tracksJson = call.argument("tracks") ?: "[]" withContext(Dispatchers.IO) { diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt new file mode 100644 index 00000000..4525a425 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -0,0 +1,1479 @@ +package com.zarz.spotiflac + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteException +import android.net.Uri +import android.util.Base64 +import android.util.Log +import com.antonkarpenko.ffmpegkit.FFmpegKit +import com.antonkarpenko.ffmpegkit.FFmpegKitConfig +import com.antonkarpenko.ffmpegkit.FFmpegSession +import com.antonkarpenko.ffmpegkit.FFmpegSessionCompleteCallback +import com.antonkarpenko.ffmpegkit.LogRedirectionStrategy +import com.antonkarpenko.ffmpegkit.ReturnCode +import gobackend.Gobackend +import org.json.JSONObject +import java.io.File +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 + +object NativeDownloadFinalizer { + private const val TAG = "NativeFinalizer" + const val NATIVE_WORKER_CONTRACT_VERSION = 1 + // Native finalizer owns background-safe history writes while Flutter may be suspended. + // Keep this schema contract in sync with Dart HistoryDatabase before bumping either side. + private const val HISTORY_SCHEMA_VERSION = 5 + private val activeFFmpegSessionIds = mutableSetOf() + private val nativeFFmpegSessionIds = mutableSetOf() + private val activeFFmpegSessionLock = Any() + private val ffmpegCompleteCallbackLock = Any() + private var forwardedFFmpegCompleteCallback: FFmpegSessionCompleteCallback? = null + private val nativeFilteringFFmpegCompleteCallback = FFmpegSessionCompleteCallback { session -> + val isNativeSession = synchronized(activeFFmpegSessionLock) { + nativeFFmpegSessionIds.contains(session.sessionId) + } + if (!isNativeSession) { + val delegate = synchronized(ffmpegCompleteCallbackLock) { + forwardedFFmpegCompleteCallback + } + delegate?.apply(session) + } + } + private val requiredHistoryColumns = setOf( + "id", + "track_name", + "artist_name", + "album_name", + "album_artist", + "cover_url", + "file_path", + "storage_mode", + "download_tree_uri", + "saf_relative_dir", + "saf_file_name", + "saf_repaired", + "service", + "downloaded_at", + "isrc", + "spotify_id", + "track_number", + "total_tracks", + "disc_number", + "total_discs", + "duration", + "release_date", + "quality", + "bit_depth", + "sample_rate", + "genre", + "composer", + "label", + "copyright", + ) + + private data class FinalizeInput( + val itemId: String, + val request: JSONObject, + val item: JSONObject, + val track: JSONObject, + val result: JSONObject, + ) + + private data class FinalizeState( + var filePath: String, + var fileName: String, + var quality: String, + var bitDepth: Int?, + var sampleRate: Int?, + ) + + private data class ReplayGainScan( + val trackGain: String, + val trackPeak: String, + val integratedLufs: Double, + val truePeakLinear: Double, + ) + + fun cancelActiveWork() { + val sessionIds = synchronized(activeFFmpegSessionLock) { + activeFFmpegSessionIds.toList() + } + for (sessionId in sessionIds) { + try { + FFmpegKit.cancel(sessionId) + } catch (_: Exception) { + } + } + } + + fun finalize( + context: Context, + itemId: String, + requestJson: String, + itemJson: String, + result: JSONObject, + shouldCancel: () -> Boolean = { false }, + ): JSONObject { + if (!result.optBoolean("success", false)) return result + + val itemObject = parseObject(itemJson) + val requestObject = parseObject(requestJson) + validateRequestContract(requestObject) + val input = FinalizeInput( + itemId = itemId, + request = requestObject, + item = itemObject, + track = itemObject.optJSONObject("track") ?: JSONObject(), + result = result, + ) + val track = if (input.track.length() > 0) input.track else input.item.optJSONObject("track") ?: JSONObject() + val effectiveInput = input.copy(track = track) + + val initialPath = result.optString("file_path", "").trim() + if (initialPath.isEmpty()) { + result.put("success", false) + result.put("error", "Native finalizer received empty file path") + result.put("error_type", "unknown") + return result + } + + val state = FinalizeState( + filePath = initialPath, + fileName = result.optString("file_name", "").ifBlank { File(initialPath).name }, + quality = requestQuality(effectiveInput), + bitDepth = optPositiveInt(result, "actual_bit_depth"), + sampleRate = optPositiveInt(result, "actual_sample_rate"), + ) + + try { + if (!result.optBoolean("already_exists", false)) { + checkCancelled(shouldCancel) + currentStatus("finalizing") + finalizeDecryption(context, effectiveInput, state, shouldCancel) + checkCancelled(shouldCancel) + finalizeHighConversion(context, effectiveInput, state, shouldCancel) + checkCancelled(shouldCancel) + finalizeContainerConversion(context, effectiveInput, state, shouldCancel) + checkCancelled(shouldCancel) + finalizeMetadata(context, effectiveInput, state) + checkCancelled(shouldCancel) + writeExternalLrc(context, effectiveInput, state) + checkCancelled(shouldCancel) + runPostProcessing(context, effectiveInput, state, shouldCancel) + checkCancelled(shouldCancel) + val replayGain = writeReplayGain(context, effectiveInput, state, shouldCancel) + if (replayGain != null) result.put("replaygain", replayGain) + checkCancelled(shouldCancel) + promoteStagedSafOutputIfNeeded(context, effectiveInput, state) + } + checkCancelled(shouldCancel) + refreshFinalAudioQualityMetadata(context, result, state) + + val history = buildHistoryRow(effectiveInput, state) + upsertHistory(context, history) + + result.put("file_path", state.filePath) + if (state.fileName.isNotBlank()) result.put("file_name", state.fileName) + result.put("native_finalized", true) + result.put("history_written", true) + result.put("history_item", historyToJson(history)) + } catch (e: CancellationException) { + cleanupFailedFinalizationOutput(context, result, initialPath, state.filePath) + result.put("success", false) + result.put("error", "Native finalization cancelled") + result.put("error_type", "cancelled") + result.put("native_finalized", false) + } catch (e: Exception) { + cleanupFailedFinalizationOutput(context, result, initialPath, state.filePath) + result.put("success", false) + result.put("error", "Native finalization failed: ${e.message}") + result.put("error_type", "unknown") + result.put("native_finalized", false) + } + + return result + } + + private fun checkCancelled(shouldCancel: () -> Boolean) { + if (shouldCancel()) { + throw CancellationException("Native finalization cancelled") + } + } + + fun replayGainAlbumKey(requestJson: String, itemJson: String): String { + val item = parseObject(itemJson) + val input = FinalizeInput( + itemId = item.optString("id", ""), + request = parseObject(requestJson), + item = item, + track = item.optJSONObject("track") ?: JSONObject(), + result = JSONObject(), + ) + return albumKey(input) + } + + fun writeAlbumReplayGain(context: Context, entriesJson: String): String { + val entries = org.json.JSONArray(entriesJson) + val grouped = linkedMapOf>() + for (index in 0 until entries.length()) { + val entry = entries.optJSONObject(index) ?: continue + val key = entry.optString("album_key", "") + if (key.isBlank()) continue + grouped.getOrPut(key) { mutableListOf() }.add(entry) + } + + var albumsWritten = 0 + var filesWritten = 0 + for ((_, group) in grouped) { + if (group.size <= 1) continue + var sumWeightedPower = 0.0 + var sumDuration = 0.0 + var maxPeak = 0.0 + for (entry in group) { + val integrated = entry.optDouble("integrated_lufs", Double.NaN) + if (integrated.isNaN()) continue + val duration = entry.optDouble("duration_secs", 1.0).let { if (it > 0) it else 1.0 } + val peak = entry.optDouble("true_peak_linear", 1.0) + sumWeightedPower += 10.0.pow(integrated / 10.0) * duration + sumDuration += duration + if (peak > maxPeak) maxPeak = peak + } + if (sumDuration <= 0) continue + val albumLufs = 10.0 * kotlin.math.log10(sumWeightedPower / sumDuration) + val albumGainDb = -18.0 - albumLufs + val albumGain = "${if (albumGainDb >= 0) "+" else ""}${"%.2f".format(Locale.US, albumGainDb)} dB" + val albumPeak = "%.6f".format(Locale.US, if (maxPeak > 0) maxPeak else 1.0) + val fields = JSONObject() + .put("replaygain_album_gain", albumGain) + .put("replaygain_album_peak", albumPeak) + var wroteForAlbum = false + for (entry in group) { + val path = entry.optString("file_path", "") + if (path.isBlank()) continue + try { + writeReplayGainFields(context, path, fields) + filesWritten++ + wroteForAlbum = true + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "Failed to write native album ReplayGain: ${e.message}") + } + } + if (wroteForAlbum) albumsWritten++ + } + + return JSONObject() + .put("success", true) + .put("albums_written", albumsWritten) + .put("files_written", filesWritten) + .toString() + } + + private fun parseObject(raw: String): JSONObject { + val trimmed = raw.trim() + if (trimmed.isEmpty()) return JSONObject() + return try { + JSONObject(trimmed) + } catch (_: Exception) { + JSONObject() + } + } + + private fun currentStatus(@Suppress("UNUSED_PARAMETER") status: String) { + // Kept as a narrow hook for future richer progress snapshots. + } + + private fun cleanupFailedFinalizationOutput( + context: Context, + result: JSONObject, + initialPath: String, + currentPath: String, + ) { + if (result.optBoolean("already_exists", false)) return + + val paths = linkedSetOf() + if (initialPath.isNotBlank()) paths.add(initialPath) + if (currentPath.isNotBlank()) paths.add(currentPath) + val resultPath = result.optString("file_path", "").trim() + if (resultPath.isNotBlank()) paths.add(resultPath) + + var cleanedAny = false + for (path in paths) { + cleanedAny = deleteFinalizerOwnedOutput(context, path) || cleanedAny + } + if (cleanedAny) { + result.put("native_finalizer_cleaned_output", true) + } + } + + private fun deleteFinalizerOwnedOutput(context: Context, path: String): Boolean { + if (path.startsWith("content://")) { + return SafDownloadHandler.deleteContentUri(context, path) + } + + return try { + val file = File(path) + if (!file.exists()) return false + + val canonicalPath = file.canonicalPath + val appDataPath = File(context.applicationInfo.dataDir).canonicalPath + val cachePath = context.cacheDir.canonicalPath + if (!canonicalPath.startsWith("$appDataPath/") && !canonicalPath.startsWith("$cachePath/")) { + return false + } + file.delete() + } catch (_: Exception) { + false + } + } + + private fun requestQuality(input: FinalizeInput): String { + return input.request.optString("quality", "").ifBlank { + input.item.optString("qualityOverride", "").ifBlank { "LOSSLESS" } + } + } + + private fun outputExt(input: FinalizeInput): String { + val safExt = input.request.optString("saf_output_ext", "") + val ext = safExt.ifBlank { input.request.optString("output_ext", "") } + return normalizeExt(ext.ifBlank { + when (requestQuality(input)) { + "HIGH" -> ".mp3" + else -> ".flac" + } + }) + } + + private fun finalizeDecryption( + context: Context, + input: FinalizeInput, + state: FinalizeState, + shouldCancel: () -> Boolean, + ) { + val descriptor = input.result.optJSONObject("decryption") + val key = descriptor?.optString("key", "")?.trim().orEmpty() + .ifBlank { input.result.optString("decryption_key", "").trim() } + if (key.isEmpty()) return + + val inputFormat = descriptor?.optString("input_format", "")?.trim().orEmpty().ifBlank { "mov" } + val requestedOutputExt = descriptor?.optString("output_extension", "")?.trim().orEmpty() + val preferredExt = resolvePreferredDecryptionExtension(state.filePath, requestedOutputExt) + val localInput = materializeForFFmpeg(context, input, state) + val originalPath = localInput + + var outputPath = buildOutputPath(localInput, preferredExt) + var successPath: String? = null + var lastOutput = "" + + try { + for (candidate in decryptionKeyCandidates(key)) { + checkCancelled(shouldCancel) + val attempts = mutableListOf>() + attempts.add(outputPath to (preferredExt == ".flac")) + if (preferredExt == ".flac") { + attempts.add(buildOutputPath(localInput, ".m4a") to false) + } + if (preferredExt == ".flac" || preferredExt == ".m4a") { + attempts.add(buildOutputPath(localInput, ".mp4") to false) + } + + for ((candidateOutput, mapAudioOnly) in attempts) { + try { + val audioMap = if (mapAudioOnly) "-map 0:a " else "" + val command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${q(candidateOutput)} -y" + val result = runFFmpeg(command, shouldCancel) + lastOutput = result.second + if (result.first && File(candidateOutput).exists()) { + successPath = candidateOutput + outputPath = candidateOutput + break + } + File(candidateOutput).delete() + } catch (e: CancellationException) { + File(candidateOutput).delete() + throw e + } catch (e: Exception) { + File(candidateOutput).delete() + throw e + } + } + if (successPath != null) break + } + + val decryptedPath = successPath ?: throw IllegalStateException("decrypt failed: $lastOutput") + replaceStatePath(context, input, state, decryptedPath, deleteOld = true) + } finally { + if (successPath == null) { + File(outputPath).delete() + } + if (originalPath != successPath && originalPath.startsWith(context.cacheDir.absolutePath)) { + File(originalPath).delete() + } + } + } + + private fun finalizeHighConversion( + context: Context, + input: FinalizeInput, + state: FinalizeState, + shouldCancel: () -> Boolean, + ) { + if (requestQuality(input) != "HIGH") return + if (!looksLikeM4a(state.filePath, state.fileName)) return + + val tidalHighFormat = input.request.optString("tidal_high_format", "").ifBlank { "mp3_320" } + val format = if (tidalHighFormat.startsWith("opus")) "opus" else "mp3" + val bitrate = if (tidalHighFormat.contains("_")) { + "${tidalHighFormat.substringAfterLast("_")}k" + } else { + if (format == "opus") "128k" else "320k" + } + val ext = if (format == "opus") ".opus" else ".mp3" + val localInput = materializeForFFmpeg(context, input, state) + val deleteLocalInput = state.filePath.startsWith("content://") + val output = buildOutputPath(localInput, ext) + var adoptedOutput = false + try { + val command = if (format == "opus") { + "-v error -hide_banner -i ${q(localInput)} -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a ${q(output)} -y" + } else { + "-v error -hide_banner -i ${q(localInput)} -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 ${q(output)} -y" + } + val result = runFFmpeg(command, shouldCancel) + if (!result.first || !File(output).exists()) { + throw IllegalStateException("HIGH conversion failed: ${result.second}") + } + embedBasicMetadata(output, input, format) + replaceStatePath(context, input, state, output, deleteOld = true) + adoptedOutput = true + } finally { + if (!adoptedOutput) File(output).delete() + if (deleteLocalInput) File(localInput).delete() + } + state.quality = "${format.uppercase(Locale.ROOT)} ${bitrate.removeSuffix("k")}kbps" + state.bitDepth = null + state.sampleRate = null + } + + private fun finalizeContainerConversion( + context: Context, + input: FinalizeInput, + state: FinalizeState, + shouldCancel: () -> Boolean, + ) { + if (requestQuality(input) == "HIGH" || outputExt(input) != ".flac") return + if (!looksLikeM4a(state.filePath, state.fileName) && !shouldForceContainerConversion(input, state)) return + + val localInput = materializeForFFmpeg(context, input, state) + val deleteLocalInput = state.filePath.startsWith("content://") + val output = buildOutputPath(localInput, ".flac") + var adoptedOutput = false + try { + val result = runFFmpeg( + "-v error -xerror -i ${q(localInput)} -c:a flac -compression_level 8 ${q(output)} -y", + shouldCancel, + ) + if (!result.first || !File(output).exists()) { + throw IllegalStateException("container conversion failed: ${result.second}") + } + embedBasicMetadata(output, input, "flac") + replaceStatePath(context, input, state, output, deleteOld = true) + adoptedOutput = true + } finally { + if (!adoptedOutput) File(output).delete() + if (deleteLocalInput) File(localInput).delete() + } + } + + private fun finalizeMetadata(context: Context, input: FinalizeInput, state: FinalizeState) { + if (!input.request.optBoolean("embed_metadata", false)) return + if (!state.filePath.startsWith("content://")) { + embedBasicMetadata(state.filePath, input, formatForPath(state.filePath)) + return + } + + val tempPath = SafDownloadHandler.copyContentUriToTemp(context, state.filePath) + ?: throw IllegalStateException("failed to copy SAF file for metadata") + try { + embedBasicMetadata(tempPath, input, formatForPath(state.fileName.ifBlank { tempPath })) + val tempFile = File(tempPath) + val finalName = desiredFileName(input, state, normalizeExt(state.fileName.substringAfterLast('.', ""))) + val newUri = SafDownloadHandler.writeFileToSaf( + context = context, + treeUriStr = input.request.optString("saf_tree_uri", ""), + relativeDir = input.request.optString("saf_relative_dir", ""), + fileName = finalName, + mimeType = mimeTypeForExt(finalName.substringAfterLast('.', "")), + srcPath = tempFile.absolutePath, + ) ?: throw IllegalStateException("failed to write metadata-updated SAF file") + if (newUri != state.filePath) SafDownloadHandler.deleteContentUri(context, state.filePath) + state.filePath = newUri + state.fileName = finalName + } finally { + File(tempPath).delete() + } + } + + private fun writeReplayGain( + context: Context, + input: FinalizeInput, + state: FinalizeState, + shouldCancel: () -> Boolean, + ): JSONObject? { + if (!input.request.optBoolean("embed_replaygain", false)) return null + val ext = normalizeExt(File(state.filePath).extension) + val fileExt = if (state.filePath.startsWith("content://")) { + normalizeExt(state.fileName.substringAfterLast('.', "")) + } else { + ext + } + if (fileExt != ".flac" && fileExt != ".m4a" && fileExt != ".mp4") return null + + val scanPath = if (state.filePath.startsWith("content://")) { + SafDownloadHandler.copyContentUriToTemp(context, state.filePath) + ?: throw IllegalStateException("failed to copy SAF file for ReplayGain") + } else { + state.filePath + } + val deleteScanPath = scanPath != state.filePath + val scan = try { + scanReplayGain(scanPath, shouldCancel) ?: return null + } finally { + if (deleteScanPath) File(scanPath).delete() + } + checkCancelled(shouldCancel) + val fields = JSONObject() + .put("replaygain_track_gain", scan.trackGain) + .put("replaygain_track_peak", scan.trackPeak) + writeReplayGainFields(context, state.filePath, fields) + + return JSONObject() + .put("album_key", albumKey(input)) + .put("file_path", state.filePath) + .put("file_name", state.fileName) + .put("track_id", trackString(input, "id", input.request.optString("spotify_id", input.itemId))) + .put("integrated_lufs", scan.integratedLufs) + .put("true_peak_linear", scan.truePeakLinear) + .put("duration_secs", replayGainDurationSeconds(input)) + .put("track_gain", scan.trackGain) + .put("track_peak", scan.trackPeak) + } + + private fun writeReplayGainFields(context: Context, path: String, fields: JSONObject) { + if (!path.startsWith("content://")) { + Gobackend.editFileMetadata(path, fields.toString()) + return + } + + val tempPath = SafDownloadHandler.copyContentUriToTemp(context, path) + ?: throw IllegalStateException("failed to copy SAF file for ReplayGain write") + try { + Gobackend.editFileMetadata(tempPath, fields.toString()) + val uri = Uri.parse(path) + context.contentResolver.openOutputStream(uri, "wt")?.use { output -> + File(tempPath).inputStream().use { input -> input.copyTo(output) } + } ?: throw IllegalStateException("failed to write ReplayGain back to SAF") + } finally { + File(tempPath).delete() + } + } + + private fun refreshFinalAudioQualityMetadata(context: Context, result: JSONObject, state: FinalizeState) { + if (!supportsAudioMetadataProbe(state.filePath, state.fileName)) return + + val probePath = if (state.filePath.startsWith("content://")) { + SafDownloadHandler.copyContentUriToTemp(context, state.filePath) ?: return + } else { + state.filePath + } + val deleteProbePath = probePath != state.filePath + + try { + val metadata = parseObject(Gobackend.readFileMetadata(probePath)) + if (metadata.has("error")) return + + val bitDepth = optPositiveInt(metadata, "bit_depth") + val sampleRate = optPositiveInt(metadata, "sample_rate") + if (bitDepth != null) { + state.bitDepth = bitDepth + result.put("actual_bit_depth", bitDepth) + } + if (sampleRate != null) { + state.sampleRate = sampleRate + result.put("actual_sample_rate", sampleRate) + } + + val displayQuality = displayAudioQuality( + bitDepth = state.bitDepth, + sampleRate = state.sampleRate, + storedQuality = state.quality, + ) + if (displayQuality != null) { + state.quality = displayQuality + } + } catch (_: Exception) { + } finally { + if (deleteProbePath) File(probePath).delete() + } + } + + private fun supportsAudioMetadataProbe(filePath: String, fileName: String): Boolean { + val lowerPath = filePath.trim().lowercase(Locale.ROOT) + val lowerName = fileName.trim().lowercase(Locale.ROOT) + if (lowerPath.startsWith("content://")) return true + return lowerPath.endsWith(".flac") || + lowerPath.endsWith(".m4a") || + lowerPath.endsWith(".mp4") || + lowerPath.endsWith(".aac") || + lowerPath.endsWith(".mp3") || + lowerPath.endsWith(".opus") || + lowerPath.endsWith(".ogg") || + lowerName.endsWith(".flac") || + lowerName.endsWith(".m4a") || + lowerName.endsWith(".mp4") || + lowerName.endsWith(".aac") || + lowerName.endsWith(".mp3") || + lowerName.endsWith(".opus") || + lowerName.endsWith(".ogg") + } + + private fun displayAudioQuality(bitDepth: Int?, sampleRate: Int?, storedQuality: String?): String? { + if (bitDepth != null && bitDepth > 0 && sampleRate != null && sampleRate > 0) { + val khz = sampleRate / 1000.0 + val precision = if (sampleRate % 1000 == 0) 0 else 1 + val sampleRateLabel = "%.${precision}f".format(Locale.US, khz) + return "$bitDepth-bit/${sampleRateLabel}kHz" + } + return normalizeOptional(storedQuality) + } + + private fun writeExternalLrc(context: Context, input: FinalizeInput, state: FinalizeState) { + 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) + if (lrc.isBlank() || lrc == "[instrumental:true]") return + val baseName = state.fileName.replace(Regex("\\.[^.]+$"), "") + if (state.filePath.startsWith("content://")) { + val treeUri = input.request.optString("saf_tree_uri", "") + val relativeDir = input.request.optString("saf_relative_dir", "") + val temp = File(context.cacheDir, "native_lrc_${System.nanoTime()}.lrc") + temp.writeText(lrc) + try { + SafDownloadHandler.writeFileToSaf( + context = context, + treeUriStr = treeUri, + relativeDir = relativeDir, + fileName = "$baseName.lrc", + mimeType = "application/octet-stream", + srcPath = temp.absolutePath, + ) + } finally { + temp.delete() + } + } else { + val target = File(File(state.filePath).parentFile, "$baseName.lrc") + target.writeText(lrc) + } + } + + private fun resolveLyricsLrc(input: FinalizeInput): String { + val existing = input.result.optString("lyrics_lrc", "").trim() + if (existing.isNotEmpty()) return existing + + val spotifyId = trackString(input, "id", input.request.optString("spotify_id", "")) + val trackName = trackString(input, "name", input.request.optString("track_name", "")) + val artistName = trackString(input, "artistName", input.request.optString("artist_name", "")) + if (trackName.isBlank() || artistName.isBlank()) return "" + + return try { + val fetched = Gobackend.getLyricsLRC( + spotifyId, + trackName, + artistName, + "", + lyricsDurationMs(input), + ).trim() + if (fetched.isNotEmpty()) { + input.result.put("lyrics_lrc", fetched) + } + fetched + } catch (_: Exception) { + "" + } + } + + private fun lyricsDurationMs(input: FinalizeInput): Long { + val requestDuration = input.request.optLong("duration_ms", 0L) + val trackDuration = trackInt(input, "duration", 0).toLong() + val duration = if (requestDuration > 0L) requestDuration else trackDuration + if (duration <= 0L) return 0L + return if (duration > 10000L) duration else duration * 1000L + } + + private fun runPostProcessing( + context: Context, + input: FinalizeInput, + state: FinalizeState, + shouldCancel: () -> Boolean, + ) { + if (!input.request.optBoolean("post_processing_enabled", false)) return + val metadata = JSONObject() + .put("title", trackString(input, "name", input.request.optString("track_name", ""))) + .put("artist", trackString(input, "artistName", input.request.optString("artist_name", ""))) + .put("album", trackString(input, "albumName", input.request.optString("album_name", ""))) + .put("album_artist", trackString(input, "albumArtist", input.request.optString("album_artist", ""))) + .put("track_number", trackInt(input, "trackNumber", input.request.optInt("track_number", 0))) + .put("disc_number", trackInt(input, "discNumber", input.request.optInt("disc_number", 0))) + .put("isrc", trackString(input, "isrc", input.request.optString("isrc", ""))) + .put("release_date", trackString(input, "releaseDate", input.request.optString("release_date", ""))) + .put("duration_ms", trackInt(input, "duration", 0) * 1000) + .put("cover_url", trackString(input, "coverUrl", input.request.optString("cover_url", ""))) + + if (state.filePath.startsWith("content://")) { + val uri = state.filePath + val tempInput = SafDownloadHandler.copyContentUriToTemp(context, uri) + ?: throw IllegalStateException("failed to copy SAF file for post-processing") + try { + val inputObj = JSONObject() + .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) + if (!response.optBoolean("success", false)) return + val newPath = response.optString("new_file_path", "") + val outputPath = newPath.ifBlank { tempInput } + val outputFile = File(outputPath) + if (!outputFile.exists()) return + val outputName = if (newPath.isBlank()) state.fileName else outputFile.name + val newUri = SafDownloadHandler.writeFileToSaf( + context = context, + treeUriStr = input.request.optString("saf_tree_uri", ""), + relativeDir = input.request.optString("saf_relative_dir", ""), + fileName = outputName, + mimeType = mimeTypeForExt(outputFile.extension), + srcPath = outputFile.absolutePath, + ) ?: return + if (newUri != uri) SafDownloadHandler.deleteContentUri(context, uri) + state.filePath = newUri + state.fileName = outputName + if (outputPath != tempInput) outputFile.delete() + } finally { + File(tempInput).delete() + } + return + } + + val inputObj = JSONObject() + .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) + if (response.optBoolean("success", false)) { + val newPath = response.optString("new_file_path", "") + if (newPath.isNotBlank() && newPath != state.filePath) { + state.filePath = newPath + state.fileName = File(newPath).name + } + } + } + + private fun materializeForFFmpeg(context: Context, input: FinalizeInput, state: FinalizeState): String { + if (!state.filePath.startsWith("content://")) return state.filePath + return SafDownloadHandler.copyContentUriToTemp(context, state.filePath) + ?: throw IllegalStateException("failed to copy SAF file") + } + + private fun replaceStatePath( + context: Context, + input: FinalizeInput, + state: FinalizeState, + localOutput: String, + deleteOld: Boolean, + ) { + if (state.filePath.startsWith("content://")) { + val outputFile = File(localOutput) + val finalName = desiredFileName(input, state, outputFile.extension) + val newUri = SafDownloadHandler.writeFileToSaf( + context = context, + treeUriStr = input.request.optString("saf_tree_uri", ""), + relativeDir = input.request.optString("saf_relative_dir", ""), + fileName = finalName, + mimeType = mimeTypeForExt(outputFile.extension), + srcPath = outputFile.absolutePath, + ) ?: throw IllegalStateException("failed to write finalized file to SAF") + SafDownloadHandler.deleteContentUri(context, state.filePath) + state.filePath = newUri + state.fileName = finalName + outputFile.delete() + return + } + + val oldPath = state.filePath + state.filePath = localOutput + state.fileName = File(localOutput).name + if (deleteOld && oldPath != localOutput) File(oldPath).delete() + } + + private fun embedBasicMetadata(path: String, input: FinalizeInput, format: String) { + if (!input.request.optBoolean("embed_metadata", false)) return + val title = trackString(input, "name", input.request.optString("track_name", "")) + val artist = trackString(input, "artistName", input.request.optString("artist_name", "")) + val album = trackString(input, "albumName", input.request.optString("album_name", "")) + val albumArtist = trackString(input, "albumArtist", input.request.optString("album_artist", "")) + val date = trackString(input, "releaseDate", input.request.optString("release_date", "")) + val trackNumber = trackInt(input, "trackNumber", input.request.optInt("track_number", 0)).toString() + val discNumber = trackInt(input, "discNumber", input.request.optInt("disc_number", 0)).toString() + val isrc = trackString(input, "isrc", input.request.optString("isrc", "")) + val composer = trackString(input, "composer", input.request.optString("composer", "")) + val genre = input.result.optString("genre", "").ifBlank { input.request.optString("genre", "") } + val label = input.result.optString("label", "").ifBlank { input.request.optString("label", "") } + val copyright = input.result.optString("copyright", "").ifBlank { input.request.optString("copyright", "") } + val lyrics = resolveLyricsLrc(input) + val shouldEmbedLyrics = input.request.optBoolean("embed_lyrics", false) && + (input.request.optString("lyrics_mode", "embed") == "embed" || + input.request.optString("lyrics_mode", "embed") == "both") && + lyrics.isNotBlank() && + lyrics != "[instrumental:true]" + if (format == "flac") { + val fields = JSONObject() + .put("title", title) + .put("artist", artist) + .put("album", album) + .put("album_artist", albumArtist) + .put("date", date) + .put("track_number", trackNumber) + .put("disc_number", discNumber) + .put("isrc", isrc) + .put("composer", composer) + .put("genre", genre) + .put("label", label) + .put("copyright", copyright) + if (shouldEmbedLyrics) { + fields.put("lyrics", lyrics) + fields.put("unsyncedlyrics", lyrics) + } + Gobackend.editFileMetadata(path, fields.toString()) + return + } + + val ext = normalizeExt(File(path).extension).ifBlank { ".tmp" } + val inputFile = File(path) + val temp = File(inputFile.parentFile, "${inputFile.nameWithoutExtension}_tagged$ext") + val metadataArgs = listOf( + "title" to title, + "artist" to artist, + "album" to album, + "album_artist" to albumArtist, + "date" to date, + "track" to trackNumber, + "disc" to discNumber, + "isrc" to isrc, + "composer" to composer, + "genre" to genre, + "label" to label, + "copyright" to copyright, + "lyrics" to if (shouldEmbedLyrics) lyrics else "", + "unsyncedlyrics" to if (shouldEmbedLyrics) lyrics else "", + ) + .filter { it.second.isNotBlank() && it.second != "0" } + .joinToString(" ") { "-metadata ${it.first}=${q(it.second)}" } + if (metadataArgs.isBlank()) return + val mp3Flags = if (format == "mp3") "-id3v2_version 3 " else "" + var adoptedTemp = false + var originalDeleted = false + try { + val result = runFFmpeg( + "-v error -hide_banner -i ${q(path)} -map 0 -c copy $metadataArgs $mp3Flags${q(temp.absolutePath)} -y" + ) + if (result.first && temp.exists()) { + if (inputFile.delete()) { + originalDeleted = true + adoptedTemp = temp.renameTo(inputFile) + } + } + } finally { + if (!adoptedTemp && !originalDeleted) { + temp.delete() + } + } + } + + private fun formatForPath(path: String): String { + return when (normalizeExt(File(path).extension)) { + ".mp3" -> "mp3" + ".opus", ".ogg" -> "opus" + ".m4a", ".mp4" -> "m4a" + else -> "flac" + } + } + + private fun scanReplayGain(path: String, shouldCancel: () -> Boolean = { false }): ReplayGainScan? { + val command = "-hide_banner -nostats -i ${q(path)} -filter_complex ebur128=peak=true:framelog=quiet -f null -" + val result = runFFmpeg(command, shouldCancel) + val output = result.second + val integrated = Regex("I:\\s+(-?\\d+\\.?\\d*)\\s+LUFS") + .findAll(output) + .lastOrNull() + ?.groupValues + ?.getOrNull(1) + ?.toDoubleOrNull() ?: return null + val truePeak = Regex("Peak:\\s+(-?\\d+\\.?\\d*)\\s+dBFS") + .findAll(output) + .mapNotNull { it.groupValues.getOrNull(1)?.toDoubleOrNull() } + .maxOrNull() + val gain = -18.0 - integrated + val peak = if (truePeak != null) 10.0.pow(truePeak / 20.0) else 1.0 + return ReplayGainScan( + trackGain = "${if (gain >= 0) "+" else ""}${"%.2f".format(Locale.US, gain)} dB", + trackPeak = "%.6f".format(Locale.US, peak), + integratedLufs = integrated, + truePeakLinear = peak, + ) + } + + private fun runFFmpeg(command: String, shouldCancel: () -> Boolean = { false }): Pair { + checkCancelled(shouldCancel) + installNativeFFmpegCallbackFilter() + val latch = CountDownLatch(1) + var completedSession: FFmpegSession? = null + val session = FFmpegSession.create( + FFmpegKitConfig.parseArguments(command), + { finishedSession -> + completedSession = finishedSession + latch.countDown() + }, + null, + null, + LogRedirectionStrategy.NEVER_PRINT_LOGS, + ) + val sessionId = session.sessionId + synchronized(activeFFmpegSessionLock) { + activeFFmpegSessionIds.add(sessionId) + nativeFFmpegSessionIds.add(sessionId) + } + FFmpegKitConfig.asyncFFmpegExecute(session) + try { + var cancelRequested = false + while (!latch.await(200, TimeUnit.MILLISECONDS)) { + if (shouldCancel()) { + cancelRequested = true + try { + FFmpegKit.cancel(sessionId) + } catch (_: Exception) { + } + break + } + } + if (cancelRequested) { + latch.await(5, TimeUnit.SECONDS) + throw CancellationException("Native FFmpeg session cancelled") + } + val finalSession = completedSession ?: session + val output = finalSession.getAllLogsAsString(1000) ?: "" + checkCancelled(shouldCancel) + return ReturnCode.isSuccess(finalSession.returnCode) to output + } finally { + synchronized(activeFFmpegSessionLock) { + activeFFmpegSessionIds.remove(sessionId) + } + } + } + + private fun installNativeFFmpegCallbackFilter() { + synchronized(ffmpegCompleteCallbackLock) { + val current = FFmpegKitConfig.getFFmpegSessionCompleteCallback() + if (current !== nativeFilteringFFmpegCompleteCallback) { + forwardedFFmpegCompleteCallback = current + FFmpegKitConfig.enableFFmpegSessionCompleteCallback(nativeFilteringFFmpegCompleteCallback) + } + } + } + + private fun withFFmpegCommandPump( + shouldCancel: () -> Boolean = { false }, + block: () -> String, + ): String { + val running = AtomicBoolean(true) + val handled = mutableSetOf() + val pump = Thread { + while (running.get()) { + if (shouldCancel()) return@Thread + try { + val raw = Gobackend.getAllPendingFFmpegCommandsJSON() + 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 commandLine = command.optString("command", "") + if (id.isBlank() || commandLine.isBlank() || handled.contains(id)) { + continue + } + handled.add(id) + val result = runFFmpeg(commandLine, shouldCancel) + Gobackend.setFFmpegCommandResultByID( + id, + result.first, + result.second, + if (result.first) "" else result.second, + ) + } + } catch (_: Exception) { + } + try { + Thread.sleep(100) + } catch (_: InterruptedException) { + return@Thread + } + } + } + pump.isDaemon = true + pump.start() + return try { + block() + } finally { + running.set(false) + pump.interrupt() + } + } + + private fun buildOutputPath(inputPath: String, extension: String): String { + val ext = normalizeExt(extension).ifBlank { ".tmp" } + val file = File(inputPath) + val base = file.nameWithoutExtension.ifBlank { "track" } + val candidate = File(file.parentFile, "$base$ext").absolutePath + if (candidate != inputPath) return candidate + return File(file.parentFile, "${base}_converted$ext").absolutePath + } + + private fun desiredFileName(input: FinalizeInput, state: FinalizeState, extension: String): String { + val ext = normalizeExt(extension).ifBlank { normalizeExt(File(state.fileName).extension).ifBlank { ".flac" } } + val rawName = input.request.optString("saf_file_name", "") + .ifBlank { state.fileName } + .ifBlank { "${trackString(input, "artistName", input.request.optString("artist_name", "Artist"))} - ${trackString(input, "name", input.request.optString("track_name", "Track"))}" } + val knownExts = listOf(".flac", ".m4a", ".mp4", ".mp3", ".opus", ".ogg", ".lrc") + var base = rawName.trim() + val lower = base.lowercase(Locale.ROOT) + for (knownExt in knownExts) { + if (lower.endsWith(knownExt)) { + base = base.dropLast(knownExt.length) + break + } + } + base = base + .replace("/", " ") + .replace(Regex("[\\\\:*?\"<>|]"), " ") + .trim() + .trim('.', ' ') + .ifBlank { "track" } + return "$base$ext" + } + + private fun shouldForceContainerConversion(input: FinalizeInput, state: FinalizeState): Boolean { + if (input.result.optBoolean("requires_container_conversion", false)) return true + if (input.request.optBoolean("requires_container_conversion", false)) return true + + val actualExt = normalizeExt( + input.result.optString("actual_extension", "") + .ifBlank { input.result.optString("output_extension", "") } + ) + if (actualExt == ".m4a" || actualExt == ".mp4") return true + + val container = input.result.optString("actual_container", "") + .ifBlank { input.result.optString("container", "") } + .trim() + .lowercase(Locale.ROOT) + .removePrefix(".") + return container == "m4a" || container == "mp4" || container == "mov" || container == "aac" + } + + private fun validateRequestContract(request: JSONObject) { + val version = request.optInt("contract_version", -1) + if (version != NATIVE_WORKER_CONTRACT_VERSION) { + throw IllegalArgumentException( + "unsupported native worker request contract v$version" + ) + } + + val required = listOf("item_id", "service", "track_name", "quality", "storage_mode") + val missing = required.filter { request.optString(it, "").trim().isEmpty() } + if (missing.isNotEmpty()) { + throw IllegalArgumentException( + "native worker request missing fields: ${missing.joinToString()}" + ) + } + } + + private fun promoteStagedSafOutputIfNeeded( + context: Context, + input: FinalizeInput, + state: FinalizeState, + ) { + if (!state.filePath.startsWith("content://")) return + if (!input.result.optBoolean("saf_staged_output", false)) return + val stagedName = input.result.optString("saf_staged_file_name", "").trim() + if (stagedName.isNotEmpty() && state.fileName != stagedName) return + + val localInput = materializeForFFmpeg(context, input, state) + try { + replaceStatePath(context, input, state, localInput, deleteOld = true) + } finally { + File(localInput).delete() + } + } + + private fun resolvePreferredDecryptionExtension(inputPath: String, requested: String): String { + val req = normalizeExt(requested) + if (req.isNotBlank()) return req + val lower = inputPath.lowercase(Locale.ROOT) + return when { + lower.endsWith(".m4a") -> ".flac" + lower.endsWith(".flac") -> ".flac" + lower.endsWith(".mp3") -> ".mp3" + lower.endsWith(".opus") -> ".opus" + lower.endsWith(".mp4") -> ".mp4" + else -> ".flac" + } + } + + private fun decryptionKeyCandidates(raw: String): List { + val candidates = linkedSetOf() + fun add(value: String) { + val trimmed = value.trim() + if (trimmed.isNotEmpty()) candidates.add(trimmed) + } + val trimmed = raw.trim() + if (trimmed.isEmpty()) return emptyList() + add(trimmed) + val noPrefix = if (trimmed.startsWith("0x", ignoreCase = true)) trimmed.substring(2) else trimmed + add(noPrefix) + val compactHex = noPrefix.replace(Regex("[^0-9a-fA-F]"), "") + if (compactHex.isNotEmpty() && compactHex.length % 2 == 0) add(compactHex) + try { + val decoded = Base64.decode(noPrefix.replace(Regex("\\s+"), ""), Base64.DEFAULT) + if (decoded.isNotEmpty()) { + add(decoded.joinToString("") { "%02x".format(it) }) + } + } catch (_: Exception) { + } + return candidates.toList() + } + + private fun looksLikeM4a(path: String, fileName: String): Boolean { + val lowerPath = path.lowercase(Locale.ROOT) + val lowerName = fileName.lowercase(Locale.ROOT) + return lowerPath.endsWith(".m4a") || + lowerPath.endsWith(".mp4") || + lowerName.endsWith(".m4a") || + lowerName.endsWith(".mp4") + } + + private fun albumKey(input: FinalizeInput): String { + val albumId = trackString(input, "albumId", "") + if (albumId.isNotBlank()) return "id:$albumId" + val albumName = trackString(input, "albumName", input.request.optString("album_name", "")) + val albumArtist = trackString(input, "albumArtist", input.request.optString("album_artist", "")) + return "name:$albumName|$albumArtist" + } + + private fun replayGainDurationSeconds(input: FinalizeInput): Double { + val duration = input.request.optInt("duration_ms", 0).let { + if (it > 0) it else trackInt(input, "duration", 0) + } + if (duration <= 0) return 1.0 + return if (duration > 10000) duration / 1000.0 else duration.toDouble() + } + + private fun buildHistoryRow(input: FinalizeInput, state: FinalizeState): ContentValues { + val result = input.result + val values = ContentValues() + values.put("id", input.itemId) + values.put("track_name", result.optString("title", "").ifBlank { trackString(input, "name", input.request.optString("track_name", "")) }) + values.put("artist_name", result.optString("artist", "").ifBlank { trackString(input, "artistName", input.request.optString("artist_name", "")) }) + values.put("album_name", result.optString("album", "").ifBlank { trackString(input, "albumName", input.request.optString("album_name", "")) }) + values.put("album_artist", normalizeOptional(trackString(input, "albumArtist", input.request.optString("album_artist", "")))) + values.put("cover_url", normalizeOptional(result.optString("cover_url", "").ifBlank { trackString(input, "coverUrl", input.request.optString("cover_url", "")) })) + values.put("file_path", state.filePath) + values.put("storage_mode", input.request.optString("storage_mode", "app")) + values.put("download_tree_uri", normalizeOptional(input.request.optString("saf_tree_uri", ""))) + values.put("saf_relative_dir", normalizeOptional(input.request.optString("saf_relative_dir", ""))) + values.put("saf_file_name", if (state.filePath.startsWith("content://")) state.fileName else null) + values.put("saf_repaired", 0) + values.put("service", result.optString("service", "").ifBlank { input.item.optString("service", "") }) + values.put("downloaded_at", java.time.Instant.now().toString()) + values.put("isrc", normalizeOptional(result.optString("isrc", "").ifBlank { trackString(input, "isrc", input.request.optString("isrc", "")) })) + values.put("spotify_id", normalizeOptional(trackString(input, "id", input.request.optString("spotify_id", "")))) + values.put("track_number", positiveOrNull(result.optInt("track_number", 0), trackInt(input, "trackNumber", input.request.optInt("track_number", 0)))) + values.put("total_tracks", positiveOrNull(result.optInt("total_tracks", 0), trackInt(input, "totalTracks", input.request.optInt("total_tracks", 0)))) + values.put("disc_number", positiveOrNull(result.optInt("disc_number", 0), trackInt(input, "discNumber", input.request.optInt("disc_number", 0)))) + values.put("total_discs", positiveOrNull(result.optInt("total_discs", 0), trackInt(input, "totalDiscs", input.request.optInt("total_discs", 0)))) + values.put("duration", trackInt(input, "duration", input.request.optInt("duration_ms", 0) / 1000)) + values.put("release_date", normalizeOptional(result.optString("release_date", "").ifBlank { trackString(input, "releaseDate", input.request.optString("release_date", "")) })) + values.put("quality", state.quality) + state.bitDepth?.let { values.put("bit_depth", it) } + state.sampleRate?.let { values.put("sample_rate", it) } + values.put("genre", normalizeOptional(result.optString("genre", "").ifBlank { input.request.optString("genre", "") })) + values.put("composer", normalizeOptional(result.optString("composer", "").ifBlank { trackString(input, "composer", input.request.optString("composer", "")) })) + values.put("label", normalizeOptional(result.optString("label", "").ifBlank { input.request.optString("label", "") })) + values.put("copyright", normalizeOptional(result.optString("copyright", "").ifBlank { input.request.optString("copyright", "") })) + return values + } + + private fun upsertHistory(context: Context, values: ContentValues) { + val dbFile = File(File(context.applicationInfo.dataDir, "app_flutter"), "history.db") + dbFile.parentFile?.mkdirs() + val db = SQLiteDatabase.openDatabase( + dbFile.absolutePath, + null, + SQLiteDatabase.OPEN_READWRITE or + SQLiteDatabase.CREATE_IF_NECESSARY or + SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING, + ) + try { + configureHistoryDatabase(db) + db.beginTransaction() + try { + if (db.version > HISTORY_SCHEMA_VERSION) { + throw IllegalStateException( + "history schema v${db.version} is newer than native finalizer contract v$HISTORY_SCHEMA_VERSION" + ) + } + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS history ( + id TEXT PRIMARY KEY, + track_name TEXT NOT NULL, + artist_name TEXT NOT NULL, + album_name TEXT NOT NULL, + album_artist TEXT, + cover_url TEXT, + file_path TEXT NOT NULL, + storage_mode TEXT, + download_tree_uri TEXT, + saf_relative_dir TEXT, + saf_file_name TEXT, + saf_repaired INTEGER, + service TEXT NOT NULL, + downloaded_at TEXT NOT NULL, + isrc TEXT, + spotify_id TEXT, + track_number INTEGER, + total_tracks INTEGER, + disc_number INTEGER, + total_discs INTEGER, + duration INTEGER, + release_date TEXT, + quality TEXT, + bit_depth INTEGER, + sample_rate INTEGER, + genre TEXT, + composer TEXT, + label TEXT, + copyright TEXT + ) + """.trimIndent() + ) + ensureHistoryColumn(db, "storage_mode", "ALTER TABLE history ADD COLUMN storage_mode TEXT") + ensureHistoryColumn(db, "download_tree_uri", "ALTER TABLE history ADD COLUMN download_tree_uri TEXT") + ensureHistoryColumn(db, "saf_relative_dir", "ALTER TABLE history ADD COLUMN saf_relative_dir TEXT") + ensureHistoryColumn(db, "saf_file_name", "ALTER TABLE history ADD COLUMN saf_file_name TEXT") + ensureHistoryColumn(db, "saf_repaired", "ALTER TABLE history ADD COLUMN saf_repaired INTEGER") + ensureHistoryColumn(db, "composer", "ALTER TABLE history ADD COLUMN composer TEXT") + ensureHistoryColumn(db, "total_tracks", "ALTER TABLE history ADD COLUMN total_tracks INTEGER") + ensureHistoryColumn(db, "total_discs", "ALTER TABLE history ADD COLUMN total_discs INTEGER") + validateHistorySchema(db) + db.execSQL("CREATE INDEX IF NOT EXISTS idx_spotify_id ON history(spotify_id)") + db.execSQL("CREATE INDEX IF NOT EXISTS idx_isrc ON history(isrc)") + db.execSQL("CREATE INDEX IF NOT EXISTS idx_downloaded_at ON history(downloaded_at DESC)") + db.execSQL("CREATE INDEX IF NOT EXISTS idx_album ON history(album_name, album_artist)") + if (db.version < HISTORY_SCHEMA_VERSION) db.version = HISTORY_SCHEMA_VERSION + deleteDuplicateHistoryRows(db, values) + db.insertWithOnConflict("history", null, values, SQLiteDatabase.CONFLICT_REPLACE) + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } finally { + db.close() + } + } + + private fun configureHistoryDatabase(db: SQLiteDatabase) { + runHistoryPragma(db, "PRAGMA busy_timeout = 5000", required = false) + runHistoryPragma(db, "PRAGMA synchronous = NORMAL", required = false) + runHistoryPragma(db, "PRAGMA journal_mode = WAL", required = false) + } + + private fun runHistoryPragma(db: SQLiteDatabase, sql: String, required: Boolean) { + try { + db.rawQuery(sql, null).use { cursor -> + while (cursor.moveToNext()) { + // PRAGMA setters may return a row; consume it so Android closes the cursor cleanly. + } + } + } catch (e: SQLiteException) { + if (required) throw e + Log.w(TAG, "Unable to apply history database setting: $sql", e) + } + } + + private fun validateHistorySchema(db: SQLiteDatabase) { + val columns = mutableSetOf() + db.rawQuery("PRAGMA table_info(history)", null).use { cursor -> + val nameIndex = cursor.getColumnIndex("name") + while (cursor.moveToNext()) { + if (nameIndex >= 0) { + columns.add(cursor.getString(nameIndex).lowercase(Locale.ROOT)) + } + } + } + val missing = requiredHistoryColumns.filterNot { columns.contains(it) } + if (missing.isNotEmpty()) { + throw IllegalStateException("history schema missing columns for native finalizer: ${missing.joinToString()}") + } + } + + private fun deleteDuplicateHistoryRows(db: SQLiteDatabase, values: ContentValues) { + val id = values.getAsString("id") ?: return + val spotifyId = values.getAsString("spotify_id")?.trim().orEmpty() + if (spotifyId.isNotEmpty()) { + db.delete( + "history", + "spotify_id = ? AND id <> ?", + arrayOf(spotifyId, id), + ) + } + + val isrc = values.getAsString("isrc")?.trim().orEmpty() + if (isrc.isNotEmpty()) { + db.delete( + "history", + "isrc = ? AND id <> ?", + arrayOf(isrc, id), + ) + } + + if (spotifyId.isEmpty() && isrc.isEmpty()) { + val trackName = values.getAsString("track_name")?.trim().orEmpty() + val artistName = values.getAsString("artist_name")?.trim().orEmpty() + if (trackName.isNotEmpty() && artistName.isNotEmpty()) { + db.delete( + "history", + "track_name = ? COLLATE NOCASE AND artist_name = ? COLLATE NOCASE AND id <> ?", + arrayOf(trackName, artistName, id), + ) + } + } + } + + private fun ensureHistoryColumn(db: SQLiteDatabase, column: String, alterSql: String) { + db.rawQuery("PRAGMA table_info(history)", null).use { cursor -> + val nameIndex = cursor.getColumnIndex("name") + while (cursor.moveToNext()) { + if (nameIndex >= 0 && cursor.getString(nameIndex).equals(column, ignoreCase = true)) { + return + } + } + } + db.execSQL(alterSql) + } + + private fun historyToJson(values: ContentValues): JSONObject { + val json = JSONObject() + fun putCamel(column: String, key: String) { + if (values.containsKey(column)) json.put(key, values.get(column)) + } + putCamel("id", "id") + putCamel("track_name", "trackName") + putCamel("artist_name", "artistName") + putCamel("album_name", "albumName") + putCamel("album_artist", "albumArtist") + putCamel("cover_url", "coverUrl") + putCamel("file_path", "filePath") + putCamel("storage_mode", "storageMode") + putCamel("download_tree_uri", "downloadTreeUri") + putCamel("saf_relative_dir", "safRelativeDir") + putCamel("saf_file_name", "safFileName") + json.put("safRepaired", values.getAsInteger("saf_repaired") == 1) + putCamel("service", "service") + putCamel("downloaded_at", "downloadedAt") + putCamel("isrc", "isrc") + putCamel("spotify_id", "spotifyId") + putCamel("track_number", "trackNumber") + putCamel("total_tracks", "totalTracks") + putCamel("disc_number", "discNumber") + putCamel("total_discs", "totalDiscs") + putCamel("duration", "duration") + putCamel("release_date", "releaseDate") + putCamel("quality", "quality") + putCamel("bit_depth", "bitDepth") + putCamel("sample_rate", "sampleRate") + putCamel("genre", "genre") + putCamel("composer", "composer") + putCamel("label", "label") + putCamel("copyright", "copyright") + return json + } + + private fun trackString(input: FinalizeInput, key: String, fallback: String): String = + input.track.optString(key, "").ifBlank { fallback } + + private fun trackInt(input: FinalizeInput, key: String, fallback: Int): Int { + val value = input.track.optInt(key, 0) + return if (value > 0) value else fallback + } + + private fun optPositiveInt(obj: JSONObject, key: String): Int? { + val value = obj.optInt(key, 0) + return if (value > 0) value else null + } + + private fun positiveOrNull(primary: Int, fallback: Int): Int? { + val value = if (primary > 0) primary else fallback + return if (value > 0) value else null + } + + private fun normalizeOptional(value: String?): String? { + val trimmed = value?.trim().orEmpty() + return trimmed.ifBlank { null } + } + + private fun normalizeExt(ext: String?): String { + val trimmed = ext?.trim().orEmpty() + if (trimmed.isEmpty()) return "" + return if (trimmed.startsWith(".")) trimmed.lowercase(Locale.ROOT) else ".${trimmed.lowercase(Locale.ROOT)}" + } + + private fun mimeTypeForExt(ext: String?): String { + return when (normalizeExt(ext)) { + ".m4a", ".mp4" -> "audio/mp4" + ".mp3" -> "audio/mpeg" + ".opus", ".ogg" -> "audio/ogg" + ".flac" -> "audio/flac" + ".lrc" -> "application/octet-stream" + else -> "application/octet-stream" + } + } + + private fun q(value: String): String = "\"${value.replace("\"", "\\\"")}\"" +} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt new file mode 100644 index 00000000..27aa45cd --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt @@ -0,0 +1,390 @@ +package com.zarz.spotiflac + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import org.json.JSONObject +import java.io.File +import java.util.Locale + +/** + * Shared SAF download wrapper for foreground activity calls and service-owned + * native workers. + */ +object SafDownloadHandler { + private val safDirLock = Any() + + fun handle(context: Context, requestJson: String, downloader: (String) -> String): 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) + } + + val treeUri = Uri.parse(treeUriStr) + val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", "")) + 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 stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName, outputExt) else fileName + + 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() + } + val obj = JSONObject() + obj.put("success", true) + obj.put("message", "File already exists") + obj.put("file_path", existing.uri.toString()) + obj.put("file_name", existing.name ?: fileName) + obj.put("already_exists", true) + return obj.toString() + } + } + + val targetDir = ensureDocumentDir(context, treeUri, relativeDir) + ?: return errorJson("Failed to access SAF directory") + + var document = createOrReuseDocumentFile(targetDir, mimeType, stagedFileName) + ?: return errorJson("Failed to create SAF file") + + val pfd = context.contentResolver.openFileDescriptor(document.uri, "rw") + ?: return errorJson("Failed to open SAF file") + + var detachedFd: Int? = null + try { + detachedFd = pfd.detachFd() + req.put("output_path", "") + req.put("output_fd", detachedFd) + req.put("output_ext", outputExt) + val response = downloader(req.toString()) + val respObj = JSONObject(response) + if (respObj.optBoolean("success", false)) { + val goFilePath = respObj.optString("file_path", "") + if (goFilePath.isNotEmpty() && + !goFilePath.startsWith("content://") && + !goFilePath.startsWith("/proc/self/fd/") + ) { + try { + val srcFile = File(goFilePath) + if (!srcFile.exists() || srcFile.length() <= 0) { + throw IllegalStateException("extension output missing or empty: $goFilePath") + } + val actualExt = normalizeExt(srcFile.extension) + if (actualExt.isNotBlank()) { + respObj.put("actual_extension", actualExt) + } + if (actualExt.isNotBlank() && actualExt != outputExt) { + val actualFileName = buildSafFileName(req, actualExt) + val actualStagedFileName = if (useStagedOutput) { + buildStagedSafFileName(actualFileName, actualExt) + } else { + actualFileName + } + val actualMimeType = mimeTypeForExt(actualExt) + val replacement = createOrReuseDocumentFile( + targetDir, + actualMimeType, + actualStagedFileName + ) ?: throw IllegalStateException( + "failed to create SAF output with actual extension" + ) + if (replacement.uri != document.uri) { + document.delete() + document = replacement + } + } + context.contentResolver.openOutputStream(document.uri, "wt")?.use { output -> + srcFile.inputStream().use { input -> + input.copyTo(output) + } + } ?: throw IllegalStateException("failed to open SAF output stream") + srcFile.delete() + } catch (e: Exception) { + document.delete() + android.util.Log.w( + "SpotiFLAC", + "Failed to copy extension output to SAF: ${e.message}" + ) + return errorJson("Failed to copy extension output to SAF: ${e.message}") + } + } + respObj.put("file_path", document.uri.toString()) + respObj.put("file_name", document.name ?: fileName) + if (useStagedOutput) { + respObj.put("saf_staged_output", true) + respObj.put("saf_staged_file_name", document.name ?: stagedFileName) + } + } else { + document.delete() + } + return respObj.toString() + } catch (e: Exception) { + document.delete() + return errorJson("SAF download failed: ${e.message}") + } finally { + if (detachedFd == null) { + try { + pfd.close() + } catch (_: Exception) { + } + } + } + } + + fun copyContentUriToTemp(context: Context, uriStr: String): String? { + return try { + val uri = Uri.parse(uriStr) + val extension = DocumentFile.fromSingleUri(context, uri) + ?.name + ?.substringAfterLast('.', "") + ?.takeIf { it.isNotBlank() } + ?.let { ".$it" } + ?: ".tmp" + val temp = File.createTempFile("native_saf_", extension, context.cacheDir) + context.contentResolver.openInputStream(uri)?.use { input -> + temp.outputStream().use { output -> + input.copyTo(output) + } + } ?: return null + temp.absolutePath + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "Failed to copy SAF URI to temp: ${e.message}") + null + } + } + + fun writeFileToSaf( + context: Context, + treeUriStr: String, + relativeDir: String, + fileName: String, + mimeType: String, + srcPath: String + ): String? { + return try { + val treeUri = Uri.parse(treeUriStr) + val targetDir = ensureDocumentDir(context, treeUri, relativeDir) ?: return null + val finalName = sanitizeFilename(fileName) + val ext = normalizeExt(finalName.substringAfterLast('.', "")) + val stagedName = buildStagedSafFileName(finalName, ext) + val document = createOrReuseDocumentFile(targetDir, mimeType, stagedName) + ?: return null + context.contentResolver.openOutputStream(document.uri, "wt")?.use { output -> + File(srcPath).inputStream().use { input -> + input.copyTo(output) + } + } ?: return null + + val existingFinal = targetDir.findFile(finalName) + if (existingFinal != null && existingFinal.uri != document.uri) { + existingFinal.delete() + } + if (!document.renameTo(finalName)) { + document.delete() + return null + } + targetDir.findFile(finalName)?.uri?.toString() ?: document.uri.toString() + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "Failed to write file to SAF: ${e.message}") + null + } + } + + fun deleteContentUri(context: Context, uriStr: String): Boolean { + return try { + DocumentFile.fromSingleUri(context, Uri.parse(uriStr))?.delete() == true + } catch (_: Exception) { + false + } + } + + private fun normalizeExt(ext: String?): String { + if (ext.isNullOrBlank()) return "" + return if (ext.startsWith(".")) { + ext.lowercase(Locale.ROOT) + } else { + ".${ext.lowercase(Locale.ROOT)}" + } + } + + private fun mimeTypeForExt(ext: String?): String { + return when (normalizeExt(ext)) { + ".m4a", ".mp4" -> "audio/mp4" + ".mp3" -> "audio/mpeg" + ".opus" -> "audio/ogg" + ".flac" -> "audio/flac" + ".lrc" -> "application/octet-stream" + else -> "application/octet-stream" + } + } + + private fun forceFilenameExt(name: String, outputExt: String): String { + val normalizedExt = normalizeExt(outputExt) + if (normalizedExt.isBlank()) return sanitizeFilename(name) + + val safeName = sanitizeFilename(name) + val lower = safeName.lowercase(Locale.ROOT) + val knownExts = listOf(".flac", ".m4a", ".mp4", ".mp3", ".opus", ".lrc") + for (knownExt in knownExts) { + if (lower.endsWith(knownExt)) { + return safeName.dropLast(knownExt.length) + normalizedExt + } + } + return safeName + normalizedExt + } + + private fun buildStagedSafFileName(fileName: String, outputExt: String): String { + val safeName = sanitizeFilename(fileName) + val ext = normalizeExt(outputExt) + if (ext.isNotBlank() && safeName.lowercase(Locale.ROOT).endsWith(ext)) { + return safeName.dropLast(ext.length).trimEnd('.', ' ') + ".partial$ext" + } + + val dot = safeName.lastIndexOf('.') + if (dot > 0 && dot < safeName.lastIndex) { + return safeName.substring(0, dot).trimEnd('.', ' ') + + ".partial" + + safeName.substring(dot) + } + return "$safeName.partial" + } + + private fun sanitizeFilename(name: String): String { + var sanitized = name + .replace("/", " ") + .replace(Regex("[\\\\:*?\"<>|]"), " ") + .filter { ch -> + val code = ch.code + !((code < 0x20 && ch != '\t' && ch != '\n' && ch != '\r') || + code == 0x7F || + (Character.isISOControl(ch) && ch != '\t' && ch != '\n' && ch != '\r')) + } + .trim() + .trim('.', ' ') + + sanitized = sanitized + .replace(Regex("\\s+"), " ") + .replace(Regex("_+"), "_") + .trim('_', ' ') + + return if (sanitized.isBlank()) "Unknown" else sanitized + } + + private fun sanitizeRelativeDir(relativeDir: String): String { + if (relativeDir.isBlank()) return "" + return relativeDir + .split("/") + .map { sanitizeFilename(it) } + .filter { it.isNotBlank() && it != "." && it != ".." } + .joinToString("/") + } + + private fun ensureDocumentDir( + context: Context, + treeUri: Uri, + relativeDir: String + ): DocumentFile? { + val safeRelativeDir = sanitizeRelativeDir(relativeDir) + if (safeRelativeDir.isBlank()) { + return DocumentFile.fromTreeUri(context, treeUri) + } + + synchronized(safDirLock) { + var current = DocumentFile.fromTreeUri(context, treeUri) ?: return null + val parts = safeRelativeDir.split("/").filter { it.isNotBlank() } + for (part in parts) { + val existing = current.findFile(part) + current = if (existing != null && existing.isDirectory) { + existing + } else { + val created = current.createDirectory(part) ?: return null + val createdName = created.name ?: part + if (createdName != part) { + created.delete() + current.findFile(part) ?: return null + } else { + created + } + } + } + return current + } + } + + private fun findDocumentDir( + context: Context, + treeUri: Uri, + relativeDir: String + ): DocumentFile? { + var current = DocumentFile.fromTreeUri(context, treeUri) ?: return null + val safeRelativeDir = sanitizeRelativeDir(relativeDir) + if (safeRelativeDir.isBlank()) return current + + val parts = safeRelativeDir.split("/").filter { it.isNotBlank() } + for (part in parts) { + val existing = current.findFile(part) + if (existing == null || !existing.isDirectory) return null + current = existing + } + return current + } + + private fun createOrReuseDocumentFile( + parent: DocumentFile, + mimeType: String, + fileName: String + ): DocumentFile? { + val safeFileName = sanitizeFilename(fileName) + if (safeFileName.isBlank()) return null + + synchronized(safDirLock) { + val existing = parent.findFile(safeFileName) + if (existing != null && existing.isFile) { + return existing + } + + val created = parent.createFile(mimeType, safeFileName) ?: return null + val createdName = created.name ?: safeFileName + if (createdName == safeFileName) { + return created + } + + val winner = parent.findFile(safeFileName) + if (winner != null && winner.isFile) { + if (winner.uri != created.uri) { + try { + created.delete() + } catch (_: Exception) { + } + } + return winner + } + + return created + } + } + + private fun buildSafFileName(req: JSONObject, outputExt: String): String { + val provided = req.optString("saf_file_name", "") + if (provided.isNotBlank()) return forceFilenameExt(provided, outputExt) + + val trackName = req.optString("track_name", "track") + val artistName = req.optString("artist_name", "") + val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName + return forceFilenameExt(baseName, outputExt) + } + + private fun errorJson(message: String): String { + val obj = JSONObject() + obj.put("success", false) + obj.put("error", message) + obj.put("message", message) + return obj.toString() + } +} diff --git a/go_backend/exports.go b/go_backend/exports.go index 7e7f7501..25566881 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -260,97 +260,108 @@ func FetchMusicBrainzGenreByISRC(isrc string) (string, error) { } type DownloadRequest struct { - ISRC string `json:"isrc"` - Service string `json:"service"` - SpotifyID string `json:"spotify_id"` - TrackName string `json:"track_name"` - ArtistName string `json:"artist_name"` - AlbumName string `json:"album_name"` - AlbumArtist string `json:"album_artist"` - CoverURL string `json:"cover_url"` - OutputDir string `json:"output_dir"` - OutputPath string `json:"output_path,omitempty"` - OutputFD int `json:"output_fd,omitempty"` - OutputExt string `json:"output_ext,omitempty"` - FilenameFormat string `json:"filename_format"` - Quality string `json:"quality"` - EmbedMetadata bool `json:"embed_metadata"` - ArtistTagMode string `json:"artist_tag_mode,omitempty"` - EmbedLyrics bool `json:"embed_lyrics"` - EmbedMaxQualityCover bool `json:"embed_max_quality_cover"` - TrackNumber int `json:"track_number"` - DiscNumber int `json:"disc_number"` - TotalTracks int `json:"total_tracks"` - TotalDiscs int `json:"total_discs,omitempty"` - ReleaseDate string `json:"release_date"` - ItemID string `json:"item_id"` - DurationMS int `json:"duration_ms"` - Source string `json:"source"` - Genre string `json:"genre,omitempty"` - Label string `json:"label,omitempty"` - Copyright string `json:"copyright,omitempty"` - Composer string `json:"composer,omitempty"` - TidalID string `json:"tidal_id,omitempty"` - QobuzID string `json:"qobuz_id,omitempty"` - DeezerID string `json:"deezer_id,omitempty"` - LyricsMode string `json:"lyrics_mode,omitempty"` - UseExtensions bool `json:"use_extensions,omitempty"` - UseFallback bool `json:"use_fallback,omitempty"` - SongLinkRegion string `json:"songlink_region,omitempty"` + ContractVersion int `json:"contract_version,omitempty"` + ISRC string `json:"isrc"` + Service string `json:"service"` + SpotifyID string `json:"spotify_id"` + TrackName string `json:"track_name"` + ArtistName string `json:"artist_name"` + AlbumName string `json:"album_name"` + AlbumArtist string `json:"album_artist"` + CoverURL string `json:"cover_url"` + OutputDir string `json:"output_dir"` + OutputPath string `json:"output_path,omitempty"` + OutputFD int `json:"output_fd,omitempty"` + OutputExt string `json:"output_ext,omitempty"` + FilenameFormat string `json:"filename_format"` + Quality string `json:"quality"` + EmbedMetadata bool `json:"embed_metadata"` + ArtistTagMode string `json:"artist_tag_mode,omitempty"` + EmbedLyrics bool `json:"embed_lyrics"` + EmbedMaxQualityCover bool `json:"embed_max_quality_cover"` + EmbedReplayGain bool `json:"embed_replaygain,omitempty"` + PostProcessingEnabled bool `json:"post_processing_enabled,omitempty"` + TidalHighFormat string `json:"tidal_high_format,omitempty"` + TrackNumber int `json:"track_number"` + DiscNumber int `json:"disc_number"` + TotalTracks int `json:"total_tracks"` + TotalDiscs int `json:"total_discs,omitempty"` + ReleaseDate string `json:"release_date"` + ItemID string `json:"item_id"` + DurationMS int `json:"duration_ms"` + Source string `json:"source"` + Genre string `json:"genre,omitempty"` + Label string `json:"label,omitempty"` + Copyright string `json:"copyright,omitempty"` + Composer string `json:"composer,omitempty"` + TidalID string `json:"tidal_id,omitempty"` + QobuzID string `json:"qobuz_id,omitempty"` + DeezerID string `json:"deezer_id,omitempty"` + LyricsMode string `json:"lyrics_mode,omitempty"` + UseExtensions bool `json:"use_extensions,omitempty"` + UseFallback bool `json:"use_fallback,omitempty"` + RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"` + SongLinkRegion string `json:"songlink_region,omitempty"` } type DownloadResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - FilePath string `json:"file_path,omitempty"` - Error string `json:"error,omitempty"` - ErrorType string `json:"error_type,omitempty"` - AlreadyExists bool `json:"already_exists,omitempty"` - ActualBitDepth int `json:"actual_bit_depth,omitempty"` - ActualSampleRate int `json:"actual_sample_rate,omitempty"` - Service string `json:"service,omitempty"` - Title string `json:"title,omitempty"` - Artist string `json:"artist,omitempty"` - Album string `json:"album,omitempty"` - AlbumArtist string `json:"album_artist,omitempty"` - ReleaseDate string `json:"release_date,omitempty"` - TrackNumber int `json:"track_number,omitempty"` - DiscNumber int `json:"disc_number,omitempty"` - TotalTracks int `json:"total_tracks,omitempty"` - TotalDiscs int `json:"total_discs,omitempty"` - ISRC string `json:"isrc,omitempty"` - CoverURL string `json:"cover_url,omitempty"` - Genre string `json:"genre,omitempty"` - Label string `json:"label,omitempty"` - Copyright string `json:"copyright,omitempty"` - Composer string `json:"composer,omitempty"` - SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` - LyricsLRC string `json:"lyrics_lrc,omitempty"` - DecryptionKey string `json:"decryption_key,omitempty"` - Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"` + Success bool `json:"success"` + Message string `json:"message"` + FilePath string `json:"file_path,omitempty"` + Error string `json:"error,omitempty"` + ErrorType string `json:"error_type,omitempty"` + AlreadyExists bool `json:"already_exists,omitempty"` + ActualBitDepth int `json:"actual_bit_depth,omitempty"` + ActualSampleRate int `json:"actual_sample_rate,omitempty"` + ActualExtension string `json:"actual_extension,omitempty"` + ActualContainer string `json:"actual_container,omitempty"` + RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"` + Service string `json:"service,omitempty"` + Title string `json:"title,omitempty"` + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + AlbumArtist string `json:"album_artist,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + TrackNumber int `json:"track_number,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + TotalTracks int `json:"total_tracks,omitempty"` + TotalDiscs int `json:"total_discs,omitempty"` + ISRC string `json:"isrc,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + Genre string `json:"genre,omitempty"` + Label string `json:"label,omitempty"` + Copyright string `json:"copyright,omitempty"` + Composer string `json:"composer,omitempty"` + SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` + LyricsLRC string `json:"lyrics_lrc,omitempty"` + DecryptionKey string `json:"decryption_key,omitempty"` + Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"` } type DownloadResult struct { - FilePath string - BitDepth int - SampleRate int - Title string - Artist string - Album string - ReleaseDate string - TrackNumber int - TotalTracks int - DiscNumber int - TotalDiscs int - ISRC string - CoverURL string - Genre string - Label string - Copyright string - Composer string - LyricsLRC string - DecryptionKey string - Decryption *DownloadDecryptionInfo + FilePath string + BitDepth int + SampleRate int + Title string + Artist string + Album string + ReleaseDate string + TrackNumber int + TotalTracks int + DiscNumber int + TotalDiscs int + ISRC string + CoverURL string + Genre string + Label string + Copyright string + Composer string + LyricsLRC string + DecryptionKey string + Decryption *DownloadDecryptionInfo + ActualExtension string + ActualContainer string + RequiresContainerConversion bool } var fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) { @@ -846,31 +857,34 @@ func buildDownloadSuccessResponse( } return DownloadResponse{ - Success: true, - Message: message, - FilePath: filePath, - AlreadyExists: alreadyExists, - ActualBitDepth: result.BitDepth, - ActualSampleRate: result.SampleRate, - Service: service, - Title: title, - Artist: artist, - Album: album, - AlbumArtist: req.AlbumArtist, - ReleaseDate: releaseDate, - TrackNumber: trackNumber, - TotalTracks: req.TotalTracks, - DiscNumber: discNumber, - TotalDiscs: req.TotalDiscs, - ISRC: isrc, - CoverURL: coverURL, - Genre: genre, - Label: label, - Copyright: copyright, - Composer: composer, - LyricsLRC: result.LyricsLRC, - DecryptionKey: result.DecryptionKey, - Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey), + Success: true, + Message: message, + FilePath: filePath, + AlreadyExists: alreadyExists, + ActualBitDepth: result.BitDepth, + ActualSampleRate: result.SampleRate, + ActualExtension: result.ActualExtension, + ActualContainer: result.ActualContainer, + RequiresContainerConversion: result.RequiresContainerConversion, + Service: service, + Title: title, + Artist: artist, + Album: album, + AlbumArtist: req.AlbumArtist, + ReleaseDate: releaseDate, + TrackNumber: trackNumber, + TotalTracks: req.TotalTracks, + DiscNumber: discNumber, + TotalDiscs: req.TotalDiscs, + ISRC: isrc, + CoverURL: coverURL, + Genre: genre, + Label: label, + Copyright: copyright, + Composer: composer, + LyricsLRC: result.LyricsLRC, + DecryptionKey: result.DecryptionKey, + Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey), } } diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index bc2a4741..bfdd7e3e 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -262,32 +262,52 @@ func resolvePreferredTrackIDForExtension(ext *loadedExtension, req DownloadReque return candidates[0] } +func normalizeDownloadResultExtension(candidates ...string) string { + for _, candidate := range candidates { + ext := strings.TrimSpace(strings.ToLower(candidate)) + if ext == "" { + continue + } + if !strings.HasPrefix(ext, ".") { + ext = "." + ext + } + if ext == ".mp4" { + return ".m4a" + } + return ext + } + return "" +} + func normalizeExtensionDownloadResult(result *ExtDownloadResult) (DownloadResult, bool) { if result == nil { return DownloadResult{}, false } downloadResult := DownloadResult{ - FilePath: strings.TrimSpace(result.FilePath), - BitDepth: result.BitDepth, - SampleRate: result.SampleRate, - Title: result.Title, - Artist: result.Artist, - Album: result.Album, - ReleaseDate: result.ReleaseDate, - TrackNumber: result.TrackNumber, - TotalTracks: result.TotalTracks, - DiscNumber: result.DiscNumber, - TotalDiscs: result.TotalDiscs, - ISRC: result.ISRC, - CoverURL: result.CoverURL, - Genre: result.Genre, - Label: result.Label, - Copyright: result.Copyright, - Composer: result.Composer, - LyricsLRC: result.LyricsLRC, - DecryptionKey: result.DecryptionKey, - Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey), + FilePath: strings.TrimSpace(result.FilePath), + BitDepth: result.BitDepth, + SampleRate: result.SampleRate, + Title: result.Title, + Artist: result.Artist, + Album: result.Album, + ReleaseDate: result.ReleaseDate, + TrackNumber: result.TrackNumber, + TotalTracks: result.TotalTracks, + DiscNumber: result.DiscNumber, + TotalDiscs: result.TotalDiscs, + ISRC: result.ISRC, + CoverURL: result.CoverURL, + Genre: result.Genre, + Label: result.Label, + Copyright: result.Copyright, + Composer: result.Composer, + LyricsLRC: result.LyricsLRC, + DecryptionKey: result.DecryptionKey, + Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey), + ActualExtension: normalizeDownloadResultExtension(result.ActualExtension, result.OutputExtension), + ActualContainer: strings.TrimSpace(result.ActualContainer), + RequiresContainerConversion: result.RequiresContainerConversion, } alreadyExists := result.AlreadyExists @@ -359,6 +379,15 @@ func overlayExtensionDownloadMetadata(resp *DownloadResponse, result *ExtDownloa if normalized := normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey); normalized != nil { resp.Decryption = normalized } + if ext := normalizeDownloadResultExtension(result.ActualExtension, result.OutputExtension); ext != "" { + resp.ActualExtension = ext + } + if container := strings.TrimSpace(result.ActualContainer); container != "" { + resp.ActualContainer = container + } + if result.RequiresContainerConversion { + resp.RequiresContainerConversion = true + } } func applyExtensionRequestFallbacks(resp *DownloadResponse, req DownloadRequest) { @@ -446,24 +475,28 @@ type ExtDownloadResult struct { ErrorMessage string `json:"error_message,omitempty"` ErrorType string `json:"error_type,omitempty"` - Title string `json:"title,omitempty"` - Artist string `json:"artist,omitempty"` - Album string `json:"album,omitempty"` - AlbumArtist string `json:"album_artist,omitempty"` - TrackNumber int `json:"track_number,omitempty"` - DiscNumber int `json:"disc_number,omitempty"` - TotalTracks int `json:"total_tracks,omitempty"` - TotalDiscs int `json:"total_discs,omitempty"` - ReleaseDate string `json:"release_date,omitempty"` - CoverURL string `json:"cover_url,omitempty"` - ISRC string `json:"isrc,omitempty"` - Genre string `json:"genre,omitempty"` - Label string `json:"label,omitempty"` - Copyright string `json:"copyright,omitempty"` - Composer string `json:"composer,omitempty"` - LyricsLRC string `json:"lyrics_lrc,omitempty"` - DecryptionKey string `json:"decryption_key,omitempty"` - Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"` + Title string `json:"title,omitempty"` + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + AlbumArtist string `json:"album_artist,omitempty"` + TrackNumber int `json:"track_number,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + TotalTracks int `json:"total_tracks,omitempty"` + TotalDiscs int `json:"total_discs,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + ISRC string `json:"isrc,omitempty"` + Genre string `json:"genre,omitempty"` + Label string `json:"label,omitempty"` + Copyright string `json:"copyright,omitempty"` + Composer string `json:"composer,omitempty"` + LyricsLRC string `json:"lyrics_lrc,omitempty"` + DecryptionKey string `json:"decryption_key,omitempty"` + Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"` + ActualExtension string `json:"actual_extension,omitempty"` + OutputExtension string `json:"output_extension,omitempty"` + ActualContainer string `json:"actual_container,omitempty"` + RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"` } const genericFFmpegMOVDecryptionStrategy = "ffmpeg.mov_key" @@ -887,31 +920,39 @@ func parseExtensionDownloadDecryptionValue(vm *goja.Runtime, value goja.Value) * func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDownloadResult { obj := value.ToObject(vm) return ExtDownloadResult{ - Success: gojaObjectBool(obj, "success"), - FilePath: gojaObjectString(obj, "file_path", "filePath", "path"), - AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"), - BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"), - SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"), - ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"), - ErrorType: gojaObjectString(obj, "error_type", "errorType"), - Title: gojaObjectString(obj, "title"), - Artist: gojaObjectString(obj, "artist"), - Album: gojaObjectString(obj, "album"), - AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"), - TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"), - DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"), - TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"), - TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"), - ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"), - CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"), - ISRC: gojaObjectString(obj, "isrc"), - Genre: gojaObjectString(obj, "genre"), - Label: gojaObjectString(obj, "label"), - Copyright: gojaObjectString(obj, "copyright"), - Composer: gojaObjectString(obj, "composer"), - LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"), - DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"), - Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")), + Success: gojaObjectBool(obj, "success"), + FilePath: gojaObjectString(obj, "file_path", "filePath", "path"), + AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"), + BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"), + SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"), + ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"), + ErrorType: gojaObjectString(obj, "error_type", "errorType"), + Title: gojaObjectString(obj, "title"), + Artist: gojaObjectString(obj, "artist"), + Album: gojaObjectString(obj, "album"), + AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"), + TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"), + DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"), + TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"), + TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"), + ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"), + CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"), + ISRC: gojaObjectString(obj, "isrc"), + Genre: gojaObjectString(obj, "genre"), + Label: gojaObjectString(obj, "label"), + Copyright: gojaObjectString(obj, "copyright"), + Composer: gojaObjectString(obj, "composer"), + LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"), + DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"), + Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")), + ActualExtension: gojaObjectString(obj, "actual_extension", "actualExtension"), + OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"), + ActualContainer: gojaObjectString(obj, "actual_container", "actualContainer", "container"), + RequiresContainerConversion: gojaObjectBool( + obj, + "requires_container_conversion", + "requiresContainerConversion", + ), } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 54874e2d..b9238ea7 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -129,7 +129,7 @@ abstract class AppLocalizations { /// App name - DO NOT TRANSLATE /// /// In en, this message translates to: - /// **'SpotiFLAC'** + /// **'SpotiFLAC Mobile'** String get appName; /// Bottom navigation - Home tab diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 82cada60..ee44ac28 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -58,6 +58,8 @@ class AppSettings { networkCompatibilityMode; // Try HTTP + allow invalid TLS cert for API requests final String songLinkRegion; // SongLink userCountry region code used for platform lookup + final bool + nativeDownloadWorkerEnabled; // Experimental Android service-owned worker final bool localLibraryEnabled; // Enable local library scanning final String localLibraryPath; // Path to scan for audio files @@ -133,6 +135,7 @@ class AppSettings { this.downloadNetworkMode = 'any', this.networkCompatibilityMode = false, this.songLinkRegion = 'US', + this.nativeDownloadWorkerEnabled = false, this.localLibraryEnabled = false, this.localLibraryPath = '', this.localLibraryBookmark = '', @@ -196,6 +199,7 @@ class AppSettings { String? downloadNetworkMode, bool? networkCompatibilityMode, String? songLinkRegion, + bool? nativeDownloadWorkerEnabled, bool? localLibraryEnabled, String? localLibraryPath, String? localLibraryBookmark, @@ -269,6 +273,8 @@ class AppSettings { networkCompatibilityMode: networkCompatibilityMode ?? this.networkCompatibilityMode, songLinkRegion: songLinkRegion ?? this.songLinkRegion, + nativeDownloadWorkerEnabled: + nativeDownloadWorkerEnabled ?? this.nativeDownloadWorkerEnabled, localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled, localLibraryPath: localLibraryPath ?? this.localLibraryPath, localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 1cf7dc0f..277f0809 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -58,6 +58,8 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( downloadNetworkMode: json['downloadNetworkMode'] as String? ?? 'any', networkCompatibilityMode: json['networkCompatibilityMode'] as bool? ?? false, songLinkRegion: json['songLinkRegion'] as String? ?? 'US', + nativeDownloadWorkerEnabled: + json['nativeDownloadWorkerEnabled'] as bool? ?? false, localLibraryEnabled: json['localLibraryEnabled'] as bool? ?? false, localLibraryPath: json['localLibraryPath'] as String? ?? '', localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '', @@ -129,6 +131,7 @@ Map _$AppSettingsToJson( 'downloadNetworkMode': instance.downloadNetworkMode, 'networkCompatibilityMode': instance.networkCompatibilityMode, 'songLinkRegion': instance.songLinkRegion, + 'nativeDownloadWorkerEnabled': instance.nativeDownloadWorkerEnabled, 'localLibraryEnabled': instance.localLibraryEnabled, 'localLibraryPath': instance.localLibraryPath, 'localLibraryBookmark': instance.localLibraryBookmark, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 5366fc1b..8868f3bf 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -835,7 +835,7 @@ class DownloadHistoryNotifier extends Notifier { await _loadFromDatabase(); } - void addToHistory(DownloadHistoryItem item) { + DownloadHistoryItem _putInMemoryHistory(DownloadHistoryItem item) { DownloadHistoryItem? existing; if (item.spotifyId != null && item.spotifyId!.isNotEmpty) { existing = state.getBySpotifyId(item.spotifyId!); @@ -876,12 +876,20 @@ class DownloadHistoryNotifier extends Notifier { state = state.copyWith(items: [mergedItem, ...state.items]); _historyLog.d('Added new history entry: ${mergedItem.trackName}'); } + return mergedItem; + } + void addToHistory(DownloadHistoryItem item) { + final mergedItem = _putInMemoryHistory(item); _db.upsert(mergedItem.toJson()).catchError((Object e) { _historyLog.e('Failed to save to database: $e'); }); } + void adoptNativeHistoryItem(DownloadHistoryItem item) { + _putInMemoryHistory(item); + } + void removeFromHistory(String id) { state = state.copyWith( items: state.items.where((item) => item.id != id).toList(), @@ -1316,6 +1324,30 @@ class _ProgressUpdate { }); } +class _NativeWorkerRequestContext { + final DownloadItem item; + final String requestJson; + final String outputDir; + final String quality; + final String storageMode; + final String outputExt; + final String? downloadTreeUri; + final String? safRelativeDir; + final String? safFileName; + + const _NativeWorkerRequestContext({ + required this.item, + required this.requestJson, + required this.outputDir, + required this.quality, + required this.storageMode, + required this.outputExt, + this.downloadTreeUri, + this.safRelativeDir, + this.safFileName, + }); +} + class DownloadQueueNotifier extends Notifier { Timer? _progressTimer; Timer? _progressStreamBootstrapTimer; @@ -1328,6 +1360,8 @@ class DownloadQueueNotifier extends Notifier { static const _idleProgressPollEveryTicks = 3; static const _queueSchedulingInterval = Duration(milliseconds: 250); static const _queuePersistDebounceDuration = Duration(milliseconds: 350); + static const _nativeWorkerRunIdPrefsKey = + 'download_queue_native_worker_run_id'; static const _bytesUiStep = 104857; // ~0.1 MiB, matches one-decimal MB UI. static const _serviceProgressStepPercent = 2; final NotificationService _notificationService = NotificationService(); @@ -1358,6 +1392,7 @@ class DownloadQueueNotifier extends Notifier { int _lastNotifQueueCount = -1; final Set _locallyCancelledItemIds = {}; final Set _pausePendingItemIds = {}; + String? _activeNativeWorkerRunId; // Album ReplayGain accumulator: keyed by album identifier. // Stores per-track loudness data until all album tracks are done, @@ -1474,7 +1509,8 @@ class DownloadQueueNotifier extends Notifier { if (normalizedService != item.service) { item = item.copyWith(service: normalizedService); } - if (item.status == DownloadStatus.downloading) { + if (item.status == DownloadStatus.downloading || + item.status == DownloadStatus.finalizing) { item = item.copyWith(status: DownloadStatus.queued, progress: 0); } if (item.status == DownloadStatus.queued) { @@ -1496,6 +1532,9 @@ class DownloadQueueNotifier extends Notifier { _log.i( 'Restored ${normalizedPendingItems.length} pending items from storage', ); + if (await _tryAdoptAndroidNativeWorkerSnapshot(normalizedPendingItems)) { + return; + } Future.microtask(() => _processQueue()); } catch (e) { _log.e('Failed to load queue from storage: $e'); @@ -1515,7 +1554,8 @@ class DownloadQueueNotifier extends Notifier { .where( (item) => item.status == DownloadStatus.queued || - item.status == DownloadStatus.downloading, + item.status == DownloadStatus.downloading || + item.status == DownloadStatus.finalizing, ) .toList(); @@ -2466,6 +2506,21 @@ class DownloadQueueNotifier extends Notifier { ); } + bool _extensionRequiresNativeContainerConversion(String service) { + final normalizedService = service.trim().toLowerCase(); + if (normalizedService.isEmpty) return false; + + final extensionState = ref.read(extensionProvider); + return extensionState.extensions.any( + (ext) => + ext.enabled && + ext.hasDownloadProvider && + (ext.id.toLowerCase() == normalizedService || + ext.replacesBuiltInProviders.contains(normalizedService)) && + ext.requiresNativeContainerConversion, + ); + } + String _determineOutputExt(String quality, String service) { final extensionPreferred = _extensionPreferredOutputExt(service); if (extensionPreferred != null) { @@ -3249,6 +3304,10 @@ class DownloadQueueNotifier extends Notifier { } state = state.copyWith(items: [], isPaused: false, currentDownload: null); + if (Platform.isAndroid && + ref.read(settingsProvider).nativeDownloadWorkerEnabled) { + PlatformBridge.cancelNativeDownloadWorker().catchError((_) {}); + } _notificationService.cancelDownloadNotification(); _saveQueueToStorage(); _albumRgData.clear(); @@ -3260,6 +3319,10 @@ class DownloadQueueNotifier extends Notifier { void pauseQueue() { if (state.isProcessing && !state.isPaused) { + if (Platform.isAndroid && + ref.read(settingsProvider).nativeDownloadWorkerEnabled) { + PlatformBridge.pauseNativeDownloadWorker().catchError((_) {}); + } final activeIds = state.items .where( (item) => @@ -3285,6 +3348,10 @@ class DownloadQueueNotifier extends Notifier { void resumeQueue() { if (state.isPaused) { + if (Platform.isAndroid && + ref.read(settingsProvider).nativeDownloadWorkerEnabled) { + PlatformBridge.resumeNativeDownloadWorker().catchError((_) {}); + } state = state.copyWith(isPaused: false); _log.i('Queue resumed'); if (state.queuedCount > 0 && !state.isProcessing) { @@ -3476,7 +3543,7 @@ class DownloadQueueNotifier extends Notifier { } } - Future _runPostProcessingHooks(String filePath, Track track) async { + Future _runPostProcessingHooks(String filePath, Track track) async { try { final settings = ref.read(settingsProvider); final extensionState = ref.read(extensionProvider); @@ -3485,12 +3552,12 @@ class DownloadQueueNotifier extends Notifier { settings, ); - if (!settings.useExtensionProviders) return; + if (!settings.useExtensionProviders) return null; final hasPostProcessing = extensionState.extensions.any( (e) => e.enabled && e.hasPostProcessing, ); - if (!hasPostProcessing) return; + if (!hasPostProcessing) return null; _log.d('Running post-processing hooks on: $filePath'); @@ -3521,7 +3588,9 @@ class DownloadQueueNotifier extends Notifier { if (newPath != null && newPath != filePath) { _log.d('File path changed by post-processing: $newPath'); + return newPath; } + return filePath; } else { final error = result['error'] as String? ?? 'Unknown error'; _log.w('Post-processing failed: $error'); @@ -3529,6 +3598,7 @@ class DownloadQueueNotifier extends Notifier { } catch (e) { _log.w('Post-processing error: $e'); } + return null; } // --------------------------------------------------------------------------- @@ -4343,6 +4413,1328 @@ class DownloadQueueNotifier extends Notifier { } } + bool _canUseAndroidNativeWorker(AppSettings settings) { + if (!Platform.isAndroid || !settings.nativeDownloadWorkerEnabled) { + return false; + } + if (!settings.useExtensionProviders) { + return false; + } + if (_isSafMode(settings)) { + if (settings.downloadTreeUri.isEmpty) { + return false; + } + } + final extensionState = ref.read(extensionProvider); + final hasEnabledDownloadProvider = extensionState.extensions.any( + (extension) => extension.enabled && extension.hasDownloadProvider, + ); + if (!hasEnabledDownloadProvider) { + return false; + } + return true; + } + + String _newNativeWorkerRunId() => + 'native-${DateTime.now().microsecondsSinceEpoch}-${Random().nextInt(1 << 32)}'; + + String _snapshotRunId(Map snapshot) { + final direct = snapshot['run_id']?.toString() ?? ''; + if (direct.isNotEmpty) return direct; + + final settingsJson = snapshot['settings_json']; + if (settingsJson is String && settingsJson.isNotEmpty) { + try { + final decoded = jsonDecode(settingsJson); + if (decoded is Map) { + return decoded['run_id']?.toString() ?? ''; + } + } catch (_) {} + } else if (settingsJson is Map) { + return settingsJson['run_id']?.toString() ?? ''; + } + return ''; + } + + bool _isNativeWorkerSnapshotContractCompatible( + Map snapshot, + ) { + final version = snapshot['contract_version']; + return version == DownloadRequestPayload.nativeWorkerContractVersion; + } + + bool _isNativeWorkerSnapshotForRun( + Map snapshot, + String runId, + ) => + runId.isNotEmpty && + _snapshotRunId(snapshot) == runId && + _isNativeWorkerSnapshotContractCompatible(snapshot); + + Future _persistNativeWorkerRunId(String runId) async { + _activeNativeWorkerRunId = runId; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_nativeWorkerRunIdPrefsKey, runId); + } + + Future _loadNativeWorkerRunId() async { + if (_activeNativeWorkerRunId != null) return _activeNativeWorkerRunId; + final prefs = await SharedPreferences.getInstance(); + final runId = prefs.getString(_nativeWorkerRunIdPrefsKey); + if (runId != null && runId.isNotEmpty) { + _activeNativeWorkerRunId = runId; + return runId; + } + return null; + } + + Future _clearNativeWorkerRunId(String runId) async { + if (_activeNativeWorkerRunId == runId) { + _activeNativeWorkerRunId = null; + } + final prefs = await SharedPreferences.getInstance(); + if (prefs.getString(_nativeWorkerRunIdPrefsKey) == runId) { + await prefs.remove(_nativeWorkerRunIdPrefsKey); + } + } + + Future _tryAdoptAndroidNativeWorkerSnapshot( + List restoredItems, + ) async { + final settings = ref.read(settingsProvider); + if (!_canUseAndroidNativeWorker(settings)) { + return false; + } + + Map snapshot; + try { + snapshot = await PlatformBridge.getNativeDownloadWorkerSnapshot(); + } catch (_) { + return false; + } + final runId = await _loadNativeWorkerRunId(); + if (runId == null || + runId.isEmpty || + !_isNativeWorkerSnapshotForRun(snapshot, runId)) { + return false; + } + + final rawItems = snapshot['items']; + if (rawItems is! List || rawItems.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; + } + + final contexts = {}; + for (final item in restoredItems) { + if (!snapshotIds.contains(item.id)) continue; + final context = await _buildAndroidNativeWorkerRequest(item, settings); + if (context != null) { + contexts[item.id] = context; + } + } + if (contexts.isEmpty) { + return false; + } + + _log.i('Adopting Android native worker snapshot'); + final reconciledIds = {}; + _totalQueuedAtStart = contexts.length; + _completedInSession = 0; + _failedInSession = 0; + state = state.copyWith( + isProcessing: snapshot['is_running'] == true, + isPaused: snapshot['is_paused'] == true, + ); + await _applyAndroidNativeWorkerSnapshot( + snapshot, + contexts, + reconciledIds, + settings, + ); + + if (snapshot['is_running'] == true) { + unawaited( + _continueAndroidNativeWorkerAdoption( + contexts, + reconciledIds, + settings, + runId, + ), + ); + } else if (state.items.any( + (item) => item.status == DownloadStatus.queued, + )) { + await _clearNativeWorkerRunId(runId); + Future.microtask(() => _processQueue()); + } else { + await _clearNativeWorkerRunId(runId); + } + + return true; + } + + Future _continueAndroidNativeWorkerAdoption( + Map contexts, + Set reconciledIds, + AppSettings settings, + String runId, + ) async { + try { + while (true) { + final snapshot = await PlatformBridge.getNativeDownloadWorkerSnapshot(); + if (!_isNativeWorkerSnapshotForRun(snapshot, runId)) { + await Future.delayed(const Duration(seconds: 1)); + continue; + } + await _applyAndroidNativeWorkerSnapshot( + snapshot, + contexts, + reconciledIds, + settings, + ); + if (snapshot['is_running'] != true) { + await _clearNativeWorkerRunId(runId); + break; + } + await Future.delayed(const Duration(seconds: 1)); + } + } catch (e) { + _log.w('Android native worker adoption stopped: $e'); + } finally { + state = state.copyWith(isProcessing: false, currentDownload: null); + } + } + + Future _tryProcessQueueWithAndroidNativeWorker( + AppSettings settings, + ) async { + if (!_canUseAndroidNativeWorker(settings)) { + return false; + } + + final queuedItems = state.items + .where((item) => item.status == DownloadStatus.queued) + .toList(growable: false); + if (queuedItems.isEmpty) { + return false; + } + + _log.i( + 'Starting Android native download worker for ${queuedItems.length} items', + ); + + final isSafMode = _isSafMode(settings); + if (!isSafMode && state.outputDir.isEmpty) { + await _initOutputDir(); + } + if (!isSafMode && state.outputDir.isEmpty) { + final musicDir = await _ensureDefaultDocumentsOutputDir(); + state = state.copyWith(outputDir: musicDir.path); + } + + final contexts = {}; + final requests = >[]; + for (final item in queuedItems) { + final context = await _buildAndroidNativeWorkerRequest(item, settings); + if (context == null) { + _log.w( + 'Native worker gate rejected ${item.track.name}; falling back to Dart queue', + ); + return false; + } + contexts[item.id] = context; + requests.add({ + 'contract_version': DownloadRequestPayload.nativeWorkerContractVersion, + 'item_id': item.id, + 'track_name': item.track.name, + 'artist_name': item.track.artistName, + 'item_json': jsonEncode(item.toJson()), + 'request_json': context.requestJson, + }); + } + + state = state.copyWith(isProcessing: true, isPaused: false); + _totalQueuedAtStart = queuedItems.length; + _completedInSession = 0; + _failedInSession = 0; + + final runId = _newNativeWorkerRunId(); + await _persistNativeWorkerRunId(runId); + final reconciledIds = {}; + try { + await PlatformBridge.startNativeDownloadWorker( + requests: requests, + settings: { + 'worker': 'android_native', + 'version': 1, + 'contract_version': + DownloadRequestPayload.nativeWorkerContractVersion, + 'run_id': runId, + 'created_at': DateTime.now().toIso8601String(), + }, + ); + + final runStartWait = Stopwatch()..start(); + while (true) { + final snapshot = await PlatformBridge.getNativeDownloadWorkerSnapshot(); + if (!_isNativeWorkerSnapshotForRun(snapshot, runId)) { + if (runStartWait.elapsed > const Duration(seconds: 30)) { + throw _NativeWorkerStartupTimeout(); + } + await Future.delayed(const Duration(milliseconds: 250)); + continue; + } + await _applyAndroidNativeWorkerSnapshot( + snapshot, + contexts, + reconciledIds, + settings, + ); + if (snapshot['is_running'] != true) { + await _clearNativeWorkerRunId(runId); + break; + } + await Future.delayed(const Duration(seconds: 1)); + } + } catch (e, stack) { + if (e is _NativeWorkerStartupTimeout) { + _log.w( + 'Android native worker did not publish a matching snapshot; cancelling native worker and falling back to Dart queue', + ); + try { + await PlatformBridge.cancelNativeDownloadWorker(); + } catch (cancelError) { + _log.w('Failed to cancel timed-out native worker: $cancelError'); + } + await _clearNativeWorkerRunId(runId); + state = state.copyWith(isProcessing: false, currentDownload: null); + await Future.delayed(const Duration(milliseconds: 500)); + return false; + } + _log.e('Android native worker failed: $e', e, stack); + for (final item in queuedItems) { + final current = _findItemById(item.id); + if (current == null || + current.status == DownloadStatus.completed || + current.status == DownloadStatus.failed || + current.status == DownloadStatus.skipped) { + continue; + } + updateItemStatus( + item.id, + DownloadStatus.failed, + error: 'Native download worker failed: $e', + errorType: DownloadErrorType.unknown, + ); + _failedInSession++; + } + } finally { + state = state.copyWith(isProcessing: false, currentDownload: null); + _stopConnectivityMonitoring(); + try { + await PlatformBridge.cleanupConnections(); + } catch (e) { + _log.e('Native worker cleanup failed: $e'); + } + } + + if (_totalQueuedAtStart > 0) { + await _notificationService.showQueueComplete( + completedCount: _completedInSession, + failedCount: _failedInSession, + ); + } + + final hasQueuedItems = state.items.any( + (item) => item.status == DownloadStatus.queued, + ); + if (hasQueuedItems && !state.isPaused) { + _log.i( + 'Found queued items after Android native worker finished, restarting queue...', + ); + Future.microtask(() => _processQueue()); + } + + return true; + } + + Future<_NativeWorkerRequestContext?> _buildAndroidNativeWorkerRequest( + DownloadItem item, + AppSettings settings, + ) async { + if (!_hasActiveDownloadProvider(item.service)) { + return null; + } + + var quality = item.qualityOverride ?? state.audioQuality; + if (quality == 'DEFAULT') quality = state.audioQuality; + + final isSafMode = _isSafMode(settings); + final outputDir = isSafMode + ? await _buildRelativeOutputDir( + item.track, + settings.folderOrganization, + separateSingles: settings.separateSingles, + albumFolderStructure: settings.albumFolderStructure, + createPlaylistFolder: settings.createPlaylistFolder, + useAlbumArtistForFolders: settings.useAlbumArtistForFolders, + usePrimaryArtistOnly: settings.usePrimaryArtistOnly, + filterContributingArtistsInAlbumArtist: + settings.filterContributingArtistsInAlbumArtist, + playlistName: item.playlistName, + ) + : await _buildOutputDir( + item.track, + settings.folderOrganization, + separateSingles: settings.separateSingles, + albumFolderStructure: settings.albumFolderStructure, + createPlaylistFolder: settings.createPlaylistFolder, + useAlbumArtistForFolders: settings.useAlbumArtistForFolders, + usePrimaryArtistOnly: settings.usePrimaryArtistOnly, + filterContributingArtistsInAlbumArtist: + settings.filterContributingArtistsInAlbumArtist, + playlistName: item.playlistName, + ); + if (!isSafMode) { + await _ensureDirExists(outputDir, label: 'Output folder'); + } + + final outputExt = _determineOutputExt(quality, item.service); + if (settings.embedReplayGain && + outputExt != '.flac' && + outputExt != '.m4a') { + return null; + } + + String? safFileName; + final safOutputExt = isSafMode ? outputExt : ''; + if (isSafMode) { + final effectiveFormat = _shouldTreatAsSingleRelease(item.track) + ? state.singleFilenameFormat + : state.filenameFormat; + final baseName = await PlatformBridge.buildFilename(effectiveFormat, { + 'title': item.track.name, + 'artist': item.track.artistName, + 'album': item.track.albumName, + 'track': item.track.trackNumber ?? 0, + 'disc': item.track.discNumber ?? 0, + 'year': _extractYear(item.track.releaseDate) ?? '', + 'date': item.track.releaseDate ?? '', + }); + final sanitized = await PlatformBridge.sanitizeFilename(baseName); + safFileName = '$sanitized$safOutputExt'; + } + + var trackForPayload = item.track; + String? nativeDeezerTrackId = _extractKnownDeezerTrackId(trackForPayload); + String? nativeGenre; + String? nativeLabel; + String? nativeCopyright; + + if (nativeDeezerTrackId == null && + trackForPayload.isrc != null && + trackForPayload.isrc!.isNotEmpty && + _isValidISRC(trackForPayload.isrc!)) { + nativeDeezerTrackId = await _searchDeezerTrackIdByIsrc( + trackForPayload.isrc, + lookupContext: 'native worker ISRC', + itemId: item.id, + ); + } + + if (nativeDeezerTrackId == null && + (trackForPayload.isrc == null || + trackForPayload.isrc!.isEmpty || + !_isValidISRC(trackForPayload.isrc!)) && + (trackForPayload.id.startsWith('tidal:') || + trackForPayload.id.startsWith('qobuz:'))) { + final providerLookup = await _resolveProviderTrackForDeezerLookup( + trackForPayload, + item.id, + ); + trackForPayload = providerLookup.track; + nativeDeezerTrackId ??= providerLookup.deezerTrackId; + } + + if (nativeDeezerTrackId != null && nativeDeezerTrackId.isNotEmpty) { + final extendedMetadata = await _loadDeezerExtendedMetadata( + nativeDeezerTrackId, + ); + nativeGenre = extendedMetadata.genre; + nativeLabel = extendedMetadata.label; + nativeCopyright = extendedMetadata.copyright; + } + + final resolvedAlbumArtist = _resolveAlbumArtistForMetadata( + trackForPayload, + settings, + ); + final extensionState = ref.read(extensionProvider); + final postProcessingEnabled = + settings.useExtensionProviders && + extensionState.extensions.any((e) => e.enabled && e.hasPostProcessing); + final normalizedTrackNumber = + (trackForPayload.trackNumber != null && + trackForPayload.trackNumber! > 0) + ? trackForPayload.trackNumber! + : 0; + final normalizedDiscNumber = + (trackForPayload.discNumber != null && trackForPayload.discNumber! > 0) + ? trackForPayload.discNumber! + : 0; + + String payloadSpotifyId = trackForPayload.id; + String payloadQobuzId = ''; + String payloadTidalId = ''; + if (trackForPayload.id.startsWith('qobuz:')) { + payloadQobuzId = trackForPayload.id.substring(6); + if (_usesBuiltInCompatibleDownloadProvider(item.service, 'qobuz')) { + payloadSpotifyId = ''; + } + } + if (trackForPayload.id.startsWith('tidal:')) { + payloadTidalId = trackForPayload.id.substring(6); + if (_usesBuiltInCompatibleDownloadProvider(item.service, 'tidal')) { + payloadSpotifyId = ''; + } + } + + final payload = DownloadRequestPayload( + isrc: trackForPayload.isrc ?? '', + service: item.service, + spotifyId: payloadSpotifyId, + trackName: trackForPayload.name, + artistName: trackForPayload.artistName, + albumName: trackForPayload.albumName, + albumArtist: resolvedAlbumArtist ?? '', + coverUrl: settings.embedMetadata ? (trackForPayload.coverUrl ?? '') : '', + outputDir: outputDir, + filenameFormat: _shouldTreatAsSingleRelease(trackForPayload) + ? state.singleFilenameFormat + : state.filenameFormat, + quality: quality, + embedMetadata: settings.embedMetadata, + artistTagMode: settings.artistTagMode, + embedLyrics: + settings.embedMetadata && + settings.embedLyrics && + !_shouldSkipLyrics( + extensionState, + trackForPayload.source, + item.service, + ), + embedMaxQualityCover: settings.embedMetadata && settings.maxQualityCover, + embedReplayGain: settings.embedReplayGain, + postProcessingEnabled: postProcessingEnabled, + tidalHighFormat: settings.tidalHighFormat, + trackNumber: normalizedTrackNumber, + discNumber: normalizedDiscNumber, + totalTracks: trackForPayload.totalTracks ?? 0, + totalDiscs: trackForPayload.totalDiscs ?? 0, + releaseDate: trackForPayload.releaseDate ?? '', + itemId: item.id, + durationMs: trackForPayload.duration * 1000, + source: trackForPayload.source ?? '', + genre: nativeGenre ?? '', + label: nativeLabel ?? '', + copyright: nativeCopyright ?? '', + composer: trackForPayload.composer ?? '', + qobuzId: payloadQobuzId, + tidalId: payloadTidalId, + deezerId: nativeDeezerTrackId ?? '', + lyricsMode: settings.lyricsMode, + storageMode: isSafMode ? 'saf' : 'app', + safTreeUri: isSafMode ? settings.downloadTreeUri : '', + safRelativeDir: isSafMode ? outputDir : '', + safFileName: safFileName ?? '', + safOutputExt: safOutputExt, + stageSafOutput: isSafMode, + requiresContainerConversion: + outputExt == '.flac' && + _extensionRequiresNativeContainerConversion(item.service), + songLinkRegion: settings.songLinkRegion, + ).withStrategy(useExtensions: true, useFallback: state.autoFallback); + + return _NativeWorkerRequestContext( + item: item, + requestJson: jsonEncode(payload.toJson()), + outputDir: outputDir, + quality: quality, + storageMode: isSafMode ? 'saf' : 'app', + outputExt: outputExt, + downloadTreeUri: isSafMode ? settings.downloadTreeUri : null, + safRelativeDir: isSafMode ? outputDir : null, + safFileName: safFileName, + ); + } + + Future _applyAndroidNativeWorkerSnapshot( + Map snapshot, + Map contexts, + Set reconciledIds, + AppSettings settings, + ) async { + final rawItems = snapshot['items']; + if (rawItems is! List) { + return; + } + + for (final rawItem in rawItems) { + if (rawItem is! Map) continue; + final itemSnapshot = Map.from(rawItem); + final itemId = itemSnapshot['item_id']?.toString() ?? ''; + if (itemId.isEmpty || reconciledIds.contains(itemId)) { + continue; + } + final context = contexts[itemId]; + if (context == null) continue; + + final status = itemSnapshot['status']?.toString() ?? 'queued'; + final progress = ((itemSnapshot['progress'] as num?)?.toDouble() ?? 0.0) + .clamp(0.0, 1.0) + .toDouble(); + final current = _findItemById(itemId); + if (current == null) { + reconciledIds.add(itemId); + continue; + } + + if (status == 'queued') { + updateItemStatus(itemId, DownloadStatus.queued, progress: 0.0); + continue; + } + + if (status == 'downloading') { + updateItemStatus( + itemId, + DownloadStatus.downloading, + progress: progress, + ); + continue; + } + + if (status == 'finalizing') { + updateItemStatus( + itemId, + DownloadStatus.finalizing, + progress: progress <= 0 ? 0.95 : progress, + ); + continue; + } + + if (status == 'completed') { + final result = itemSnapshot['result']; + if (result is Map) { + reconciledIds.add(itemId); + await _completeAndroidNativeWorkerItem( + context, + Map.from(result), + settings, + ); + } + continue; + } + + if (status == 'failed' || status == 'skipped') { + reconciledIds.add(itemId); + final result = itemSnapshot['result']; + final error = itemSnapshot['error']?.toString(); + if (status == 'skipped') { + updateItemStatus(itemId, DownloadStatus.skipped); + } else { + final errorType = result is Map + ? _downloadErrorTypeFromBackend( + Map.from(result)['error_type']?.toString(), + ) + : DownloadErrorType.unknown; + updateItemStatus( + itemId, + DownloadStatus.failed, + error: error == null || error.isEmpty ? 'Download failed' : error, + errorType: errorType, + ); + _failedInSession++; + } + } + } + } + + Future _completeAndroidNativeWorkerItem( + _NativeWorkerRequestContext context, + Map result, + AppSettings settings, + ) async { + final item = context.item; + var filePath = result['file_path'] as String?; + if (filePath == null || filePath.isEmpty) { + updateItemStatus( + item.id, + DownloadStatus.failed, + error: 'Native worker completed without a file path', + errorType: DownloadErrorType.unknown, + ); + _failedInSession++; + return; + } + + if (result['native_finalized'] == true) { + updateItemStatus( + item.id, + DownloadStatus.completed, + progress: 1.0, + filePath: filePath, + ); + final historyItem = result['history_item']; + if (historyItem is Map) { + try { + ref + .read(downloadHistoryProvider.notifier) + .adoptNativeHistoryItem( + DownloadHistoryItem.fromJson( + Map.from(historyItem), + ), + ); + } catch (e) { + _log.w('Failed to adopt native history item: $e'); + await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); + } + } else if (result['history_written'] == true) { + await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); + } + _completedInSession++; + await _notificationService.showDownloadComplete( + trackName: item.track.name, + artistName: item.track.artistName, + completedCount: _completedInSession, + totalCount: _totalQueuedAtStart, + alreadyInLibrary: result['already_exists'] == true, + ); + removeItem(item.id); + return; + } + + final finalizedPath = await _finalizeNativeWorkerDecryption( + context: context, + result: result, + filePath: filePath, + ); + if (finalizedPath == null) { + updateItemStatus( + item.id, + DownloadStatus.failed, + error: 'Failed to decrypt encrypted stream', + errorType: DownloadErrorType.unknown, + ); + _failedInSession++; + return; + } + filePath = finalizedPath; + + var actualQuality = context.quality; + final actualBitDepth = result['actual_bit_depth'] as int?; + final actualSampleRate = result['actual_sample_rate'] as int?; + final resolvedQuality = buildDisplayAudioQuality( + bitDepth: actualBitDepth, + sampleRate: actualSampleRate, + storedQuality: actualQuality, + ); + if (resolvedQuality != null) { + actualQuality = resolvedQuality; + } + + final resolvedAlbumArtist = _resolveAlbumArtistForMetadata( + item.track, + settings, + ); + final trackToDownload = _buildTrackForMetadataEmbedding( + item.track, + result, + resolvedAlbumArtist, + ); + final convertedHighPath = await _finalizeNativeWorkerHighConversion( + context: context, + result: result, + settings: settings, + track: trackToDownload, + filePath: filePath, + ); + if (convertedHighPath == null) { + updateItemStatus( + item.id, + DownloadStatus.failed, + error: 'Failed to convert HIGH quality download', + errorType: DownloadErrorType.unknown, + ); + _failedInSession++; + return; + } + filePath = convertedHighPath; + final nativeActualQuality = result['_native_actual_quality'] as String?; + if (nativeActualQuality != null && nativeActualQuality.isNotEmpty) { + actualQuality = nativeActualQuality; + } + final convertedContainerPath = + await _finalizeNativeWorkerContainerConversion( + context: context, + result: result, + settings: settings, + track: trackToDownload, + filePath: filePath, + ); + if (convertedContainerPath == null) { + updateItemStatus( + item.id, + DownloadStatus.failed, + error: 'Failed to convert downloaded container', + errorType: DownloadErrorType.unknown, + ); + _failedInSession++; + return; + } + filePath = convertedContainerPath; + + updateItemStatus( + item.id, + DownloadStatus.completed, + progress: 1.0, + filePath: filePath, + ); + await _saveNativeWorkerExternalLrc( + context: context, + result: result, + settings: settings, + track: trackToDownload, + filePath: filePath, + ); + final postProcessedPath = await _runPostProcessingHooks( + filePath, + trackToDownload, + ); + if (postProcessedPath != null && postProcessedPath.isNotEmpty) { + filePath = postProcessedPath; + } + await _writeNativeWorkerReplayGain( + context: context, + settings: settings, + track: trackToDownload, + filePath: filePath, + ); + _completedInSession++; + + await _notificationService.showDownloadComplete( + trackName: item.track.name, + artistName: item.track.artistName, + completedCount: _completedInSession, + totalCount: _totalQueuedAtStart, + alreadyInLibrary: result['already_exists'] == true, + ); + + final backendTitle = result['title'] as String?; + final backendArtist = result['artist'] as String?; + final backendAlbum = result['album'] as String?; + final backendYear = result['release_date'] as String?; + final backendTrackNum = result['track_number'] as int?; + final backendDiscNum = result['disc_number'] as int?; + final backendTotalTracks = result['total_tracks'] as int?; + final backendTotalDiscs = result['total_discs'] as int?; + final backendISRC = result['isrc'] as String?; + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; + final backendComposer = result['composer'] as String?; + final resultSafFileName = result['file_name'] as String?; + final lowerFilePath = filePath.toLowerCase(); + final isLossyOutput = + lowerFilePath.endsWith('.mp3') || + lowerFilePath.endsWith('.opus') || + lowerFilePath.endsWith('.ogg'); + + ref + .read(downloadHistoryProvider.notifier) + .addToHistory( + DownloadHistoryItem( + id: item.id, + trackName: (backendTitle != null && backendTitle.isNotEmpty) + ? backendTitle + : trackToDownload.name, + artistName: (backendArtist != null && backendArtist.isNotEmpty) + ? backendArtist + : trackToDownload.artistName, + albumName: (backendAlbum != null && backendAlbum.isNotEmpty) + ? backendAlbum + : trackToDownload.albumName, + albumArtist: normalizeOptionalString(trackToDownload.albumArtist), + coverUrl: normalizeCoverReference(trackToDownload.coverUrl), + filePath: filePath, + storageMode: context.storageMode, + downloadTreeUri: context.storageMode == 'saf' + ? context.downloadTreeUri + : null, + safRelativeDir: context.storageMode == 'saf' + ? context.safRelativeDir + : null, + safFileName: context.storageMode == 'saf' + ? ((resultSafFileName != null && resultSafFileName.isNotEmpty) + ? resultSafFileName + : context.safFileName) + : null, + safRepaired: false, + service: result['service'] as String? ?? item.service, + downloadedAt: DateTime.now(), + isrc: (backendISRC != null && backendISRC.isNotEmpty) + ? backendISRC + : trackToDownload.isrc, + spotifyId: trackToDownload.id, + trackNumber: (backendTrackNum != null && backendTrackNum > 0) + ? backendTrackNum + : trackToDownload.trackNumber, + totalTracks: (backendTotalTracks != null && backendTotalTracks > 0) + ? backendTotalTracks + : trackToDownload.totalTracks, + discNumber: (backendDiscNum != null && backendDiscNum > 0) + ? backendDiscNum + : trackToDownload.discNumber, + totalDiscs: (backendTotalDiscs != null && backendTotalDiscs > 0) + ? backendTotalDiscs + : trackToDownload.totalDiscs, + duration: trackToDownload.duration, + releaseDate: (backendYear != null && backendYear.isNotEmpty) + ? backendYear + : trackToDownload.releaseDate, + quality: actualQuality, + bitDepth: isLossyOutput ? null : actualBitDepth, + sampleRate: isLossyOutput ? null : actualSampleRate, + genre: normalizeOptionalString(backendGenre), + composer: (backendComposer != null && backendComposer.isNotEmpty) + ? backendComposer + : trackToDownload.composer, + label: normalizeOptionalString(backendLabel), + copyright: normalizeOptionalString(backendCopyright), + ), + ); + + removeItem(item.id); + } + + Future _finalizeNativeWorkerDecryption({ + required _NativeWorkerRequestContext context, + required Map result, + required String filePath, + }) async { + if (result['already_exists'] == true) { + return filePath; + } + + final descriptor = DownloadDecryptionDescriptor.fromDownloadResult(result); + if (descriptor == null) { + return filePath; + } + + _log.i( + 'Native-worker encrypted stream detected, decrypting via ${descriptor.normalizedStrategy}...', + ); + + if (context.storageMode == 'saf' && isContentUri(filePath)) { + final treeUri = context.downloadTreeUri; + if (treeUri == null || treeUri.isEmpty) { + return null; + } + final tempPath = await _copySafToTemp(filePath); + if (tempPath == null) { + return null; + } + + String? decryptedTempPath; + try { + decryptedTempPath = await FFmpegService.decryptWithDescriptor( + inputPath: tempPath, + descriptor: descriptor, + deleteOriginal: false, + ); + if (decryptedTempPath == null) { + return null; + } + + final dotIndex = decryptedTempPath.lastIndexOf('.'); + final decryptedExt = dotIndex >= 0 + ? decryptedTempPath.substring(dotIndex).toLowerCase() + : context.outputExt; + const allowedExt = {'.flac', '.m4a', '.mp4', '.mp3', '.opus'}; + final finalExt = allowedExt.contains(decryptedExt) + ? decryptedExt + : context.outputExt; + final rawFileName = + (result['file_name'] as String?) ?? context.safFileName ?? 'track'; + final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''); + final newFileName = '$baseName$finalExt'; + final newUri = await _writeTempToSaf( + treeUri: treeUri, + relativeDir: context.safRelativeDir ?? '', + fileName: newFileName, + mimeType: _mimeTypeForExt(finalExt), + srcPath: decryptedTempPath, + ); + if (newUri == null) { + return null; + } + if (newUri != filePath) { + await _deleteSafFile(filePath); + } + result['file_name'] = newFileName; + return newUri; + } finally { + try { + await File(tempPath).delete(); + } catch (_) {} + if (decryptedTempPath != null && decryptedTempPath != tempPath) { + try { + await File(decryptedTempPath).delete(); + } catch (_) {} + } + } + } + + final decryptedPath = await FFmpegService.decryptWithDescriptor( + inputPath: filePath, + descriptor: descriptor, + deleteOriginal: true, + ); + return decryptedPath; + } + + Future _finalizeNativeWorkerHighConversion({ + required _NativeWorkerRequestContext context, + required Map result, + required AppSettings settings, + required Track track, + required String filePath, + }) async { + if (context.quality != 'HIGH') { + return filePath; + } + + final lowerPath = filePath.toLowerCase(); + final resultFileName = (result['file_name'] as String?)?.toLowerCase(); + final looksLikeM4a = + lowerPath.endsWith('.m4a') || + lowerPath.endsWith('.mp4') || + (resultFileName != null && + (resultFileName.endsWith('.m4a') || + resultFileName.endsWith('.mp4'))); + if (!looksLikeM4a) { + return filePath; + } + + final tidalHighFormat = settings.tidalHighFormat; + final format = tidalHighFormat.startsWith('opus') ? 'opus' : 'mp3'; + final newExt = format == 'opus' ? '.opus' : '.mp3'; + final bitrateDisplay = tidalHighFormat.contains('_') + ? '${tidalHighFormat.split('_').last}kbps' + : '320kbps'; + + Future embedConvertedMetadata(String convertedPath) async { + if (!settings.embedMetadata) return; + await _embedMetadataToFile( + convertedPath, + track, + format: format, + genre: result['genre'] as String?, + label: result['label'] as String?, + copyright: result['copyright'] as String?, + downloadService: context.item.service, + ); + } + + if (context.storageMode == 'saf' && isContentUri(filePath)) { + final treeUri = context.downloadTreeUri; + if (treeUri == null || treeUri.isEmpty) { + return null; + } + final tempPath = await _copySafToTemp(filePath); + if (tempPath == null) { + return null; + } + + String? convertedPath; + try { + convertedPath = await FFmpegService.convertM4aToLossy( + tempPath, + format: format, + bitrate: tidalHighFormat, + deleteOriginal: false, + ); + if (convertedPath == null) { + return null; + } + await embedConvertedMetadata(convertedPath); + final rawFileName = + (result['file_name'] as String?) ?? context.safFileName ?? 'track'; + final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''); + final newFileName = '$baseName$newExt'; + final newUri = await _writeTempToSaf( + treeUri: treeUri, + relativeDir: context.safRelativeDir ?? '', + fileName: newFileName, + mimeType: _mimeTypeForExt(newExt), + srcPath: convertedPath, + ); + if (newUri == null) { + return null; + } + if (newUri != filePath) { + await _deleteSafFile(filePath); + } + result['file_name'] = newFileName; + result['_native_actual_quality'] = + '${format.toUpperCase()} $bitrateDisplay'; + return newUri; + } finally { + try { + await File(tempPath).delete(); + } catch (_) {} + if (convertedPath != null) { + try { + await File(convertedPath).delete(); + } catch (_) {} + } + } + } + + final convertedPath = await FFmpegService.convertM4aToLossy( + filePath, + format: format, + bitrate: tidalHighFormat, + deleteOriginal: true, + ); + if (convertedPath == null) { + return null; + } + await embedConvertedMetadata(convertedPath); + result['_native_actual_quality'] = + '${format.toUpperCase()} $bitrateDisplay'; + return convertedPath; + } + + Future _finalizeNativeWorkerContainerConversion({ + required _NativeWorkerRequestContext context, + required Map result, + required AppSettings settings, + required Track track, + required String filePath, + }) async { + if (context.quality == 'HIGH' || context.outputExt != '.flac') { + return filePath; + } + final lowerPath = filePath.toLowerCase(); + final resultFileName = (result['file_name'] as String?)?.toLowerCase(); + final looksLikeM4a = + lowerPath.endsWith('.m4a') || + lowerPath.endsWith('.mp4') || + (resultFileName != null && + (resultFileName.endsWith('.m4a') || + resultFileName.endsWith('.mp4'))); + if (!looksLikeM4a && !isContentUri(filePath)) { + return filePath; + } + + Future embedFlacMetadata(String flacPath) async { + if (!settings.embedMetadata) return; + await _embedMetadataToFile( + flacPath, + track, + format: 'flac', + genre: result['genre'] as String?, + label: result['label'] as String?, + copyright: result['copyright'] as String?, + downloadService: context.item.service, + writeExternalLrc: context.storageMode != 'saf', + ); + } + + if (context.storageMode == 'saf' && isContentUri(filePath)) { + final treeUri = context.downloadTreeUri; + if (treeUri == null || treeUri.isEmpty) { + return null; + } + final tempPath = await _copySafToTemp(filePath); + if (tempPath == null) { + return null; + } + + String? flacPath; + try { + flacPath = await FFmpegService.convertM4aToFlac(tempPath); + if (flacPath == null) { + return null; + } + await embedFlacMetadata(flacPath); + final rawFileName = + (result['file_name'] as String?) ?? context.safFileName ?? 'track'; + final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''); + final newFileName = '$baseName.flac'; + final newUri = await _writeTempToSaf( + treeUri: treeUri, + relativeDir: context.safRelativeDir ?? '', + fileName: newFileName, + mimeType: _mimeTypeForExt('.flac'), + srcPath: flacPath, + ); + if (newUri == null) { + return null; + } + if (newUri != filePath) { + await _deleteSafFile(filePath); + } + result['file_name'] = newFileName; + return newUri; + } finally { + try { + await File(tempPath).delete(); + } catch (_) {} + if (flacPath != null) { + try { + await File(flacPath).delete(); + } catch (_) {} + } + } + } + + final flacPath = await FFmpegService.convertM4aToFlac(filePath); + if (flacPath == null) { + return null; + } + await embedFlacMetadata(flacPath); + return flacPath; + } + + Future _writeNativeWorkerReplayGain({ + required _NativeWorkerRequestContext context, + required AppSettings settings, + required Track track, + required String filePath, + }) async { + if (!settings.embedReplayGain) { + return; + } + if (context.outputExt != '.flac' && context.outputExt != '.m4a') { + return; + } + + try { + final rgResult = await FFmpegService.scanReplayGain(filePath); + if (rgResult == null) { + return; + } + await PlatformBridge.editFileMetadata(filePath, { + 'replaygain_track_gain': rgResult.trackGain, + 'replaygain_track_peak': rgResult.trackPeak, + }); + _storeTrackReplayGainForAlbum(track, filePath, rgResult); + _updateAlbumRgFilePath(track, filePath); + await _checkAndWriteAlbumReplayGain(track); + _log.d( + 'Native-worker ReplayGain written: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}', + ); + } catch (e) { + _log.w('Failed to write native-worker ReplayGain: $e'); + } + } + + Future _saveNativeWorkerExternalLrc({ + required _NativeWorkerRequestContext context, + required Map result, + required AppSettings settings, + required Track track, + required String filePath, + }) async { + final lyricsMode = settings.lyricsMode; + final shouldSaveExternalLrc = + settings.embedMetadata && + settings.embedLyrics && + !_shouldSkipLyrics( + ref.read(extensionProvider), + track.source, + context.item.service, + ) && + (lyricsMode == 'external' || lyricsMode == 'both'); + if (!shouldSaveExternalLrc) { + return; + } + + String? lrcContent = result['lyrics_lrc'] as String?; + if (lrcContent == null || lrcContent.isEmpty) { + try { + lrcContent = await PlatformBridge.getLyricsLRC( + track.id, + track.name, + track.artistName, + durationMs: track.duration * 1000, + ); + } catch (e) { + _log.w('Failed to fetch native-worker external LRC: $e'); + } + } + if (lrcContent == null || lrcContent.isEmpty) { + return; + } + + if (context.storageMode == 'saf' && isContentUri(filePath)) { + final treeUri = context.downloadTreeUri; + if (treeUri == null || treeUri.isEmpty) { + return; + } + final resultFileName = result['file_name'] as String?; + final fileName = (resultFileName != null && resultFileName.isNotEmpty) + ? resultFileName + : context.safFileName; + final baseName = fileName != null && fileName.isNotEmpty + ? fileName.replaceFirst(RegExp(r'\.[^.]+$'), '') + : await PlatformBridge.sanitizeFilename( + '${track.artistName} - ${track.name}', + ); + await _writeLrcToSaf( + treeUri: treeUri, + relativeDir: context.safRelativeDir ?? '', + baseName: baseName, + lrcContent: lrcContent, + ); + return; + } + + try { + final lrcPath = filePath.replaceAll(RegExp(r'\.[^.]+$'), '.lrc'); + final safeLrcPath = lrcPath == filePath ? '$filePath.lrc' : lrcPath; + await File(safeLrcPath).writeAsString(lrcContent); + _log.d('Native-worker external LRC saved: $safeLrcPath'); + } catch (e) { + _log.w('Failed to save native-worker external LRC: $e'); + } + } + + DownloadErrorType _downloadErrorTypeFromBackend(String? errorType) { + switch (errorType) { + case 'not_found': + return DownloadErrorType.notFound; + case 'rate_limit': + return DownloadErrorType.rateLimit; + case 'network': + return DownloadErrorType.network; + case 'permission': + return DownloadErrorType.permission; + default: + return DownloadErrorType.unknown; + } + } + Future _processQueue() async { if (state.isProcessing) return; @@ -4366,6 +5758,10 @@ class DownloadQueueNotifier extends Notifier { _stopConnectivityMonitoring(); } + if (await _tryProcessQueueWithAndroidNativeWorker(settings)) { + return; + } + state = state.copyWith(isProcessing: true); _log.i('Starting queue processing...'); @@ -4971,6 +6367,11 @@ class DownloadQueueNotifier extends Notifier { final hasActiveExtensions = extensionState.extensions.any( (e) => e.enabled, ); + final postProcessingEnabled = + settings.useExtensionProviders && + extensionState.extensions.any( + (e) => e.enabled && e.hasPostProcessing, + ); final useExtensions = settings.useExtensionProviders && hasActiveExtensions; @@ -5059,13 +6460,16 @@ class DownloadQueueNotifier extends Notifier { ), embedMaxQualityCover: metadataEmbeddingEnabled && settings.maxQualityCover, + embedReplayGain: settings.embedReplayGain, + postProcessingEnabled: postProcessingEnabled, + tidalHighFormat: settings.tidalHighFormat, trackNumber: normalizedTrackNumber, discNumber: normalizedDiscNumber, totalTracks: trackToDownload.totalTracks ?? 0, totalDiscs: trackToDownload.totalDiscs ?? 0, releaseDate: trackToDownload.releaseDate ?? '', itemId: item.id, - durationMs: trackToDownload.duration, + durationMs: trackToDownload.duration * 1000, source: trackToDownload.source ?? '', genre: genre ?? '', label: label ?? '', @@ -6427,15 +7831,18 @@ class DownloadQueueLookup { activeDownloadsCount = 0; DownloadQueueLookup._({ - required this.byTrackId, - required this.byItemId, - required this.indexByItemId, - required this.itemIds, + required Map byTrackId, + required Map byItemId, + required Map indexByItemId, + required List itemIds, required this.queuedCount, required this.completedCount, required this.failedCount, required this.activeDownloadsCount, - }); + }) : byTrackId = Map.unmodifiable(byTrackId), + byItemId = Map.unmodifiable(byItemId), + indexByItemId = Map.unmodifiable(indexByItemId), + itemIds = List.unmodifiable(itemIds); factory DownloadQueueLookup.fromItems(List items) { final byTrackId = {}; @@ -6470,7 +7877,9 @@ class DownloadQueueLookup { } static bool _countsAsQueued(DownloadStatus status) => - status == DownloadStatus.queued || status == DownloadStatus.downloading; + status == DownloadStatus.queued || + status == DownloadStatus.downloading || + status == DownloadStatus.finalizing; static int _deltaForStatus({ required DownloadStatus previous, @@ -6558,6 +7967,11 @@ class DownloadQueueLookup { } } +class _NativeWorkerStartupTimeout implements Exception { + @override + String toString() => 'Native worker did not publish run snapshot'; +} + final downloadQueueLookupProvider = Provider((ref) { return ref.watch(downloadQueueProvider.select((s) => s.lookup)); }); diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 03c83644..bd1a4ef6 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -263,6 +263,9 @@ class Extension { bool get hasServiceHealth => serviceHealth.isNotEmpty; bool get hasHomeFeed => capabilities['homeFeed'] == true; bool get hasBrowseCategories => capabilities['browseCategories'] == true; + bool get requiresNativeContainerConversion => + capabilities['requiresContainerConversion'] == true || + capabilities['requiresNativeContainerConversion'] == true; List get replacesBuiltInProviders { final value = capabilities['replacesBuiltInProviders']; if (value is! List) return const []; diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 8ca8ba2d..2c5e6da4 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -569,6 +569,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setNativeDownloadWorkerEnabled(bool enabled) { + state = state.copyWith(nativeDownloadWorkerEnabled: enabled); + _saveSettings(); + } + void setLocalLibraryEnabled(bool enabled) { state = state.copyWith(localLibraryEnabled: enabled); _saveSettings(); diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index f6ea42a6..8e55018e 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; @@ -23,6 +25,7 @@ class _DownloadSettingsPageState extends ConsumerState { final hasDownloadExtensions = extensionState.extensions.any( (extension) => extension.enabled && extension.hasDownloadProvider, ); + final nativeWorkerAvailable = Platform.isAndroid && hasDownloadExtensions; final colorScheme = Theme.of(context).colorScheme; final topPadding = normalizedHeaderTopPadding(context); @@ -141,6 +144,22 @@ class _DownloadSettingsPageState extends ConsumerState { settings.downloadNetworkMode, ), ), + if (Platform.isAndroid) + SettingsSwitchItem( + icon: Icons.downloading_outlined, + title: 'Native download worker', + titleTrailing: const _BetaBadge(), + subtitle: hasDownloadExtensions + ? 'Beta Android service worker for extension downloads' + : context.l10n.extensionsNoDownloadProvider, + value: + settings.nativeDownloadWorkerEnabled && + nativeWorkerAvailable, + enabled: nativeWorkerAvailable, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setNativeDownloadWorkerEnabled(value), + ), SettingsSwitchItem( icon: Icons.security_outlined, title: context.l10n.downloadNetworkCompatibilityMode, @@ -594,6 +613,29 @@ class _DownloadSettingsPageState extends ConsumerState { // ── Private widgets (reused from original) ───────────────────────────────── +class _BetaBadge extends StatelessWidget { + const _BetaBadge(); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'BETA', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onTertiaryContainer, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + class _ServiceSelector extends ConsumerWidget { final String currentService; final ValueChanged onChanged; diff --git a/lib/services/download_request_payload.dart b/lib/services/download_request_payload.dart index 39675dfe..75b53471 100644 --- a/lib/services/download_request_payload.dart +++ b/lib/services/download_request_payload.dart @@ -1,4 +1,7 @@ class DownloadRequestPayload { + static const int nativeWorkerContractVersion = 1; + + final int contractVersion; final String isrc; final String service; final String spotifyId; @@ -14,6 +17,9 @@ class DownloadRequestPayload { final String artistTagMode; final bool embedLyrics; final bool embedMaxQualityCover; + final bool embedReplayGain; + final bool postProcessingEnabled; + final String tidalHighFormat; final int trackNumber; final int discNumber; final int totalTracks; @@ -37,9 +43,12 @@ class DownloadRequestPayload { final String safRelativeDir; final String safFileName; final String safOutputExt; + final bool stageSafOutput; + final bool requiresContainerConversion; final String songLinkRegion; const DownloadRequestPayload({ + this.contractVersion = nativeWorkerContractVersion, this.isrc = '', this.service = '', this.spotifyId = '', @@ -55,6 +64,9 @@ class DownloadRequestPayload { this.artistTagMode = 'joined', this.embedLyrics = true, this.embedMaxQualityCover = true, + this.embedReplayGain = false, + this.postProcessingEnabled = false, + this.tidalHighFormat = 'mp3_320', this.trackNumber = 0, this.discNumber = 0, this.totalTracks = 1, @@ -78,11 +90,14 @@ class DownloadRequestPayload { this.safRelativeDir = '', this.safFileName = '', this.safOutputExt = '', + this.stageSafOutput = false, + this.requiresContainerConversion = false, this.songLinkRegion = 'US', }); Map toJson() { return { + 'contract_version': contractVersion, 'isrc': isrc, 'service': service, 'spotify_id': spotifyId, @@ -98,6 +113,9 @@ class DownloadRequestPayload { 'artist_tag_mode': artistTagMode, 'embed_lyrics': embedLyrics, 'embed_max_quality_cover': embedMaxQualityCover, + 'embed_replaygain': embedReplayGain, + 'post_processing_enabled': postProcessingEnabled, + 'tidal_high_format': tidalHighFormat, 'track_number': trackNumber, 'disc_number': discNumber, 'total_tracks': totalTracks, @@ -121,6 +139,8 @@ class DownloadRequestPayload { 'saf_relative_dir': safRelativeDir, 'saf_file_name': safFileName, 'saf_output_ext': safOutputExt, + 'stage_saf_output': stageSafOutput, + 'requires_container_conversion': requiresContainerConversion, 'songlink_region': songLinkRegion, }; } @@ -130,6 +150,7 @@ class DownloadRequestPayload { bool? useFallback, }) { return DownloadRequestPayload( + contractVersion: contractVersion, isrc: isrc, service: service, spotifyId: spotifyId, @@ -145,6 +166,9 @@ class DownloadRequestPayload { artistTagMode: artistTagMode, embedLyrics: embedLyrics, embedMaxQualityCover: embedMaxQualityCover, + embedReplayGain: embedReplayGain, + postProcessingEnabled: postProcessingEnabled, + tidalHighFormat: tidalHighFormat, trackNumber: trackNumber, discNumber: discNumber, totalTracks: totalTracks, @@ -168,6 +192,8 @@ class DownloadRequestPayload { safRelativeDir: safRelativeDir, safFileName: safFileName, safOutputExt: safOutputExt, + stageSafOutput: stageSafOutput, + requiresContainerConversion: requiresContainerConversion, songLinkRegion: songLinkRegion, ); } diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 6716dbd5..02b49cb0 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -837,6 +837,35 @@ class PlatformBridge { return result as bool; } + static Future startNativeDownloadWorker({ + required List> requests, + Map settings = const {}, + }) async { + await _channel.invokeMethod('startNativeDownloadWorker', { + 'requests_json': jsonEncode(requests), + 'settings_json': jsonEncode(settings), + }); + } + + static Future pauseNativeDownloadWorker() async { + await _channel.invokeMethod('pauseNativeDownloadWorker'); + } + + static Future resumeNativeDownloadWorker() async { + await _channel.invokeMethod('resumeNativeDownloadWorker'); + } + + static Future cancelNativeDownloadWorker() async { + await _channel.invokeMethod('cancelNativeDownloadWorker'); + } + + static Future> getNativeDownloadWorkerSnapshot() async { + final result = await _channel.invokeMethod( + 'getNativeDownloadWorkerSnapshot', + ); + return _decodeMapResult(result); + } + static Future preWarmTrackCache( List> tracks, ) async { diff --git a/lib/widgets/settings_group.dart b/lib/widgets/settings_group.dart index b24ebecd..ef839701 100644 --- a/lib/widgets/settings_group.dart +++ b/lib/widgets/settings_group.dart @@ -4,20 +4,22 @@ class SettingsGroup extends StatelessWidget { final List children; final EdgeInsetsGeometry? margin; - const SettingsGroup({ - super.key, - required this.children, - this.margin, - }); + const SettingsGroup({super.key, required this.children, this.margin}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final isDark = Theme.of(context).brightness == Brightness.dark; - - final cardColor = isDark - ? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface) - : Color.alphaBlend(Colors.black.withValues(alpha: 0.04), colorScheme.surface); + + final cardColor = isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.08), + colorScheme.surface, + ) + : Color.alphaBlend( + Colors.black.withValues(alpha: 0.04), + colorScheme.surface, + ); return Container( margin: margin ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 4), @@ -28,10 +30,7 @@ class SettingsGroup extends StatelessWidget { clipBehavior: Clip.antiAlias, child: Material( color: Colors.transparent, - child: Column( - mainAxisSize: MainAxisSize.min, - children: children, - ), + child: Column(mainAxisSize: MainAxisSize.min, children: children), ), ); } @@ -58,7 +57,7 @@ class SettingsItem extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - + return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -78,17 +77,13 @@ class SettingsItem extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: Theme.of(context).textTheme.bodyLarge, - ), + Text(title, style: Theme.of(context).textTheme.bodyLarge), if (subtitle != null) ...[ const SizedBox(height: 2), Text( subtitle!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), ), ], ], @@ -99,7 +94,10 @@ class SettingsItem extends StatelessWidget { trailing!, ] else if (onTap != null) ...[ const SizedBox(width: 8), - Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant), + Icon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant, + ), ], ], ), @@ -121,6 +119,7 @@ class SettingsItem extends StatelessWidget { class SettingsSwitchItem extends StatelessWidget { final IconData? icon; final String title; + final Widget? titleTrailing; final String? subtitle; final bool value; final ValueChanged? onChanged; @@ -131,6 +130,7 @@ class SettingsSwitchItem extends StatelessWidget { super.key, this.icon, required this.title, + this.titleTrailing, this.subtitle, required this.value, this.onChanged, @@ -142,7 +142,7 @@ class SettingsSwitchItem extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final isDisabled = !enabled || onChanged == null; - + return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -157,26 +157,49 @@ class SettingsSwitchItem extends StatelessWidget { child: Row( children: [ if (icon != null) ...[ - Icon(icon, color: isDisabled ? colorScheme.outline : colorScheme.onSurfaceVariant, size: 24), + Icon( + icon, + color: isDisabled + ? colorScheme.outline + : colorScheme.onSurfaceVariant, + size: 24, + ), const SizedBox(width: 16), ], Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: isDisabled ? colorScheme.outline : null, - ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + title, + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith( + color: isDisabled + ? colorScheme.outline + : null, + ), + ), + ), + if (titleTrailing != null) ...[ + const SizedBox(width: 8), + titleTrailing!, + ], + ], ), if (subtitle != null) ...[ const SizedBox(height: 2), Text( subtitle!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: isDisabled ? colorScheme.outline : colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: isDisabled + ? colorScheme.outline + : colorScheme.onSurfaceVariant, + ), ), ], ], diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 663e91cf..418bd1f9 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -236,6 +236,7 @@ void main() { musixmatchLanguage: 'id', lastSeenVersion: '4.5.0', deduplicateDownloads: false, + nativeDownloadWorkerEnabled: true, ); final decoded = AppSettings.fromJson(settings.toJson()); @@ -255,6 +256,7 @@ void main() { expect(decoded.musixmatchLanguage, 'id'); expect(decoded.lastSeenVersion, '4.5.0'); expect(decoded.deduplicateDownloads, isFalse); + expect(decoded.nativeDownloadWorkerEnabled, isTrue); }); }); @@ -300,6 +302,9 @@ void main() { artistTagMode: artistTagModeSplitVorbis, embedLyrics: false, embedMaxQualityCover: false, + embedReplayGain: true, + postProcessingEnabled: true, + tidalHighFormat: 'opus_256', trackNumber: 7, discNumber: 2, totalTracks: 12, @@ -327,6 +332,7 @@ void main() { ); expect(payload.toJson(), { + 'contract_version': DownloadRequestPayload.nativeWorkerContractVersion, 'isrc': 'ISRC123', 'service': 'tidal', 'spotify_id': 'spotify:track:1', @@ -342,6 +348,9 @@ void main() { 'artist_tag_mode': artistTagModeSplitVorbis, 'embed_lyrics': false, 'embed_max_quality_cover': false, + 'embed_replaygain': true, + 'post_processing_enabled': true, + 'tidal_high_format': 'opus_256', 'track_number': 7, 'disc_number': 2, 'total_tracks': 12, @@ -365,6 +374,8 @@ void main() { 'saf_relative_dir': 'Album', 'saf_file_name': 'Song.flac', 'saf_output_ext': 'flac', + 'stage_saf_output': false, + 'requires_container_conversion': false, 'songlink_region': 'ID', }); });