From 5d0f27441439b8a0b5767bc038d0044f1f59b080 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:05:29 +0700 Subject: [PATCH] refactor(android): move the metadata and tag backend calls into MainActivityMetadataCalls.kt MainActivity.kt should be the dispatcher only. The metadata, tag, cover, re-enrich, post-processing and CUE cases keep their labels and order in the backend `when` and now call MainActivity.handle functions in MainActivityMetadataCalls.kt. Bodies move verbatim apart from the receiver label for this@MainActivity and re-indenting the FLAC copy-back lines of editFileMetadata, which sat at the wrong depth. --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 370 +---------------- .../spotiflac/MainActivityMetadataCalls.kt | 384 ++++++++++++++++++ 2 files changed, 397 insertions(+), 357 deletions(-) create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/MainActivityMetadataCalls.kt 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 70e5bdf3..963db8d0 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -4,13 +4,11 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.net.Uri import android.os.Build import android.os.Bundle import android.os.ParcelFileDescriptor import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts -import androidx.documentfile.provider.DocumentFile import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterActivityLaunchConfigs.BackgroundMode import io.flutter.embedding.android.FlutterFragment @@ -549,270 +547,18 @@ class MainActivity: FlutterFragmentActivity() { "safCreateCollisionAwareFromPath" -> handleSafCreateCollisionAwareFromPath(call, result) "openContentUri" -> handleOpenContentUri(call, result) "shareContentUri" -> handleShareContentUri(call, result) - "rewriteSplitArtistTags" -> { - val filePath = call.argument("file_path") ?: "" - val artist = call.argument("artist") ?: "" - val albumArtist = call.argument("album_artist") ?: "" - val response = withContext(Dispatchers.IO) { - if (filePath.startsWith("content://")) { - val uri = Uri.parse(filePath) - val tempPath = copyUriToTemp(uri, ".flac") - ?: return@withContext errorJson("Failed to copy SAF file to temp") - try { - val raw = coreBackend.rewriteSplitArtistTags(tempPath, artist, albumArtist) - val obj = JSONObject(raw) - if (!obj.optBoolean("success", false)) { - return@withContext raw - } - - if (!writeUriFromPath(uri, tempPath)) { - return@withContext errorJson("Failed to write rewritten tags back to SAF file") - } - - obj.put("file_path", filePath) - obj.toString() - } catch (e: Exception) { - errorJson("Failed to rewrite split artist tags in SAF file: ${e.message}") - } finally { - try { - File(tempPath).delete() - } catch (_: Exception) {} - } - } else { - coreBackend.rewriteSplitArtistTags(filePath, artist, albumArtist) - } - } - result.success(response) - } - "readFileMetadata" -> { - val filePath = call.argument("file_path") ?: "" - val displayName = call.argument("display_name") ?: "" - val response = withContext(Dispatchers.IO) { - try { - if (filePath.startsWith("content://")) { - readCompleteMetadataFromUri(Uri.parse(filePath), displayName) - ?.toString() ?: errorJson("Failed to read SAF metadata") - } else { - coreBackend.readFileMetadata(filePath, displayName) - } - } catch (e: Exception) { - errorJson(e.message ?: "Failed to read metadata") - } - } - result.success(response) - } - "checkHiResAuthenticity" -> { - val filePath = call.argument("file_path") ?: "" - val optionsJson = call.argument("options_json") ?: "" - val response = withContext(Dispatchers.IO) { - try { - if (filePath.startsWith("content://")) { - checkHiResAuthenticityFromUri(Uri.parse(filePath), optionsJson) - ?.toString() - ?: errorJson("Failed to read SAF audio file") - } else { - coreBackend.checkHiResAuthenticity(filePath, optionsJson) - } - } catch (e: Exception) { - errorJson(e.message ?: "Hi-Res check failed") - } - } - result.success(response) - } - "editFileMetadata" -> { - val filePath = call.argument("file_path") ?: "" - val metadataJson = call.argument("metadata_json") ?: "{}" - val response = withContext(Dispatchers.IO) { - try { - if (filePath.startsWith("content://")) { - val uri = Uri.parse(filePath) - val tempPath = copyUriToTemp(uri) - ?: return@withContext """{"error":"Failed to copy SAF file to temp"}""" - try { - val raw = coreBackend.editFileMetadata(tempPath, metadataJson) - val obj = JSONObject(raw) - val method = obj.optString("method", "") - if (method == "ffmpeg") { - // MP3/Opus: Dart needs to FFmpeg the temp file, then call writeTempToSaf - obj.put("temp_path", tempPath) - obj.put("saf_uri", filePath) - return@withContext obj.toString() - // Note: temp file NOT deleted here - Dart will clean up after FFmpeg + writeTempToSaf - } - // FLAC: the backend wrote to temp, copy back now - if (!writeUriFromPath(uri, tempPath)) { - try { File(tempPath).delete() } catch (_: Exception) {} - return@withContext """{"error":"Failed to write metadata back to SAF file"}""" - } - try { File(tempPath).delete() } catch (_: Exception) {} - raw - } catch (e: Exception) { - try { File(tempPath).delete() } catch (_: Exception) {} - throw e - } - } else { - coreBackend.editFileMetadata(filePath, metadataJson) - } - } catch (e: Exception) { - android.util.Log.e("SpotiFLAC", "editFileMetadata failed: ${e.message}", e) - """{"error":${org.json.JSONObject.quote(e.message ?: "unknown")}}""" - } - } - result.success(response) - } - "writeM4AFreeformTags" -> { - val filePath = call.argument("file_path") ?: "" - val metadataJson = call.argument("metadata_json") ?: "{}" - val response = withContext(Dispatchers.IO) { - try { - coreBackend.writeM4aFreeformTags(filePath, metadataJson) - } catch (e: Exception) { - android.util.Log.e("SpotiFLAC", "writeM4AFreeformTags failed: ${e.message}", e) - """{"error":${org.json.JSONObject.quote(e.message ?: "unknown")}}""" - } - } - result.success(response) - } - "ensureAC4Config" -> { - val filePath = call.argument("file_path") ?: "" - val sourcePath = call.argument("source_path") ?: "" - val response = withContext(Dispatchers.IO) { - try { - coreBackend.ensureAc4Config(filePath, sourcePath) - } catch (e: Exception) { - android.util.Log.e("SpotiFLAC", "ensureAC4Config failed: ${e.message}", e) - """{"error":${org.json.JSONObject.quote(e.message ?: "unknown")}}""" - } - } - result.success(response) - } - "writeAC4Metadata" -> { - val filePath = call.argument("file_path") ?: "" - val metadataJson = call.argument("metadata_json") ?: "{}" - val coverPath = call.argument("cover_path") ?: "" - val response = withContext(Dispatchers.IO) { - try { - coreBackend.writeAc4Metadata(filePath, metadataJson, coverPath) - } catch (e: Exception) { - android.util.Log.e("SpotiFLAC", "writeAC4Metadata failed: ${e.message}", e) - """{"error":${org.json.JSONObject.quote(e.message ?: "unknown")}}""" - } - } - result.success(response) - } + "rewriteSplitArtistTags" -> handleRewriteSplitArtistTags(call, result) + "readFileMetadata" -> handleReadFileMetadata(call, result) + "checkHiResAuthenticity" -> handleCheckHiResAuthenticity(call, result) + "editFileMetadata" -> handleEditFileMetadata(call, result) + "writeM4AFreeformTags" -> handleWriteM4AFreeformTags(call, result) + "ensureAC4Config" -> handleEnsureAC4Config(call, result) + "writeAC4Metadata" -> handleWriteAC4Metadata(call, result) "writeTempToSaf" -> handleWriteTempToSaf(call, result) "writeSafSidecarLrc" -> handleWriteSafSidecarLrc(call, result) - "downloadCoverToFile" -> { - val coverUrl = call.argument("cover_url") ?: "" - val outputPath = call.argument("output_path") ?: "" - val maxDimension = call.argument("max_dimension") - ?.toLong() - ?.coerceAtLeast(0L) - ?: 0L - val response = withContext(Dispatchers.IO) { - var temporaryCover: File? = null - try { - val destination = if (outputPath.isBlank()) { - coreBackend.createTemporaryMediaFile( - this@MainActivity, - "cover_", - ".jpg", - ).also { temporaryCover = it }.absolutePath - } else { - outputPath - } - coreBackend.downloadCoverToFileSized( - coverUrl, - destination, - maxDimension - ) - JSONObject() - .put("success", true) - .put("file_path", destination) - .toString() - } catch (e: Exception) { - temporaryCover?.delete() - """{"success":false,"error":"${e.message?.replace("\"", "'")}"}""" - } - } - result.success(response) - } - "extractCoverToFile" -> { - val audioPath = call.argument("audio_path") ?: "" - val outputPath = call.argument("output_path") ?: "" - val response = withContext(Dispatchers.IO) { - try { - if (audioPath.startsWith("content://")) { - extractCoverFromUri(Uri.parse(audioPath), outputPath) - """{"success":true}""" - } else { - coreBackend.extractCoverToFile(audioPath, outputPath) - """{"success":true}""" - } - } catch (e: Exception) { - """{"success":false,"error":"${e.message?.replace("\"", "'")}"}""" - } - } - result.success(response) - } - "reEnrichFile" -> { - val requestJson = call.argument("request_json") ?: "{}" - val response = withContext(Dispatchers.IO) { - try { - val reqObj = JSONObject(requestJson) - val filePath = reqObj.optString("file_path", "") - - // Preview only resolves online metadata; it does not need - // a full SAF document copy and never writes the source. - if (filePath.startsWith("content://") && !reqObj.optBoolean("preview_only", false)) { - val uri = Uri.parse(filePath) - val tempPath = copyUriToTemp(uri) - ?: return@withContext """{"error":"Failed to copy SAF file to temp"}""" - var retainedForFfmpeg = false - try { - reqObj.put("file_path", tempPath) - val raw = coreBackend.reEnrichFile(reqObj.toString()) - val obj = JSONObject(raw) - - if (obj.has("error")) { - return@withContext raw - } - - val method = obj.optString("method", "") - if (method == "ffmpeg") { - // MP3/Opus: Dart handles FFmpeg on temp file, then writes back - obj.put("temp_path", tempPath) - obj.put("saf_uri", filePath) - retainedForFfmpeg = true - return@withContext obj.toString() - // Dart cleans up after FFmpeg + writeTempToSaf. - } - - // FLAC: the selected backend wrote to temp; copy back now. - if (!writeUriFromPath(uri, tempPath)) { - return@withContext """{"error":"Failed to write enriched metadata back to SAF file"}""" - } - if (obj.optBoolean("write_external_lrc", false) && - !writeSafSidecarLrc(uri, obj.optString("lyrics", ""))) { - return@withContext errorJson( - "Metadata was saved, but the LRC lyrics file could not be updated" - ) - } - raw - } finally { - if (!retainedForFfmpeg) { - try { File(tempPath).delete() } catch (_: Exception) {} - } - } - } else { - coreBackend.reEnrichFile(requestJson) - } - } catch (e: Exception) { - """{"error":${org.json.JSONObject.quote(e.message ?: "unknown")}}""" - } - } - result.success(response) - } + "downloadCoverToFile" -> handleDownloadCoverToFile(call, result) + "extractCoverToFile" -> handleExtractCoverToFile(call, result) + "reEnrichFile" -> handleReEnrichFile(call, result) "startDownloadService" -> { val trackName = call.argument("track_name") ?: "" val artistName = call.argument("artist_name") ?: "" @@ -950,31 +696,7 @@ class MainActivity: FlutterFragmentActivity() { android.util.Log.d("SpotiFLAC", "Backend memory pressure release completed") result.success(null) } - "runPostProcessingV2" -> { - val inputJson = call.argument("input") ?: "" - val metadataJson = call.argument("metadata") ?: "" - val response = withContext(Dispatchers.IO) { - val inputObj = if (inputJson.isNotBlank()) JSONObject(inputJson) else JSONObject() - val uriStr = inputObj.optString("uri", "") - val pathStr = inputObj.optString("path", "") - val effectiveUri = when { - uriStr.startsWith("content://") -> uriStr - pathStr.startsWith("content://") -> pathStr - else -> "" - } - - if (effectiveUri.isNotBlank()) { - runPostProcessingSafV2(effectiveUri, metadataJson, inputObj.optString("item_id", "")) - } else { - if (pathStr.isNotBlank()) { - inputObj.put("name", File(pathStr).name) - inputObj.put("is_saf", false) - } - coreBackend.runPostProcessing(inputObj.toString(), metadataJson) - } - } - result.success(response) - } + "runPostProcessingV2" -> handleRunPostProcessingV2(call, result) "setLibraryCoverCacheDir" -> { val cacheDir = call.argument("cache_dir") ?: "" withContext(Dispatchers.IO) { @@ -1060,74 +782,8 @@ class MainActivity: FlutterFragmentActivity() { } result.success(null) } - "readAudioMetadata" -> { - val filePath = call.argument("file_path") ?: "" - val response = withContext(Dispatchers.IO) { - try { - if (filePath.startsWith("content://")) { - val uri = Uri.parse(filePath) - val metadata = readAudioMetadataFromUri(uri) - ?: return@withContext """{"error":"Failed to read SAF audio metadata"}""" - metadata.put("filePath", filePath) - metadata.toString() - } else { - coreBackend.readAudioMetadata(filePath, "", "") - } - } catch (e: Exception) { - """{"error":${org.json.JSONObject.quote(e.message ?: "unknown")}}""" - } - } - result.success(response) - } - "parseCueSheet" -> { - val cuePath = call.argument("cue_path") ?: "" - val audioDir = call.argument("audio_dir") ?: "" - val response = withContext(Dispatchers.IO) { - try { - if (cuePath.startsWith("content://")) { - val uri = Uri.parse(cuePath) - val tempCuePath = copyUriToTemp(uri, ".cue") - ?: return@withContext """{"error":"Failed to copy CUE file to temp"}""" - try { - val audioFileName = extractCueAudioFileName(tempCuePath) - - val parentDir = safParentDir(uri) - val audioDoc = if (parentDir != null) { - val cueName = try { - DocumentFile.fromSingleUri(this@MainActivity, uri)?.name ?: "" - } catch (_: Exception) { "" } - findSafCueAudioSibling(this@MainActivity, parentDir, cueName, audioFileName) - } else { - null - } - - if (audioDoc == null || !audioDoc.isFile) { - return@withContext JSONObject() - .put("error", "Audio file not found for CUE sheet") - .toString() - } - - // Preview parses the CUE text only; the selected audio - // stays in SAF instead of copying a whole album to cache. - val resultJson = coreBackend.parseCueSheetWithResolvedAudio( - tempCuePath, - audioDoc.uri.toString(), - ) - JSONObject(resultJson) - .put("cue_path", cuePath) - .toString() - } finally { - try { File(tempCuePath).delete() } catch (_: Exception) {} - } - } else { - coreBackend.parseCueSheet(cuePath, audioDir) - } - } catch (e: Exception) { - """{"error":${org.json.JSONObject.quote(e.message ?: "unknown")}}""" - } - } - result.success(response) - } + "readAudioMetadata" -> handleReadAudioMetadata(call, result) + "parseCueSheet" -> handleParseCueSheet(call, result) else -> result.notImplemented() } } catch (e: Exception) { diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivityMetadataCalls.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivityMetadataCalls.kt new file mode 100644 index 00000000..3f1daf57 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivityMetadataCalls.kt @@ -0,0 +1,384 @@ +package com.zarz.spotiflac + +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File + +// Metadata and tag backend calls: reading and editing tags, M4A and AC-4 +// writes, cover download and extraction, re-enrichment, post-processing and +// CUE sheets. SAF documents go through a temp copy where the backend needs +// a file path. MainActivity's backend `when` dispatches each case here, in +// the order it lists them. + +internal suspend fun MainActivity.handleRewriteSplitArtistTags(call: MethodCall, result: MethodChannel.Result) { + val filePath = call.argument("file_path") ?: "" + val artist = call.argument("artist") ?: "" + val albumArtist = call.argument("album_artist") ?: "" + val response = withContext(Dispatchers.IO) { + if (filePath.startsWith("content://")) { + val uri = Uri.parse(filePath) + val tempPath = copyUriToTemp(uri, ".flac") + ?: return@withContext errorJson("Failed to copy SAF file to temp") + try { + val raw = coreBackend.rewriteSplitArtistTags(tempPath, artist, albumArtist) + val obj = JSONObject(raw) + if (!obj.optBoolean("success", false)) { + return@withContext raw + } + + if (!writeUriFromPath(uri, tempPath)) { + return@withContext errorJson("Failed to write rewritten tags back to SAF file") + } + + obj.put("file_path", filePath) + obj.toString() + } catch (e: Exception) { + errorJson("Failed to rewrite split artist tags in SAF file: ${e.message}") + } finally { + try { + File(tempPath).delete() + } catch (_: Exception) {} + } + } else { + coreBackend.rewriteSplitArtistTags(filePath, artist, albumArtist) + } + } + result.success(response) +} + +internal suspend fun MainActivity.handleReadFileMetadata(call: MethodCall, result: MethodChannel.Result) { + val filePath = call.argument("file_path") ?: "" + val displayName = call.argument("display_name") ?: "" + val response = withContext(Dispatchers.IO) { + try { + if (filePath.startsWith("content://")) { + readCompleteMetadataFromUri(Uri.parse(filePath), displayName) + ?.toString() ?: errorJson("Failed to read SAF metadata") + } else { + coreBackend.readFileMetadata(filePath, displayName) + } + } catch (e: Exception) { + errorJson(e.message ?: "Failed to read metadata") + } + } + result.success(response) +} + +internal suspend fun MainActivity.handleCheckHiResAuthenticity(call: MethodCall, result: MethodChannel.Result) { + val filePath = call.argument("file_path") ?: "" + val optionsJson = call.argument("options_json") ?: "" + val response = withContext(Dispatchers.IO) { + try { + if (filePath.startsWith("content://")) { + checkHiResAuthenticityFromUri(Uri.parse(filePath), optionsJson) + ?.toString() + ?: errorJson("Failed to read SAF audio file") + } else { + coreBackend.checkHiResAuthenticity(filePath, optionsJson) + } + } catch (e: Exception) { + errorJson(e.message ?: "Hi-Res check failed") + } + } + result.success(response) +} + +internal suspend fun MainActivity.handleEditFileMetadata(call: MethodCall, result: MethodChannel.Result) { + val filePath = call.argument("file_path") ?: "" + val metadataJson = call.argument("metadata_json") ?: "{}" + val response = withContext(Dispatchers.IO) { + try { + if (filePath.startsWith("content://")) { + val uri = Uri.parse(filePath) + val tempPath = copyUriToTemp(uri) + ?: return@withContext """{"error":"Failed to copy SAF file to temp"}""" + try { + val raw = coreBackend.editFileMetadata(tempPath, metadataJson) + val obj = JSONObject(raw) + val method = obj.optString("method", "") + if (method == "ffmpeg") { + // MP3/Opus: Dart needs to FFmpeg the temp file, then call writeTempToSaf + obj.put("temp_path", tempPath) + obj.put("saf_uri", filePath) + return@withContext obj.toString() + // Note: temp file NOT deleted here - Dart will clean up after FFmpeg + writeTempToSaf + } + // FLAC: the backend wrote to temp, copy back now + if (!writeUriFromPath(uri, tempPath)) { + try { File(tempPath).delete() } catch (_: Exception) {} + return@withContext """{"error":"Failed to write metadata back to SAF file"}""" + } + try { File(tempPath).delete() } catch (_: Exception) {} + raw + } catch (e: Exception) { + try { File(tempPath).delete() } catch (_: Exception) {} + throw e + } + } else { + coreBackend.editFileMetadata(filePath, metadataJson) + } + } catch (e: Exception) { + android.util.Log.e("SpotiFLAC", "editFileMetadata failed: ${e.message}", e) + """{"error":${org.json.JSONObject.quote(e.message ?: "unknown")}}""" + } + } + result.success(response) +} + +internal suspend fun MainActivity.handleWriteM4AFreeformTags(call: MethodCall, result: MethodChannel.Result) { + val filePath = call.argument("file_path") ?: "" + val metadataJson = call.argument("metadata_json") ?: "{}" + val response = withContext(Dispatchers.IO) { + try { + coreBackend.writeM4aFreeformTags(filePath, metadataJson) + } catch (e: Exception) { + android.util.Log.e("SpotiFLAC", "writeM4AFreeformTags failed: ${e.message}", e) + """{"error":${org.json.JSONObject.quote(e.message ?: "unknown")}}""" + } + } + result.success(response) +} + +internal suspend fun MainActivity.handleEnsureAC4Config(call: MethodCall, result: MethodChannel.Result) { + val filePath = call.argument("file_path") ?: "" + val sourcePath = call.argument("source_path") ?: "" + val response = withContext(Dispatchers.IO) { + try { + coreBackend.ensureAc4Config(filePath, sourcePath) + } catch (e: Exception) { + android.util.Log.e("SpotiFLAC", "ensureAC4Config failed: ${e.message}", e) + """{"error":${org.json.JSONObject.quote(e.message ?: "unknown")}}""" + } + } + result.success(response) +} + +internal suspend fun MainActivity.handleWriteAC4Metadata(call: MethodCall, result: MethodChannel.Result) { + val filePath = call.argument("file_path") ?: "" + val metadataJson = call.argument("metadata_json") ?: "{}" + val coverPath = call.argument("cover_path") ?: "" + val response = withContext(Dispatchers.IO) { + try { + coreBackend.writeAc4Metadata(filePath, metadataJson, coverPath) + } catch (e: Exception) { + android.util.Log.e("SpotiFLAC", "writeAC4Metadata failed: ${e.message}", e) + """{"error":${org.json.JSONObject.quote(e.message ?: "unknown")}}""" + } + } + result.success(response) +} + +internal suspend fun MainActivity.handleDownloadCoverToFile(call: MethodCall, result: MethodChannel.Result) { + val coverUrl = call.argument("cover_url") ?: "" + val outputPath = call.argument("output_path") ?: "" + val maxDimension = call.argument("max_dimension") + ?.toLong() + ?.coerceAtLeast(0L) + ?: 0L + val response = withContext(Dispatchers.IO) { + var temporaryCover: File? = null + try { + val destination = if (outputPath.isBlank()) { + coreBackend.createTemporaryMediaFile( + this@handleDownloadCoverToFile, + "cover_", + ".jpg", + ).also { temporaryCover = it }.absolutePath + } else { + outputPath + } + coreBackend.downloadCoverToFileSized( + coverUrl, + destination, + maxDimension + ) + JSONObject() + .put("success", true) + .put("file_path", destination) + .toString() + } catch (e: Exception) { + temporaryCover?.delete() + """{"success":false,"error":"${e.message?.replace("\"", "'")}"}""" + } + } + result.success(response) +} + +internal suspend fun MainActivity.handleExtractCoverToFile(call: MethodCall, result: MethodChannel.Result) { + val audioPath = call.argument("audio_path") ?: "" + val outputPath = call.argument("output_path") ?: "" + val response = withContext(Dispatchers.IO) { + try { + if (audioPath.startsWith("content://")) { + extractCoverFromUri(Uri.parse(audioPath), outputPath) + """{"success":true}""" + } else { + coreBackend.extractCoverToFile(audioPath, outputPath) + """{"success":true}""" + } + } catch (e: Exception) { + """{"success":false,"error":"${e.message?.replace("\"", "'")}"}""" + } + } + result.success(response) +} + +internal suspend fun MainActivity.handleReEnrichFile(call: MethodCall, result: MethodChannel.Result) { + val requestJson = call.argument("request_json") ?: "{}" + val response = withContext(Dispatchers.IO) { + try { + val reqObj = JSONObject(requestJson) + val filePath = reqObj.optString("file_path", "") + + // Preview only resolves online metadata; it does not need + // a full SAF document copy and never writes the source. + if (filePath.startsWith("content://") && !reqObj.optBoolean("preview_only", false)) { + val uri = Uri.parse(filePath) + val tempPath = copyUriToTemp(uri) + ?: return@withContext """{"error":"Failed to copy SAF file to temp"}""" + var retainedForFfmpeg = false + try { + reqObj.put("file_path", tempPath) + val raw = coreBackend.reEnrichFile(reqObj.toString()) + val obj = JSONObject(raw) + + if (obj.has("error")) { + return@withContext raw + } + + val method = obj.optString("method", "") + if (method == "ffmpeg") { + // MP3/Opus: Dart handles FFmpeg on temp file, then writes back + obj.put("temp_path", tempPath) + obj.put("saf_uri", filePath) + retainedForFfmpeg = true + return@withContext obj.toString() + // Dart cleans up after FFmpeg + writeTempToSaf. + } + + // FLAC: the selected backend wrote to temp; copy back now. + if (!writeUriFromPath(uri, tempPath)) { + return@withContext """{"error":"Failed to write enriched metadata back to SAF file"}""" + } + if (obj.optBoolean("write_external_lrc", false) && + !writeSafSidecarLrc(uri, obj.optString("lyrics", ""))) { + return@withContext errorJson( + "Metadata was saved, but the LRC lyrics file could not be updated" + ) + } + raw + } finally { + if (!retainedForFfmpeg) { + try { File(tempPath).delete() } catch (_: Exception) {} + } + } + } else { + coreBackend.reEnrichFile(requestJson) + } + } catch (e: Exception) { + """{"error":${org.json.JSONObject.quote(e.message ?: "unknown")}}""" + } + } + result.success(response) +} + +internal suspend fun MainActivity.handleRunPostProcessingV2(call: MethodCall, result: MethodChannel.Result) { + val inputJson = call.argument("input") ?: "" + val metadataJson = call.argument("metadata") ?: "" + val response = withContext(Dispatchers.IO) { + val inputObj = if (inputJson.isNotBlank()) JSONObject(inputJson) else JSONObject() + val uriStr = inputObj.optString("uri", "") + val pathStr = inputObj.optString("path", "") + val effectiveUri = when { + uriStr.startsWith("content://") -> uriStr + pathStr.startsWith("content://") -> pathStr + else -> "" + } + + if (effectiveUri.isNotBlank()) { + runPostProcessingSafV2(effectiveUri, metadataJson, inputObj.optString("item_id", "")) + } else { + if (pathStr.isNotBlank()) { + inputObj.put("name", File(pathStr).name) + inputObj.put("is_saf", false) + } + coreBackend.runPostProcessing(inputObj.toString(), metadataJson) + } + } + result.success(response) +} + +internal suspend fun MainActivity.handleReadAudioMetadata(call: MethodCall, result: MethodChannel.Result) { + val filePath = call.argument("file_path") ?: "" + val response = withContext(Dispatchers.IO) { + try { + if (filePath.startsWith("content://")) { + val uri = Uri.parse(filePath) + val metadata = readAudioMetadataFromUri(uri) + ?: return@withContext """{"error":"Failed to read SAF audio metadata"}""" + metadata.put("filePath", filePath) + metadata.toString() + } else { + coreBackend.readAudioMetadata(filePath, "", "") + } + } catch (e: Exception) { + """{"error":${org.json.JSONObject.quote(e.message ?: "unknown")}}""" + } + } + result.success(response) +} + +internal suspend fun MainActivity.handleParseCueSheet(call: MethodCall, result: MethodChannel.Result) { + val cuePath = call.argument("cue_path") ?: "" + val audioDir = call.argument("audio_dir") ?: "" + val response = withContext(Dispatchers.IO) { + try { + if (cuePath.startsWith("content://")) { + val uri = Uri.parse(cuePath) + val tempCuePath = copyUriToTemp(uri, ".cue") + ?: return@withContext """{"error":"Failed to copy CUE file to temp"}""" + try { + val audioFileName = extractCueAudioFileName(tempCuePath) + + val parentDir = safParentDir(uri) + val audioDoc = if (parentDir != null) { + val cueName = try { + DocumentFile.fromSingleUri(this@handleParseCueSheet, uri)?.name ?: "" + } catch (_: Exception) { "" } + findSafCueAudioSibling(this@handleParseCueSheet, parentDir, cueName, audioFileName) + } else { + null + } + + if (audioDoc == null || !audioDoc.isFile) { + return@withContext JSONObject() + .put("error", "Audio file not found for CUE sheet") + .toString() + } + + // Preview parses the CUE text only; the selected audio + // stays in SAF instead of copying a whole album to cache. + val resultJson = coreBackend.parseCueSheetWithResolvedAudio( + tempCuePath, + audioDoc.uri.toString(), + ) + JSONObject(resultJson) + .put("cue_path", cuePath) + .toString() + } finally { + try { File(tempCuePath).delete() } catch (_: Exception) {} + } + } else { + coreBackend.parseCueSheet(cuePath, audioDir) + } + } catch (e: Exception) { + """{"error":${org.json.JSONObject.quote(e.message ?: "unknown")}}""" + } + } + result.success(response) +}