From 2af0fa95e502434662d1d78e27676f39d48d8b72 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:49:10 +0700 Subject: [PATCH] refactor(android): move SAF CUE helpers out of MainActivitySafScan.kt The CUE sheet helpers used by both SAF scans (FILE line extraction, the per-directory child lookup cache and the audio sibling resolver) move verbatim into SafCueScan.kt. rememberCueDirectoryListing becomes internal because the scans stay behind; the sibling extension list and the lookup builder are only used in the new file and stay private. --- .../com/zarz/spotiflac/MainActivitySafScan.kt | 95 ---------------- .../kotlin/com/zarz/spotiflac/SafCueScan.kt | 103 ++++++++++++++++++ 2 files changed, 103 insertions(+), 95 deletions(-) create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/SafCueScan.kt diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt index cfa4d209..41009edb 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt @@ -90,40 +90,6 @@ internal fun MainActivity.loadExistingFilesFromSnapshot(snapshotPath: String): M return result } - /** - * Extract the audio filename referenced by a CUE sheet file. - * Reads the FILE "name" TYPE line from the .cue text. - * Returns just the filename (no path), or null if not found. - */ -internal fun MainActivity.extractCueAudioFileName(cueTempPath: String): String? { - try { - val lines = File(cueTempPath).readLines() - for (line in lines) { - val trimmed = line.trim().let { l -> - if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l - } - if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) { - val rest = trimmed.substring(5).trim() - val filename = if (rest.startsWith("\"")) { - val endQuote = rest.indexOf('"', 1) - if (endQuote > 0) rest.substring(1, endQuote) else rest - } else { - val parts = rest.split("\\s+".toRegex()) - if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest - } - return filename.substringAfterLast("/").substringAfterLast("\\") - } - } - } catch (e: Exception) { - android.util.Log.w("SpotiFLAC", "Failed to extract audio filename from CUE: ${e.message}") - } - return null - } - - private val cueSiblingAudioExtensions = listOf( - ".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac" - ) - // Keep the SAF folder walk aligned with the backend's supported audio formats. // CUE files are handled separately. private val libraryScanAudioExtensions = setOf( @@ -131,36 +97,6 @@ internal fun MainActivity.extractCueAudioFileName(cueTempPath: String): String? ".ape", ".wv", ".mpc", ".wav", ".aiff", ".aif", ".dsf", ".dff" ) -internal fun MainActivity.getSafChildFileLookup( - dir: DocumentFile, - cache: MutableMap>, - ): Map { - val dirKey = dir.uri.toString() - return cache.getOrPut(dirKey) { safChildFileLookup(listSafChildrenOrThrow(dir)) } - } - -private fun safChildFileLookup(children: List): Map = - buildMap { - for (child in children) { - if (child.isDirectory) continue - val childName = child.name?.trim().orEmpty() - if (childName.isBlank()) continue - put(childName.lowercase(Locale.ROOT), child.doc) - } - } - -/** Keeps a traversal listing for CUE sibling lookup so the directory is not - * queried again; on network providers every listing is a round trip. */ -private fun rememberCueDirectoryListing( - dir: DocumentFile, - children: List, - cache: MutableMap>, -) { - if (children.any { !it.isDirectory && it.name?.endsWith(".cue", ignoreCase = true) == true }) { - cache[dir.uri.toString()] = safChildFileLookup(children) - } -} - // Provider round trips dominate on SD cards, USB drives and network shares. private const val SAF_LIST_CONCURRENCY = 4 private const val SAF_READ_WORKERS = 6 @@ -266,37 +202,6 @@ private fun runSafReadsInOrder( } } -internal fun MainActivity.resolveCueAudioSibling( - parentDir: DocumentFile, - cueName: String, - audioFileName: String?, - childLookupCache: MutableMap>, - ): DocumentFile? { - val childLookup = getSafChildFileLookup(parentDir, childLookupCache) - - val directMatch = audioFileName - ?.trim() - ?.takeIf { it.isNotEmpty() } - ?.substringAfterLast("/") - ?.substringAfterLast("\\") - ?.lowercase(Locale.ROOT) - ?.let(childLookup::get) - if (directMatch != null) { - return directMatch - } - - val cueBaseName = cueName.substringBeforeLast('.').trim() - if (cueBaseName.isBlank()) { - return null - } - - val cueBaseKey = cueBaseName.lowercase(Locale.ROOT) - for (ext in cueSiblingAudioExtensions) { - childLookup["$cueBaseKey$ext"]?.let { return it } - } - return null - } - internal data class SafChildEntry( val doc: DocumentFile, val name: String?, diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/SafCueScan.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/SafCueScan.kt new file mode 100644 index 00000000..301eeaa0 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/SafCueScan.kt @@ -0,0 +1,103 @@ +package com.zarz.spotiflac + +import androidx.documentfile.provider.DocumentFile +import java.io.File +import java.util.Locale + +// CUE sheet helpers for the SAF library scan: the audio file a sheet names, +// cached per-directory child lookups, and resolving the sheet's audio sibling. + + /** + * Extract the audio filename referenced by a CUE sheet file. + * Reads the FILE "name" TYPE line from the .cue text. + * Returns just the filename (no path), or null if not found. + */ +internal fun MainActivity.extractCueAudioFileName(cueTempPath: String): String? { + try { + val lines = File(cueTempPath).readLines() + for (line in lines) { + val trimmed = line.trim().let { l -> + if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l + } + if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) { + val rest = trimmed.substring(5).trim() + val filename = if (rest.startsWith("\"")) { + val endQuote = rest.indexOf('"', 1) + if (endQuote > 0) rest.substring(1, endQuote) else rest + } else { + val parts = rest.split("\\s+".toRegex()) + if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest + } + return filename.substringAfterLast("/").substringAfterLast("\\") + } + } + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "Failed to extract audio filename from CUE: ${e.message}") + } + return null + } + + private val cueSiblingAudioExtensions = listOf( + ".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac" + ) + +internal fun MainActivity.getSafChildFileLookup( + dir: DocumentFile, + cache: MutableMap>, + ): Map { + val dirKey = dir.uri.toString() + return cache.getOrPut(dirKey) { safChildFileLookup(listSafChildrenOrThrow(dir)) } + } + +private fun safChildFileLookup(children: List): Map = + buildMap { + for (child in children) { + if (child.isDirectory) continue + val childName = child.name?.trim().orEmpty() + if (childName.isBlank()) continue + put(childName.lowercase(Locale.ROOT), child.doc) + } + } + +/** Keeps a traversal listing for CUE sibling lookup so the directory is not + * queried again; on network providers every listing is a round trip. */ +internal fun rememberCueDirectoryListing( + dir: DocumentFile, + children: List, + cache: MutableMap>, +) { + if (children.any { !it.isDirectory && it.name?.endsWith(".cue", ignoreCase = true) == true }) { + cache[dir.uri.toString()] = safChildFileLookup(children) + } +} + +internal fun MainActivity.resolveCueAudioSibling( + parentDir: DocumentFile, + cueName: String, + audioFileName: String?, + childLookupCache: MutableMap>, + ): DocumentFile? { + val childLookup = getSafChildFileLookup(parentDir, childLookupCache) + + val directMatch = audioFileName + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.substringAfterLast("/") + ?.substringAfterLast("\\") + ?.lowercase(Locale.ROOT) + ?.let(childLookup::get) + if (directMatch != null) { + return directMatch + } + + val cueBaseName = cueName.substringBeforeLast('.').trim() + if (cueBaseName.isBlank()) { + return null + } + + val cueBaseKey = cueBaseName.lowercase(Locale.ROOT) + for (ext in cueSiblingAudioExtensions) { + childLookup["$cueBaseKey$ext"]?.let { return it } + } + return null + }