perf(library): scan SAF CUE audio through live descriptors

This commit is contained in:
zarzet committed 2026-10-04 01:41:35 +07:00
1 parent 5019851903
commit c69f2cef9f
8 files changed
+396 -85

No files matched your search

@@ -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<String, Any>())
root.deleteRecursively()
}
}
}
@@ -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
@@ -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
@@ -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)
@@ -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++
@@ -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)) {
@@ -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<Value, String> {
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<Value, String> {
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);
}
}
+58
View File
@@ -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<Arc<RequestLease>>,
) -> Result<String, ExtensionManagerError> {
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,