From c69f2cef9f8c0e77acc055a473cfacf34e0454bf Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:41:35 +0700 Subject: [PATCH] perf(library): scan SAF CUE audio through live descriptors --- .../zarz/spotiflac/RemoteSafPlaybackTest.kt | 43 +++ .../zarz/spotiflac/ResolvedCueAudioTest.kt | 41 +++ .../kotlin/com/zarz/spotiflac/CoreBackend.kt | 1 + .../com/zarz/spotiflac/MainActivitySafIo.kt | 18 ++ .../com/zarz/spotiflac/MainActivitySafScan.kt | 50 +--- .../com/zarz/spotiflac/SelectedCoreBackend.kt | 26 ++ .../crates/extensions/src/backend/library.rs | 244 +++++++++++++++--- rust_backend/crates/mobile/src/tags.rs | 58 +++++ 8 files changed, 396 insertions(+), 85 deletions(-) diff --git a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/RemoteSafPlaybackTest.kt b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/RemoteSafPlaybackTest.kt index 352dd5fc..062186d4 100644 --- a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/RemoteSafPlaybackTest.kt +++ b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/RemoteSafPlaybackTest.kt @@ -13,12 +13,14 @@ import com.spotiflac.backend.readFileMetadata import com.spotiflac.backend.readLibraryMetadataFromDescriptor import java.io.ByteArrayOutputStream import java.io.DataOutputStream +import java.io.File import java.io.FileInputStream import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicLong +import org.json.JSONArray import org.json.JSONObject import org.junit.Assert.* import org.junit.Test @@ -118,4 +120,45 @@ class RemoteSafPlaybackTest { assertTrue(fd.fileDescriptor.valid()) } } + + @Test fun proxyCueLibraryScanReadsTagsWithoutTransferringTheAlbum() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val root = File(context.cacheDir, "proxy-cue-${System.nanoTime()}").apply { mkdirs() } + val library = File(root, "library").apply { mkdirs() } + val backend = createCoreBackend(context) + try { + backend.invokeApplication("initExtensionSystem", mapOf( + "extensions_dir" to File(root, "sources").apply { mkdirs() }.path, + "data_dir" to File(root, "data").apply { mkdirs() }.path, + "master_key" to "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "allowed_directories" to listOf(library.path), + )) + val cue = File(library, "album.cue").apply { + writeText("FILE \"absent.flac\" WAVE\nTRACK 01 AUDIO\nINDEX 01 00:00:00\nTRACK 02 AUDIO\nINDEX 01 00:01:00\n") + } + val streamInfo = ByteBuffer.allocate(34).apply { + putShort(4096); putShort(4096); position(10) + putLong((96000L shl 44) or (1L shl 41) or (23L shl 36) or 264000L) + }.array() + val header = "fLaC".toByteArray() + byteArrayOf(0x80.toByte(), 0, 0, 34) + streamInfo + byteArrayOf(0xff.toByte(), 0xf8.toByte()) + withProxy(header, 64L * 1024 * 1024) { fd, reads -> + val prefix = "content://example.documents/document/album.cue" + val rows = JSONArray(backend.scanCueForLibraryWithResolvedAudio( + cue.path, "/proc/self/fd/${fd.fd}", "Remote.flac", prefix, 123L, "proxy-cue-cover", + )) + assertEquals(2, rows.length()) + val last = rows.getJSONObject(1) + assertEquals("$prefix#track02", last.getString("filePath")) + assertEquals(96000, last.getInt("sampleRate")) + assertEquals(24, last.getInt("bitDepth")) + assertEquals(1, last.getInt("duration")) + assertTrue("CUE metadata must not transfer 64 MiB of audio", reads.get() < 1024 * 1024) + assertTrue(fd.fileDescriptor.valid()) + } + assertFalse(File(library, "absent.flac").exists()) + } finally { + backend.invokeApplication("cleanupExtensions", emptyMap()) + root.deleteRecursively() + } + } } diff --git a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/ResolvedCueAudioTest.kt b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/ResolvedCueAudioTest.kt index 5aefd809..9d8731f7 100644 --- a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/ResolvedCueAudioTest.kt +++ b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/ResolvedCueAudioTest.kt @@ -5,7 +5,9 @@ import androidx.test.platform.app.InstrumentationRegistry import com.spotiflac.backend.CancellationDomain import com.spotiflac.backend.CancellationRegistry import com.spotiflac.backend.ExtensionManager +import android.os.ParcelFileDescriptor import java.io.File +import org.json.JSONArray import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -14,6 +16,45 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ResolvedCueAudioTest { + @Test + fun cueLibraryScanUsesBorrowedDescriptorAndPreservesQualityAndLastDuration() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val root = File(context.cacheDir, "descriptor-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 \"absent.flac\" WAVE\nTRACK 01 AUDIO\nINDEX 01 00:00:00\nTRACK 02 AUDIO\nINDEX 01 00:01:00\n") + } + val audio = File(root, "outside-cue-directory.flac") + val fixture = NativeDownloadFinalizer.runFFmpegArguments(arrayOf( + "-v", "error", "-f", "lavfi", "-i", "sine=frequency=997:sample_rate=96000", + "-t", "2.75", "-c:a", "flac", "-sample_fmt", "s32", audio.path, + )) + assertTrue(fixture.second, fixture.first) + val prefix = "content://example.documents/document/album.cue" + ParcelFileDescriptor.open(audio, ParcelFileDescriptor.MODE_READ_ONLY).use { descriptor -> + val rows = JSONArray(manager.scanCueFileForLibraryFromDescriptor( + cue.path, descriptor.fd, audio.name, prefix, 123L, "cached-cover.jpg", "scan-time", null, + )) + assertEquals(2, rows.length()) + val last = rows.getJSONObject(1) + assertEquals("$prefix#track02", last.getString("filePath")) + assertEquals(96000, last.getInt("sampleRate")) + assertEquals(24, last.getInt("bitDepth")) + assertEquals(1, last.getInt("duration")) + assertEquals("cached-cover.jpg", last.getString("coverPath")) + assertTrue(descriptor.statSize > 0) + } + assertTrue(!File(data, "absent.flac").exists()) + assertTrue(runCatching { manager.scanCueFileForLibraryFromDescriptor( + cue.path, -1, audio.name, prefix, 123L, "", "scan-time", null, + ) }.isFailure) + } + } finally { root.deleteRecursively() } + } + @Test fun realRustBindingParsesWithoutAudioAndPreservesLegacyValidation() { val context = InstrumentationRegistry.getInstrumentation().targetContext 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 8bfe3f7f..37ba7236 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/CoreBackend.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/CoreBackend.kt @@ -208,6 +208,7 @@ internal interface CoreBackend { 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 scanCueForLibraryWithResolvedAudio(path: String, audioPath: String, audioName: String, virtualPrefix: String, modTime: Long, cacheKey: String): String fun editFileMetadata(path: String, metadataJson: String): String fun reEnrichFile(requestJson: String): String fun rewriteSplitArtistTags(path: String, artist: String, albumArtist: String): String diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt index 3437631c..2a7141ca 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt @@ -358,6 +358,24 @@ internal fun MainActivity.readCompleteMetadataFromUri( JSONObject(coreBackend.readFileMetadata(path, name)).takeUnless { it.has("error") } } +/** Keep the resolved audio descriptor alive for quality and artwork reads. + * Only nonseekable providers need a staged audio copy. */ +internal fun MainActivity.scanCueFromUri( + cuePath: String, + audioUri: Uri, + audioName: String, + virtualPrefix: String, + modTime: Long, + cacheKey: String, +): String { + val result = readMetadataFromUri(audioUri, audioName) { path, name -> + JSONObject().put("rows", coreBackend.scanCueForLibraryWithResolvedAudio( + cuePath, path, name, virtualPrefix, modTime, cacheKey, + )) + } + return result?.getString("rows") ?: error("Could not read resolved CUE audio") +} + /** Seekable remote documents need only tag reads, not a full audio download. */ internal fun MainActivity.extractCoverFromUri(uri: Uri, outputPath: String) { val name = buildUriDisplayName(uri) 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 b1d4d78f..53780666 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt @@ -1089,7 +1089,6 @@ internal fun MainActivity.scanSafTree( updateSafScanProgress { it.currentFile = cueName } var tempCuePath: String? = null - var tempAudioPath: String? = null try { tempCuePath = copyUriToTemp(cueDoc.uri, ".cue", cueName) if (tempCuePath == null) { @@ -1121,36 +1120,19 @@ internal fun MainActivity.scanSafTree( continue } - val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } - val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) - val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null val audioLastModified = try { audioDoc.lastModified() } catch (_: Exception) { cueDoc.lastModified() } val coverCacheKey = buildLibraryCoverCacheKey( audioDoc.uri.toString(), audioLastModified, ) - tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt) - if (tempAudioPath == null) { - reportLibraryScanError(audioDoc.uri.toString(), "Read CUE audio", "Failed to copy audio for $cueName") - errors++ - scanned++ - continue - } - - val renamedAudio = File(tempDir, audioName) - val tempAudioFile = File(tempAudioPath) - if (renamedAudio.absolutePath != tempAudioFile.absolutePath) { - tempAudioFile.renameTo(renamedAudio) - tempAudioPath = renamedAudio.absolutePath - } - val cueLastModified = cue.lastModified - val cueResultsJson = coreBackend.scanCueForLibrary( + val cueResultsJson = scanCueFromUri( tempCuePath, - tempDir, + audioDoc.uri, + audioName, cueDoc.uri.toString(), cueLastModified, coverCacheKey, @@ -1168,7 +1150,6 @@ internal fun MainActivity.scanSafTree( reportLibraryScanError(cueUri, "Scan CUE", e.message ?: e.javaClass.simpleName) } finally { try { tempCuePath?.let { File(it).delete() } } catch (_: Exception) {} - try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {} } scanned++ @@ -1521,7 +1502,6 @@ internal fun MainActivity.scanSafTreeIncremental( updateSafScanProgress { it.currentFile = cueName } var tempCuePath: String? = null - var tempAudioPath: String? = null try { tempCuePath = copyUriToTemp(cueDoc.uri, ".cue", cueName) if (tempCuePath == null) { @@ -1549,34 +1529,17 @@ internal fun MainActivity.scanSafTreeIncremental( cueReferencedAudioUris.add(audioDoc.uri.toString()) - val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } - val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) - val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null val audioLastModified = try { audioDoc.lastModified() } catch (_: Exception) { cueLastModified } val coverCacheKey = buildLibraryCoverCacheKey( audioDoc.uri.toString(), audioLastModified, ) - tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt) - if (tempAudioPath == null) { - reportLibraryScanError(audioDoc.uri.toString(), "Read CUE audio", "Failed to copy audio for $cueName") - errors++ - scanned++ - continue - } - - val renamedAudio = File(tempDir, audioName) - val tempAudioFile = File(tempAudioPath) - if (renamedAudio.absolutePath != tempAudioFile.absolutePath) { - tempAudioFile.renameTo(renamedAudio) - tempAudioPath = renamedAudio.absolutePath - } - - val cueResultsJson = coreBackend.scanCueForLibrary( + val cueResultsJson = scanCueFromUri( tempCuePath, - tempDir, + audioDoc.uri, + audioName, cueDoc.uri.toString(), cueLastModified, coverCacheKey, @@ -1597,7 +1560,6 @@ internal fun MainActivity.scanSafTreeIncremental( reportLibraryScanError(cueDoc.uri.toString(), "Scan CUE", e.message ?: e.javaClass.simpleName) } finally { try { tempCuePath?.let { File(it).delete() } } catch (_: Exception) {} - try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {} } scanned++ 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 8290aa41..8fd7f0ac 100644 --- a/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt +++ b/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt @@ -363,6 +363,32 @@ internal object RustCoreBackend : CoreBackend { } } + override fun scanCueForLibraryWithResolvedAudio(path: String, audioPath: String, audioName: String, virtualPrefix: String, modTime: Long, cacheKey: String): String { + val cue = File(path).canonicalFile + fun scan(descriptor: Int): String { + // Artwork remains optional, as in the filesystem CUE scanner. + // A cover extraction failure must not discard valid CUE rows. + val coverPath = try { + JSONObject(readAudioMetadata("/proc/self/fd/$descriptor", audioName, cacheKey)).optString("coverPath", "") + } catch (error: Exception) { + if (error is java.util.concurrent.CancellationException) throw error + android.util.Log.w("SpotiFLAC", "Could not read CUE artwork", error) + "" + } + return withLibraryDirectories(listOf(requireNotNull(cue.parent))) { + it.scanCueFileForLibraryFromDescriptor( + cue.path, descriptor, audioName, virtualPrefix, modTime, + coverPath, java.time.Instant.now().toString(), null, + ) + } + } + val descriptor = audioPath.removePrefix("/proc/self/fd/").toIntOrNull() + if (audioPath.startsWith("/proc/self/fd/") && descriptor != null) return scan(descriptor) + return android.os.ParcelFileDescriptor.open(File(audioPath), android.os.ParcelFileDescriptor.MODE_READ_ONLY).use { + scan(it.fd) + } + } + override fun editFileMetadata(path: String, metadataJson: String): String { val cover = if (metadataJson.trim() == "null") "" else JSONObject(metadataJson).optString("cover_path", "") return withMediaFiles(listOf(path, cover)) { diff --git a/rust_backend/crates/extensions/src/backend/library.rs b/rust_backend/crates/extensions/src/backend/library.rs index 56cbe4cd..9310fc80 100644 --- a/rust_backend/crates/extensions/src/backend/library.rs +++ b/rust_backend/crates/extensions/src/backend/library.rs @@ -297,6 +297,42 @@ impl Backend { ) } + #[allow(clippy::too_many_arguments)] + pub fn scan_cue_file_for_library_with_metadata( + &self, + path: &str, + audio_hint: &str, + metadata_json: &str, + virtual_prefix: &str, + mod_time: i64, + scan_time: &str, + check: &dyn Fn() -> Result<(), String>, + ) -> Result { + let _operation = self.enter()?; + let check = || self.check().and_then(|()| check()); + check()?; + let metadata: Value = serde_json::from_str(metadata_json).map_err(|e| e.to_string())?; + if !metadata.is_object() || audio_hint.trim().is_empty() { + return Err("invalid resolved CUE audio metadata".into()); + } + let sheet = self.parse_cue_file(path, &check)?; + let mod_time = if mod_time > 0 { + mod_time + } else { + modified(&self.environment().native_files()?.resolve_legacy(path)?) + }; + cue_library_rows( + path, + &sheet, + virtual_prefix, + mod_time, + scan_time, + &library_extension(audio_hint, ""), + &metadata, + &check, + ) + } + #[allow(clippy::too_many_arguments)] fn scan_cue_sheet( &self, @@ -351,50 +387,77 @@ impl Backend { } else { modified(&files.resolve_legacy(path)?) }; - let prefix = prefer(virtual_prefix, path); - let mut results = Vec::with_capacity(sheet.tracks.len()); - for (index, track) in sheet.tracks.iter().enumerate() { - check()?; - let end = next_start(sheet, index).or_else(|| (duration > 0.0).then_some(duration)); - let mut result = json!({ - "id":library_id(&format!("{prefix}#track{}", track.number)), - "filePath":format!("{prefix}#track{:02}", track.number), - "trackName":if track.title.is_empty() { format!("Track {:02}", track.number) } else { track.title.clone() }, - "artistName":prefer(prefer(&track.performer, &sheet.performer), "Unknown Artist"), - "albumName":prefer(&sheet.title, "Unknown Album"), "scannedAt":scan_time, - "hasLyrics":false,"totalTracks":sheet.tracks.len(),"discNumber":1,"totalDiscs":1, - "format":format!("cue+{format}"), - }); - for (key, value) in [ - ("fileModTime", mod_time), - ("trackNumber", track.number), - ( - "duration", - end.map_or(0, |end| (end - track.start_time) as i64), - ), - ("bitDepth", depth), - ("sampleRate", rate), - ] { - if value != 0 { - result[key] = value.into(); - } - } - for (key, value) in [ - ("albumArtist", sheet.performer.as_str()), - ("coverPath", &cover), - ("isrc", &track.isrc), - ("releaseDate", &sheet.date), - ("genre", &sheet.genre), - ("composer", prefer(&track.composer, &sheet.composer)), - ] { - optional(&mut result, key, value); - } - results.push(result); - } - Ok(results.into()) + cue_library_rows( + path, + sheet, + virtual_prefix, + mod_time, + scan_time, + &format, + &json!({"bitDepth":depth,"sampleRate":rate,"duration":duration,"coverPath":cover}), + check, + ) } } +#[allow(clippy::too_many_arguments)] +fn cue_library_rows( + path: &str, + sheet: &CueSheet, + virtual_prefix: &str, + mod_time: i64, + scan_time: &str, + format: &str, + metadata: &Value, + check: &dyn Fn() -> Result<(), String>, +) -> Result { + let prefix = prefer(virtual_prefix, path); + let depth = metadata["bitDepth"].as_i64().unwrap_or_default(); + let rate = metadata["sampleRate"].as_i64().unwrap_or_default(); + let duration = metadata["duration"].as_f64().unwrap_or_default(); + let cover = metadata["coverPath"].as_str().unwrap_or_default(); + let mut results = Vec::with_capacity(sheet.tracks.len()); + for (index, track) in sheet.tracks.iter().enumerate() { + check()?; + let end = next_start(sheet, index).or_else(|| (duration > 0.0).then_some(duration)); + let mut result = json!({ + "id":library_id(&format!("{prefix}#track{}", track.number)), + "filePath":format!("{prefix}#track{:02}", track.number), + "trackName":if track.title.is_empty() { format!("Track {:02}", track.number) } else { track.title.clone() }, + "artistName":prefer(prefer(&track.performer, &sheet.performer), "Unknown Artist"), + "albumName":prefer(&sheet.title, "Unknown Album"), "scannedAt":scan_time, + "hasLyrics":false,"totalTracks":sheet.tracks.len(),"discNumber":1,"totalDiscs":1, + "format":format!("cue+{format}"), + }); + for (key, value) in [ + ("fileModTime", mod_time), + ("trackNumber", track.number), + ( + "duration", + end.map_or(0, |end| (end - track.start_time) as i64), + ), + ("bitDepth", depth), + ("sampleRate", rate), + ] { + if value != 0 { + result[key] = value.into(); + } + } + for (key, value) in [ + ("albumArtist", sheet.performer.as_str()), + ("coverPath", cover), + ("isrc", &track.isrc), + ("releaseDate", &sheet.date), + ("genre", &sheet.genre), + ("composer", prefer(&track.composer, &sheet.composer)), + ] { + optional(&mut result, key, value); + } + results.push(result); + } + Ok(results.into()) +} + fn scan_time() -> String { Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) } @@ -544,4 +607,103 @@ mod tests { assert_eq!(error, "cancelled"); assert_eq!(checks.get(), 2); } + + #[test] + fn resolved_cue_metadata_scans_without_materializing_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 prefix = "content://example.documents/document/album.cue"; + let metadata = json!({ + "bitDepth": 24, "sampleRate": 96000, "duration": 367.75, + "coverPath": "cached-cover.jpg", + }); + let result = backend + .scan_cue_file_for_library_with_metadata( + path.to_str().unwrap(), + "album.flac", + &metadata.to_string(), + prefix, + 123, + "scan-time", + &|| Ok(()), + ) + .unwrap(); + assert_eq!(result.as_array().unwrap().len(), 2); + assert_eq!(result[0]["filePath"], format!("{prefix}#track01")); + assert_eq!(result[0]["duration"], 180); + assert_eq!(result[1]["duration"], 185); + assert_eq!(result[1]["artistName"], "Album Artist"); + assert_eq!(result[1]["bitDepth"], 24); + assert_eq!(result[1]["sampleRate"], 96000); + assert_eq!(result[1]["format"], "cue+flac"); + assert_eq!(result[1]["coverPath"], "cached-cover.jpg"); + assert_eq!(result[1]["fileModTime"], 123); + assert_eq!(result[1]["scannedAt"], "scan-time"); + assert!(!data.join("album.flac").exists()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), CUE); + assert_eq!( + backend + .scan_cue_file_for_library_with_metadata( + path.to_str().unwrap(), + "album.flac", + "[]", + prefix, + 123, + "scan-time", + &|| Ok(()), + ) + .unwrap_err(), + "invalid resolved CUE audio metadata" + ); + assert_eq!( + backend + .scan_cue_file_for_library_with_metadata( + path.to_str().unwrap(), + "album.flac", + &metadata.to_string(), + prefix, + 123, + "scan-time", + &|| Err("cancelled".into()), + ) + .unwrap_err(), + "cancelled" + ); + } + + #[test] + fn cue_library_mapping_checks_cancellation_per_track() { + let sheet = cue::parse(&mut Cursor::new(CUE), &|| Ok(())).unwrap(); + let checks = Cell::new(0); + let error = cue_library_rows( + "album.cue", + &sheet, + "opaque-cue", + 123, + "scan-time", + "flac", + &json!({}), + &|| { + 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 a9cbb32d..90b39b8b 100644 --- a/rust_backend/crates/mobile/src/tags.rs +++ b/rust_backend/crates/mobile/src/tags.rs @@ -68,6 +68,64 @@ impl ExtensionManager { .map_err(ExtensionManagerError::Operation) } + #[allow(clippy::too_many_arguments)] + pub fn scan_cue_file_for_library_from_descriptor( + &self, + path: String, + descriptor: i32, + hint: String, + virtual_prefix: String, + mod_time: i64, + cover_path: String, + scan_time: String, + lease: Option>, + ) -> Result { + let check = || check_lease(lease.as_deref()); + check().map_err(ExtensionManagerError::Operation)?; + if descriptor < 0 { + return Err(ExtensionManagerError::Operation( + "invalid audio descriptor".into(), + )); + } + let directory = if cfg!(any(target_os = "android", target_os = "linux")) { + "/proc/self/fd" + } else { + "/dev/fd" + }; + let audio_path = format!("{directory}/{descriptor}"); + let mut file = open_audio_file(&audio_path).map_err(ExtensionManagerError::Operation)?; + let format = tags::library_extension(&audio_path, &hint); + let mut metadata = serde_json::json!({"coverPath":cover_path}); + if format == "flac" { + if let Ok(quality) = spotiflac_core::media::probe_quality(&mut file, &check) { + metadata["bitDepth"] = quality.bit_depth.into(); + metadata["sampleRate"] = quality.sample_rate.into(); + if quality.sample_rate > 0 && quality.total_samples > 0 { + metadata["duration"] = + (quality.total_samples as f64 / quality.sample_rate as f64).into(); + } + } + } else if format == "mp3" + && let Ok(value) = tags::read_file_metadata(&mut file, &audio_path, &hint, &check) + { + metadata["sampleRate"] = value["sample_rate"].clone(); + metadata["duration"] = value["duration"].clone(); + } + check().map_err(ExtensionManagerError::Operation)?; + self.inner + .scan_cue_file_for_library_with_metadata( + &path, + &hint, + &metadata.to_string(), + &virtual_prefix, + mod_time, + &scan_time, + &check, + ) + .map(|value| value.to_string()) + .map_err(ExtensionManagerError::Operation) + } + #[allow(clippy::too_many_arguments)] pub fn scan_cue_file_for_library( &self,