diff --git a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/ResolvedCueAudioTest.kt b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/ResolvedCueAudioTest.kt new file mode 100644 index 00000000..5aefd809 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/ResolvedCueAudioTest.kt @@ -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() + } + } +} diff --git a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/SafDocumentLookupTest.kt b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/SafDocumentLookupTest.kt index f96f7346..5e38d891 100644 --- a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/SafDocumentLookupTest.kt +++ b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/SafDocumentLookupTest.kt @@ -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) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/CoreBackend.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/CoreBackend.kt index b75da870..84ad51b1 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/CoreBackend.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/CoreBackend.kt @@ -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 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 8cbdbd28..c7d5878c 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -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) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/SafCueLookup.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/SafCueLookup.kt new file mode 100644 index 00000000..21950cf4 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/SafCueLookup.kt @@ -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() + 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) +} diff --git a/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt b/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt index b5da804e..476ca74e 100644 --- a/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt +++ b/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt @@ -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 diff --git a/rust_backend/crates/extensions/src/backend/library.rs b/rust_backend/crates/extensions/src/backend/library.rs index dbfed384..ff228078 100644 --- a/rust_backend/crates/extensions/src/backend/library.rs +++ b/rust_backend/crates/extensions/src/backend/library.rs @@ -239,28 +239,27 @@ impl Backend { "audio file not found for cue sheet:", ) })?; - let tracks: Vec = 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 { + 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 { } }) } + +fn cue_sheet_json( + path: &str, + sheet: &CueSheet, + audio: &str, + check: &dyn Fn() -> Result<(), String>, +) -> Result { + 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); + } +} diff --git a/rust_backend/crates/mobile/src/tags.rs b/rust_backend/crates/mobile/src/tags.rs index f8759453..a2558aa6 100644 --- a/rust_backend/crates/mobile/src/tags.rs +++ b/rust_backend/crates/mobile/src/tags.rs @@ -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>, + ) -> Result { + 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,