perf(cue): parse SAF previews without copying audio

This commit is contained in:
zarzet committed 2026-09-17 17:38:49 +07:00
1 parent 1387a28d66
commit 91ef538c87
8 files changed
+282 -65

No files matched your search

@@ -0,0 +1,53 @@
package com.zarz.spotiflac
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.spotiflac.backend.CancellationDomain
import com.spotiflac.backend.CancellationRegistry
import com.spotiflac.backend.ExtensionManager
import java.io.File
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ResolvedCueAudioTest {
@Test
fun realRustBindingParsesWithoutAudioAndPreservesLegacyValidation() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val root = File(context.cacheDir, "resolved-cue-${System.nanoTime()}").apply { mkdirs() }
try {
val sources = File(root, "sources").apply { mkdirs() }
val data = File(root, "data")
ExtensionManager(sources.path, data.path, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "1", 2000uL).use { manager ->
val cue = File(data, "album.cue").apply {
writeText("PERFORMER \"Artist\"\nTITLE \"Album\"\nFILE \"album.flac\" WAVE\nTRACK 01 AUDIO\nTITLE \"First\"\nINDEX 01 00:00:00\nTRACK 02 AUDIO\nTITLE \"Second\"\nINDEX 01 03:00:00\n")
}
val original = cue.readText()
val audio = File(data, "album.flac").apply { writeText("") }
val legacy = JSONObject(manager.parseCueFileJson(cue.path, "", null))
assertTrue(audio.delete())
val uri = "content://example.documents/tree/root/document/opaque%2F音楽.flac"
val parsed = JSONObject(manager.parseCueFileJsonWithResolvedAudio(cue.path, uri, null))
assertEquals(uri, parsed.getString("audio_path"))
assertEquals(cue.path, parsed.getString("cue_path"))
assertEquals(legacy.getJSONArray("tracks").toString(), parsed.getJSONArray("tracks").toString())
assertEquals(-1.0, parsed.getJSONArray("tracks").getJSONObject(1).getDouble("end_sec"), 0.0)
assertTrue(runCatching { manager.parseCueFileJson(cue.path, "", null) }.isFailure)
assertTrue(runCatching { manager.parseCueFileJsonWithResolvedAudio(cue.path, " ", null) }.isFailure)
CancellationRegistry(CancellationDomain.EXTENSION_REQUEST).use { registry ->
registry.acquire("cue-preview").use { lease ->
registry.cancel("cue-preview")
assertTrue(runCatching { manager.parseCueFileJsonWithResolvedAudio(cue.path, uri, lease) }.isFailure)
}
}
assertEquals(original, cue.readText())
assertTrue(!audio.exists())
}
} finally {
root.deleteRecursively()
}
}
}
@@ -39,6 +39,32 @@ class SafDocumentLookupTest {
context.contentResolver.call(controlUri, "lookup.clearCounters", null, null)
}
@Test
fun cueSiblingLookupBatchesCandidatesAndPreservesPriority() {
val parent = reset(count = 2000)
val declared = findSafCueAudioSibling(context, parent, "Song.cue", "Track 1999.flac")
assertEquals("child:1999", DocumentsContract.getDocumentId(requireNotNull(declared).uri))
assertEquals(1, stats().getInt("children"))
assertEquals(0, stats().getInt("documents"))
clearCounters()
val fallback = findSafCueAudioSibling(context, parent, "Song.cue", "missing.flac")
assertEquals("original", DocumentsContract.getDocumentId(requireNotNull(fallback).uri))
assertEquals(1, stats().getInt("children"))
assertEquals(0, stats().getInt("documents"))
assertTrue(fallback.renameTo("Song.FLAC"))
clearCounters()
assertEquals(fallback.uri, findSafCueAudioSibling(context, parent, "Song.cue", null)?.uri)
assertEquals(1, stats().getInt("children"))
assertEquals(0, stats().getInt("documents"))
clearCounters()
assertNull(findSafCueAudioSibling(context, parent, "Missing.cue", null))
assertEquals(1, stats().getInt("children"))
assertEquals(0, stats().getInt("documents"))
}
@Test
fun projectedBatchReplacesThousandsOfNameQueriesAndClosesItsCursor() {
val parent = reset(count = 2000)
@@ -204,6 +204,7 @@ internal interface CoreBackend {
fun getLibraryScanProgress(): String
fun cancelLibraryScan()
fun parseCueSheet(path: String, audioDirectory: String): String
fun parseCueSheetWithResolvedAudio(path: String, audioPath: String): String
fun scanCueForLibrary(path: String, audioDirectory: String, virtualPrefix: String, modTime: Long, cacheKey: String): String
fun editFileMetadata(path: String, metadataJson: String): String
fun reEnrichFile(requestJson: String): String
@@ -1953,61 +1953,36 @@ class MainActivity: FlutterFragmentActivity() {
val uri = Uri.parse(cuePath)
val tempCuePath = copyUriToTemp(uri, ".cue")
?: return@withContext """{"error":"Failed to copy CUE file to temp"}"""
var tempAudioPath: String? = null
try {
val audioFileName = extractCueAudioFileName(tempCuePath)
var audioDoc: DocumentFile? = null
val parentDir = safParentDir(uri)
if (parentDir != null && !audioFileName.isNullOrBlank()) {
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
}
if (audioDoc == null && parentDir != null) {
val audioDoc = if (parentDir != null) {
val cueName = try {
DocumentFile.fromSingleUri(this@MainActivity, uri)?.name ?: ""
} catch (_: Exception) { "" }
val cueBaseName = cueName.substringBeforeLast('.')
if (cueBaseName.isNotBlank()) {
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac")
for (ext in commonExts) {
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
if (audioDoc != null) break
audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null }
if (audioDoc != null) break
}
}
}
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
if (audioDoc != null) {
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (audioExt.isNotBlank()) ".$audioExt" else null
val copiedAudio = copyUriToTemp(audioDoc.uri, fallbackExt)
if (copiedAudio != null) {
val renamedAudio = File(tempDir, audioName)
val copiedFile = File(copiedAudio)
if (renamedAudio.absolutePath != copiedFile.absolutePath) {
copiedFile.renameTo(renamedAudio)
}
tempAudioPath = renamedAudio.absolutePath
}
}
val resultJson = coreBackend.parseCueSheet(tempCuePath, tempDir)
if (audioDoc != null) {
val resultObj = JSONObject(resultJson)
resultObj.put("audio_path", audioDoc.uri.toString())
resultObj.put("cue_path", cuePath)
resultObj.toString()
findSafCueAudioSibling(this@MainActivity, parentDir, cueName, audioFileName)
} else {
resultJson
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) {}
try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {}
}
} else {
coreBackend.parseCueSheet(cuePath, audioDir)
@@ -0,0 +1,31 @@
package com.zarz.spotiflac
import android.content.Context
import androidx.documentfile.provider.DocumentFile
import java.util.Locale
internal fun findSafCueAudioSibling(
context: Context,
parent: DocumentFile,
cueName: String,
audioFileName: String?,
): DocumentFile? {
val candidates = linkedSetOf<String>()
if (!audioFileName.isNullOrBlank()) candidates.add(audioFileName)
val baseName = cueName.substringBeforeLast('.')
if (baseName.isNotBlank()) {
for (extension in listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac")) {
candidates.add(baseName + extension)
candidates.add(baseName + extension.uppercase(Locale.ROOT))
}
}
val matches = try {
findSafChildren(context, parent, candidates)
} catch (_: Exception) {
// Preserve the old per-candidate recovery for unusual providers.
return candidates.firstNotNullOfOrNull { name ->
try { findSafChild(context, parent, name) } catch (_: Exception) { null }
}
}
return candidates.firstNotNullOfOrNull(matches::get)
}
@@ -296,6 +296,13 @@ internal object RustCoreBackend : CoreBackend {
}
}
override fun parseCueSheetWithResolvedAudio(path: String, audioPath: String): String {
val cue = File(path).canonicalFile
return withLibraryDirectories(listOf(cue.parent)) {
it.parseCueFileJsonWithResolvedAudio(cue.path, audioPath, null)
}
}
override fun scanCueForLibrary(path: String, audioDirectory: String, virtualPrefix: String, modTime: Long, cacheKey: String): String {
val cue = File(path).canonicalFile
val audio = if (audioDirectory.isEmpty()) cue.parentFile!! else File(audioDirectory).canonicalFile