mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
perf(library): scan SAF CUE audio through live descriptors
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user