mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 04:42:02 +02:00
perf(cue): parse SAF previews without copying audio
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -239,28 +239,27 @@ impl Backend {
|
||||
"audio file not found for cue sheet:",
|
||||
)
|
||||
})?;
|
||||
let tracks: Vec<Value> = sheet
|
||||
.tracks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, track)| {
|
||||
let mut value = json!({"number":track.number,"title":track.title,
|
||||
"artist":prefer(&track.performer, &sheet.performer),"start_sec":track.start_time,
|
||||
"end_sec":next_start(&sheet, index).unwrap_or(-1.0)});
|
||||
optional(&mut value, "isrc", &track.isrc);
|
||||
optional(
|
||||
&mut value,
|
||||
"composer",
|
||||
prefer(&track.composer, &sheet.composer),
|
||||
);
|
||||
value
|
||||
})
|
||||
.collect();
|
||||
cue_sheet_json(path, &sheet, &audio, &check)
|
||||
}
|
||||
|
||||
/// Native callers resolve and validate the audio reference themselves (for
|
||||
/// example an Android SAF URI). Parsing the sheet needs no audio contents.
|
||||
pub fn parse_cue_file_json_with_resolved_audio(
|
||||
&self,
|
||||
path: &str,
|
||||
audio_path: &str,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Value, String> {
|
||||
let _operation = self.enter()?;
|
||||
let check = || self.check().and_then(|()| check());
|
||||
check()?;
|
||||
let mut result = json!({"cue_path":path,"audio_path":audio,"album":sheet.title,"artist":sheet.performer,"tracks":tracks});
|
||||
optional(&mut result, "genre", &sheet.genre);
|
||||
optional(&mut result, "date", &sheet.date);
|
||||
Ok(result)
|
||||
if audio_path.trim().is_empty() {
|
||||
return Err("resolved audio path is empty".into());
|
||||
}
|
||||
let sheet = self
|
||||
.parse_cue_file(path, &check)
|
||||
.map_err(|error| format!("failed to parse cue file: {error}"))?;
|
||||
cue_sheet_json(path, &sheet, audio_path, &check)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -429,3 +428,114 @@ fn next_start(sheet: &CueSheet, index: usize) -> Option<f64> {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn cue_sheet_json(
|
||||
path: &str,
|
||||
sheet: &CueSheet,
|
||||
audio: &str,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Value, String> {
|
||||
let mut tracks = Vec::with_capacity(sheet.tracks.len());
|
||||
for (index, track) in sheet.tracks.iter().enumerate() {
|
||||
check()?;
|
||||
let mut value = json!({"number":track.number,"title":track.title,
|
||||
"artist":prefer(&track.performer, &sheet.performer),"start_sec":track.start_time,
|
||||
"end_sec":next_start(sheet, index).unwrap_or(-1.0)});
|
||||
optional(&mut value, "isrc", &track.isrc);
|
||||
optional(
|
||||
&mut value,
|
||||
"composer",
|
||||
prefer(&track.composer, &sheet.composer),
|
||||
);
|
||||
tracks.push(value);
|
||||
}
|
||||
check()?;
|
||||
let mut result = json!({"cue_path":path,"audio_path":audio,"album":sheet.title,"artist":sheet.performer,"tracks":tracks});
|
||||
optional(&mut result, "genre", &sheet.genre);
|
||||
optional(&mut result, "date", &sheet.date);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
use std::io::Cursor;
|
||||
|
||||
const CUE: &str = "PERFORMER \"Album Artist\"\nTITLE \"Album\"\nREM GENRE Jazz\nREM DATE 2026\nREM COMPOSER \"Album Writer\"\nFILE \"album.flac\" WAVE\nTRACK 01 AUDIO\nTITLE \"First\"\nPERFORMER \"Track Artist\"\nISRC XXAAA2600001\nINDEX 01 00:00:00\nTRACK 02 AUDIO\nTITLE \"Second\"\nINDEX 00 03:00:00\nINDEX 01 03:02:00\n";
|
||||
|
||||
#[test]
|
||||
fn resolved_cue_audio_is_opaque_and_matches_legacy_tracks_without_audio() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let data = directory.path().join("data");
|
||||
let backend = Backend::new(
|
||||
&directory.path().join("sources"),
|
||||
&data,
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
"1",
|
||||
crate::RuntimeLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let path = data.join("album.cue");
|
||||
std::fs::write(&path, CUE).unwrap();
|
||||
let path = path.to_str().unwrap();
|
||||
let audio = data.join("album.flac");
|
||||
std::fs::write(&audio, []).unwrap();
|
||||
let mut expected = backend.parse_cue_file_json(path, "", &|| Ok(())).unwrap();
|
||||
std::fs::remove_file(audio).unwrap();
|
||||
let resolved = "content://example.documents/tree/root/document/opaque%2F音楽.flac";
|
||||
expected["audio_path"] = resolved.into();
|
||||
let result = backend
|
||||
.parse_cue_file_json_with_resolved_audio(path, resolved, &|| Ok(()))
|
||||
.unwrap();
|
||||
assert_eq!(result, expected);
|
||||
assert_eq!(result["tracks"][0]["end_sec"], 180.0);
|
||||
assert_eq!(result["tracks"][1]["end_sec"], -1.0);
|
||||
assert_eq!(result["tracks"][1]["composer"], "Album Writer");
|
||||
assert!(
|
||||
backend
|
||||
.parse_cue_file_json(path, "", &|| Ok(()))
|
||||
.unwrap_err()
|
||||
.contains("audio file not found")
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(path).unwrap(), CUE);
|
||||
assert_eq!(
|
||||
backend
|
||||
.parse_cue_file_json_with_resolved_audio(path, " \t", &|| Ok(()))
|
||||
.unwrap_err(),
|
||||
"resolved audio path is empty"
|
||||
);
|
||||
assert_eq!(
|
||||
backend
|
||||
.parse_cue_file_json_with_resolved_audio(path, resolved, &|| {
|
||||
Err("cancelled".into())
|
||||
})
|
||||
.unwrap_err(),
|
||||
"cancelled"
|
||||
);
|
||||
std::fs::write(path, "TITLE \"No tracks\"\n").unwrap();
|
||||
assert!(
|
||||
backend
|
||||
.parse_cue_file_json_with_resolved_audio(path, resolved, &|| Ok(()))
|
||||
.unwrap_err()
|
||||
.contains("no tracks found")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cue_json_checks_cancellation_while_mapping_tracks() {
|
||||
let sheet = cue::parse(&mut Cursor::new(CUE), &|| Ok(())).unwrap();
|
||||
let checks = Cell::new(0);
|
||||
let error = cue_sheet_json("album.cue", &sheet, "opaque-audio", &|| {
|
||||
checks.set(checks.get() + 1);
|
||||
if checks.get() == 2 {
|
||||
Err("cancelled".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(error, "cancelled");
|
||||
assert_eq!(checks.get(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,20 @@ impl ExtensionManager {
|
||||
.map_err(ExtensionManagerError::Operation)
|
||||
}
|
||||
|
||||
pub fn parse_cue_file_json_with_resolved_audio(
|
||||
&self,
|
||||
path: String,
|
||||
audio_path: String,
|
||||
lease: Option<Arc<RequestLease>>,
|
||||
) -> Result<String, ExtensionManagerError> {
|
||||
self.inner
|
||||
.parse_cue_file_json_with_resolved_audio(&path, &audio_path, &|| {
|
||||
check_lease(lease.as_deref())
|
||||
})
|
||||
.map(|value| value.to_string())
|
||||
.map_err(ExtensionManagerError::Operation)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn scan_cue_file_for_library(
|
||||
&self,
|
||||
|
||||
Reference in New Issue
Block a user