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

@@ -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);
}
}
+14
View File
@@ -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,