mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-02 22:26:52 +02:00
fix(storage): retain document access for library metadata and covers
This commit is contained in:
1 parent
cda9868a1f
commit
c5789df87d
5 files changed
+298
-14
No files matched your search
@@ -36,6 +36,76 @@ class NativeDownloadContainerTest {
|
||||
backendRoot.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectedLibraryFilesCanBeEditedReenrichedAndExportArtworkOutsideDownloadRoot() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val directory = File(context.filesDir, "selected-library-${System.nanoTime()}").apply { mkdirs() }
|
||||
try {
|
||||
val input = File(directory, "selected.flac")
|
||||
val fixture = NativeDownloadFinalizer.runFFmpegArguments(arrayOf(
|
||||
"-v", "error", "-f", "lavfi", "-i", "sine=frequency=997:sample_rate=48000",
|
||||
"-t", "0.5", "-c:a", "flac", input.path,
|
||||
))
|
||||
assertTrue(fixture.second, fixture.first)
|
||||
val artwork = File(directory, "selected.png")
|
||||
val bitmap = android.graphics.Bitmap.createBitmap(4, 4, android.graphics.Bitmap.Config.ARGB_8888)
|
||||
bitmap.eraseColor(android.graphics.Color.BLUE)
|
||||
artwork.outputStream().use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) }
|
||||
bitmap.recycle()
|
||||
val edit = JSONObject(backend.editFileMetadata(input.path, JSONObject()
|
||||
.put("title", "Edited title").put("cover_path", artwork.path)
|
||||
.put("replaygain_track_gain", "-7.00 dB").put("replaygain_track_peak", "0.750000").toString()))
|
||||
assertTrue(edit.toString(), edit.getBoolean("success"))
|
||||
val request = JSONObject().put("file_path", input.path).put("search_online", false)
|
||||
.put("track_name", "Enriched title").put("artist_name", "Example artist")
|
||||
.put("update_fields", org.json.JSONArray(listOf("track_name")))
|
||||
val enriched = JSONObject(backend.reEnrichFile(request.toString()))
|
||||
assertTrue(enriched.toString(), enriched.getBoolean("success"))
|
||||
val metadata = JSONObject(backend.readFileMetadata(input.path, input.name))
|
||||
assertEquals("Enriched title", metadata.getString("title"))
|
||||
assertEquals("-7.00 dB", metadata.getString("replaygain_track_gain"))
|
||||
val output = File(directory, "export.png")
|
||||
backend.extractCoverToFile(input.path, output.path)
|
||||
assertTrue(output.readBytes().contentEquals(artwork.readBytes()))
|
||||
val library = JSONObject(backend.readAudioMetadata(input.path, input.name, ""))
|
||||
assertEquals("Enriched title", library.getString("trackName"))
|
||||
} finally { directory.deleteRecursively() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun libraryMetadataReadsAnOpenDescriptorWithoutGrantingItsFilesystemPath() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val artwork = File(backendRoot, "selected-cover.png")
|
||||
val bitmap = android.graphics.Bitmap.createBitmap(4, 4, android.graphics.Bitmap.Config.ARGB_8888)
|
||||
bitmap.eraseColor(android.graphics.Color.GREEN)
|
||||
artwork.outputStream().use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) }
|
||||
bitmap.recycle()
|
||||
val input = File(backendRoot, "selected-track.flac")
|
||||
val fixture = NativeDownloadFinalizer.runFFmpegArguments(arrayOf(
|
||||
"-v", "error", "-f", "lavfi", "-i", "sine=frequency=997:sample_rate=48000",
|
||||
"-i", artwork.path, "-t", "0.5", "-map", "0:a", "-map", "1:v",
|
||||
"-c:a", "flac", "-c:v", "copy", "-disposition:v", "attached_pic",
|
||||
"-metadata", "title=Document track", input.path,
|
||||
))
|
||||
assertTrue(fixture.second, fixture.first)
|
||||
val covers = File(context.cacheDir, "descriptor-covers-${System.nanoTime()}").apply { mkdirs() }
|
||||
try {
|
||||
backend.setLibraryCoverCacheDirectory(covers.path)
|
||||
android.os.ParcelFileDescriptor.open(input, android.os.ParcelFileDescriptor.MODE_READ_ONLY).use { descriptor ->
|
||||
val result = JSONObject(backend.readAudioMetadata(
|
||||
"/proc/self/fd/${descriptor.fd}", input.name, "content://example/tree/music/selected-track.flac",
|
||||
))
|
||||
assertEquals("Document track", result.getString("trackName"))
|
||||
assertEquals(48000, result.getInt("sampleRate"))
|
||||
assertFalse(result.optBoolean("metadataFromFilename"))
|
||||
val cachedCover = File(result.getString("coverPath"))
|
||||
assertEquals(covers.canonicalPath, cachedCover.parentFile!!.canonicalPath)
|
||||
assertTrue(cachedCover.readBytes().contentEquals(artwork.readBytes()))
|
||||
assertTrue(descriptor.statSize > 0)
|
||||
}
|
||||
} finally { covers.deleteRecursively() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativeFormatsPreserveTagsLyricsAndReplayGain() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
@@ -58,6 +58,7 @@ internal object RustCoreBackend : CoreBackend {
|
||||
private var runtimeState: Pair<String, String>? = null
|
||||
private val directoryScopes = mutableMapOf<String, AutoCloseable>()
|
||||
private var libraryCoverScope: AutoCloseable? = null
|
||||
@Volatile private var libraryCoverDirectory: File? = null
|
||||
|
||||
@Synchronized
|
||||
fun initialize(context: Context): RustCoreBackend {
|
||||
@@ -209,6 +210,7 @@ internal object RustCoreBackend : CoreBackend {
|
||||
directoryScopes.clear()
|
||||
libraryCoverScope?.close()
|
||||
libraryCoverScope = null
|
||||
libraryCoverDirectory = null
|
||||
identity = null
|
||||
runtimeState = null
|
||||
}
|
||||
@@ -221,8 +223,53 @@ internal object RustCoreBackend : CoreBackend {
|
||||
override fun checkHiResAuthenticity(path: String, optionsJson: String): String =
|
||||
com.spotiflac.backend.checkHiresAuthenticity(path, optionsJson, null)
|
||||
|
||||
override fun readAudioMetadata(path: String, hint: String, cacheKey: String): String =
|
||||
owner().readAudioMetadata(File(path).canonicalPath, hint, cacheKey, null)
|
||||
override fun readAudioMetadata(path: String, hint: String, cacheKey: String): String {
|
||||
val descriptor = path.removePrefix("/proc/self/fd/").toIntOrNull()
|
||||
if (path.startsWith("/proc/self/fd/") && descriptor != null) {
|
||||
val directory = libraryCoverDirectory
|
||||
// Never key artwork by the descriptor number: Android reuses it.
|
||||
val key = cacheKey.trim().takeIf { it.isNotEmpty() }
|
||||
val hash = key?.codePoints()?.reduce(5381) { hash, codePoint -> hash * 33 + codePoint }
|
||||
?.toUInt()?.toString(16)
|
||||
val cached = if (directory != null && hash != null) {
|
||||
listOf(File(directory, "cover_$hash.jpg"), File(directory, "cover_$hash.png"))
|
||||
.firstOrNull { it.isFile }
|
||||
} else null
|
||||
val result = com.spotiflac.backend.readLibraryMetadataFromDescriptor(
|
||||
descriptor, hint, java.time.Instant.now().toString(),
|
||||
directory != null && hash != null && cached == null, null,
|
||||
)
|
||||
val metadata = JSONObject(result.metadataJson)
|
||||
var cover = cached
|
||||
if (directory != null && hash != null && result.coverBytes.isNotEmpty()) {
|
||||
try {
|
||||
val extension = if (result.coverMime.contains("png")) "png" else "jpg"
|
||||
val output = File(directory, "cover_$hash.$extension")
|
||||
val staged = File.createTempFile("cover_", ".tmp", directory)
|
||||
try {
|
||||
staged.writeBytes(result.coverBytes)
|
||||
check(staged.renameTo(output)) { "Could not publish library artwork" }
|
||||
cover = output
|
||||
} finally { staged.delete() }
|
||||
} catch (error: Exception) {
|
||||
android.util.Log.w("SpotiFLAC", "Could not cache document artwork", error)
|
||||
}
|
||||
}
|
||||
cover?.let { metadata.put("coverPath", it.path) }
|
||||
return metadata.toString()
|
||||
}
|
||||
return withMediaFiles(listOf(path)) {
|
||||
it.readAudioMetadata(File(path).canonicalPath, hint, cacheKey, null)
|
||||
}
|
||||
}
|
||||
|
||||
// Native callers have already selected/resolved these files through app or
|
||||
// SAF access. Retain only their parent directories for this operation.
|
||||
private fun <T> withMediaFiles(paths: List<String>, block: (ExtensionManager) -> T): T =
|
||||
withLibraryDirectories(paths.filter { it.isNotBlank() }.map { path ->
|
||||
require(File(path).isAbsolute) { "Media paths must be absolute" }
|
||||
File(path).absoluteFile.parent!!
|
||||
}.distinct(), block)
|
||||
|
||||
private fun <T> withLibraryDirectories(paths: List<String>, block: (ExtensionManager) -> T): T {
|
||||
val (current, scope) = synchronized(this) {
|
||||
@@ -247,6 +294,7 @@ internal object RustCoreBackend : CoreBackend {
|
||||
catch (error: Exception) { next?.close(); throw error }
|
||||
libraryCoverScope?.close()
|
||||
libraryCoverScope = next
|
||||
libraryCoverDirectory = directory
|
||||
}
|
||||
|
||||
override fun scanLibraryFolder(folder: String): String = withLibraryDirectories(listOf(folder)) {
|
||||
@@ -314,8 +362,12 @@ internal object RustCoreBackend : CoreBackend {
|
||||
}
|
||||
}
|
||||
|
||||
override fun editFileMetadata(path: String, metadataJson: String): String =
|
||||
owner().editFileMetadata(File(path).canonicalPath, metadataJson, null)
|
||||
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)) {
|
||||
it.editFileMetadata(File(path).canonicalPath, metadataJson, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mediaPath(path: String): String = if (path.isEmpty()) "" else File(path).canonicalPath
|
||||
|
||||
@@ -325,18 +377,22 @@ internal object RustCoreBackend : CoreBackend {
|
||||
if (!request.optBoolean("preview_only", false) && path?.startsWith("/") == true) {
|
||||
request.put("file_path", mediaPath(path))
|
||||
}
|
||||
return owner().reenrichFile(request.toString(), null)
|
||||
return if (!request.optBoolean("preview_only", false) && !path.isNullOrBlank()) {
|
||||
withMediaFiles(listOf(path)) { it.reenrichFile(request.toString(), null) }
|
||||
} else owner().reenrichFile(request.toString(), null)
|
||||
}
|
||||
|
||||
override fun rewriteSplitArtistTags(path: String, artist: String, albumArtist: String): String =
|
||||
owner().rewriteSplitArtistTags(mediaPath(path), artist, albumArtist, null)
|
||||
withMediaFiles(listOf(path)) { it.rewriteSplitArtistTags(mediaPath(path), artist, albumArtist, null) }
|
||||
|
||||
override fun extractCoverToFile(audioPath: String, outputPath: String) {
|
||||
owner().extractCoverToFile(mediaPath(audioPath), mediaPath(outputPath), null)
|
||||
withMediaFiles(listOf(audioPath, outputPath)) {
|
||||
it.extractCoverToFile(mediaPath(audioPath), mediaPath(outputPath), null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun writeM4aFreeformTags(path: String, metadataJson: String): String =
|
||||
owner().writeM4aFreeformTags(mediaPath(path), metadataJson, null)
|
||||
withMediaFiles(listOf(path)) { it.writeM4aFreeformTags(mediaPath(path), metadataJson, null) }
|
||||
|
||||
override fun ensureAc4Config(path: String, reference: String): String =
|
||||
owner().ensureAc4Config(mediaPath(path), mediaPath(reference), null)
|
||||
|
||||
@@ -318,6 +318,14 @@ final class RustCoreBackend: CoreBackend {
|
||||
return try block(current)
|
||||
}
|
||||
|
||||
private func withMediaFiles<T>(_ paths: [String], block: (ExtensionManager) throws -> T) throws -> T {
|
||||
let directories = try paths.filter { !$0.isEmpty }.map { path -> String in
|
||||
guard path.hasPrefix("/") else { throw failure("Media paths must be absolute") }
|
||||
return URL(fileURLWithPath: path).deletingLastPathComponent().path
|
||||
}
|
||||
return try withLibraryDirectories(Array(Set(directories)), block: block)
|
||||
}
|
||||
|
||||
func setLibraryCoverCacheDirectory(path: String) throws {
|
||||
ownerLock.lock()
|
||||
defer { ownerLock.unlock() }
|
||||
@@ -489,7 +497,10 @@ final class RustCoreBackend: CoreBackend {
|
||||
}
|
||||
|
||||
func editFileMetadata(path: String, metadataJson: String) throws -> String {
|
||||
try owner().editFileMetadata(path: URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL.path, metadataJson: metadataJson, lease: nil)
|
||||
let fields = try JSONSerialization.jsonObject(with: Data(metadataJson.utf8), options: [.fragmentsAllowed]) as? [String: Any]
|
||||
return try withMediaFiles([path, fields?["cover_path"] as? String ?? ""]) {
|
||||
try $0.editFileMetadata(path: mediaPath(path), metadataJson: metadataJson, lease: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func mediaPath(_ path: String) -> String {
|
||||
@@ -505,19 +516,29 @@ final class RustCoreBackend: CoreBackend {
|
||||
request["file_path"] = mediaPath(path)
|
||||
}
|
||||
let data = try JSONSerialization.data(withJSONObject: request)
|
||||
return try owner().reenrichFile(requestJson: String(decoding: data, as: UTF8.self), lease: nil)
|
||||
let resolved = String(decoding: data, as: UTF8.self)
|
||||
if request["preview_only"] as? Bool != true, let path = request["file_path"] as? String, !path.isEmpty {
|
||||
return try withMediaFiles([path]) { try $0.reenrichFile(requestJson: resolved, lease: nil) }
|
||||
}
|
||||
return try owner().reenrichFile(requestJson: resolved, lease: nil)
|
||||
}
|
||||
|
||||
func rewriteSplitArtistTags(path: String, artist: String, albumArtist: String) throws -> String {
|
||||
try owner().rewriteSplitArtistTags(path: mediaPath(path), artist: artist, albumArtist: albumArtist, lease: nil)
|
||||
try withMediaFiles([path]) {
|
||||
try $0.rewriteSplitArtistTags(path: mediaPath(path), artist: artist, albumArtist: albumArtist, lease: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func extractCoverToFile(audioPath: String, outputPath: String) throws {
|
||||
try owner().extractCoverToFile(audioPath: mediaPath(audioPath), outputPath: mediaPath(outputPath), lease: nil)
|
||||
try withMediaFiles([audioPath, outputPath]) {
|
||||
try $0.extractCoverToFile(audioPath: mediaPath(audioPath), outputPath: mediaPath(outputPath), lease: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func writeM4aFreeformTags(path: String, metadataJson: String) throws -> String {
|
||||
try owner().writeM4aFreeformTags(path: mediaPath(path), metadataJson: metadataJson, lease: nil)
|
||||
try withMediaFiles([path]) {
|
||||
try $0.writeM4aFreeformTags(path: mediaPath(path), metadataJson: metadataJson, lease: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureAc4Config(path: String, reference: String) throws -> String {
|
||||
@@ -709,7 +730,11 @@ final class RustCoreBackend: CoreBackend {
|
||||
let albumArtist = (try? current.fetchMusicBrainzAlbumArtistByIsrc(isrc: string("isrc"), albumName: string("album_name"), lease: nil)) ?? ""
|
||||
return String(decoding: try JSONSerialization.data(withJSONObject: ["genre": genre, "album_artist": albumArtist]), as: UTF8.self)
|
||||
case "getTrackCacheSize": return Int(try current.getTrackCacheSize())
|
||||
case "readAudioMetadata": return try current.readAudioMetadata(path: string("file_path"), hint: "", cacheKey: "", lease: nil)
|
||||
case "readAudioMetadata":
|
||||
let path = string("file_path")
|
||||
return try withMediaFiles([path]) {
|
||||
try $0.readAudioMetadata(path: mediaPath(path), hint: "", cacheKey: "", lease: nil)
|
||||
}
|
||||
case "clearTrackCache": try current.clearTrackIdCache(); return nil
|
||||
case "setMetadataLanguage": try current.setMetadataLanguage(tag: string("tag")); return nil
|
||||
case "downloadByStrategy", "downloadWithExtensions":
|
||||
|
||||
@@ -6,6 +6,7 @@ mod ffmpeg;
|
||||
mod filename;
|
||||
mod hires;
|
||||
mod index;
|
||||
mod library_metadata;
|
||||
mod logging;
|
||||
mod lyrics;
|
||||
mod manager;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Metadata from a descriptor already opened by the native document provider.
|
||||
//! This entry point is not exposed to extensions and does not grant filesystem roots.
|
||||
|
||||
use crate::cancellation::RequestLease;
|
||||
use crate::tags::{AudioTagsError, check_lease, open_audio_file};
|
||||
use spotiflac_core::{cover, tags};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(uniffi::Record)]
|
||||
pub struct DescriptorLibraryMetadata {
|
||||
pub metadata_json: String,
|
||||
pub cover_bytes: Vec<u8>,
|
||||
pub cover_mime: String,
|
||||
}
|
||||
|
||||
/// The caller keeps the descriptor open until this synchronous read returns.
|
||||
/// Reopening its descriptor path preserves SAF access instead of resolving a
|
||||
/// protected /storage or /mnt/media_rw path and copying the entire audio file.
|
||||
#[uniffi::export]
|
||||
pub fn read_library_metadata_from_descriptor(
|
||||
descriptor: i32,
|
||||
hint: String,
|
||||
scan_time: String,
|
||||
include_cover: bool,
|
||||
lease: Option<Arc<RequestLease>>,
|
||||
) -> Result<DescriptorLibraryMetadata, AudioTagsError> {
|
||||
if descriptor < 0 {
|
||||
return Err("invalid audio descriptor".to_owned().into());
|
||||
}
|
||||
let check = || check_lease(lease.as_deref());
|
||||
check()?;
|
||||
let directory = if cfg!(any(target_os = "android", target_os = "linux")) {
|
||||
"/proc/self/fd"
|
||||
} else {
|
||||
"/dev/fd"
|
||||
};
|
||||
let path = format!("{directory}/{descriptor}");
|
||||
let mut file = open_audio_file(&path)?;
|
||||
let format = tags::library_extension(&path, &hint);
|
||||
let (metadata, mut artwork) = if include_cover && format == "flac" {
|
||||
tags::read_library_metadata_with_cover(&mut file, &path, &hint, &scan_time, 0, &check)?
|
||||
} else {
|
||||
let metadata = tags::read_library_metadata(&mut file, &path, &hint, &scan_time, 0, &check)?;
|
||||
let artwork = if include_cover {
|
||||
let format = if matches!(format.as_str(), "mp4" | "aac") {
|
||||
"m4a"
|
||||
} else {
|
||||
&format
|
||||
};
|
||||
tags::extract_cover(&mut file, format, &check).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(metadata, artwork)
|
||||
};
|
||||
if let Some(artwork) = artwork.as_mut()
|
||||
&& let Ok(resized) = cover::library_thumbnail(&artwork.data, &check)
|
||||
{
|
||||
artwork.mime = if resized.starts_with(b"\x89PNG") {
|
||||
"image/png"
|
||||
} else if resized.starts_with(b"\xff\xd8") {
|
||||
"image/jpeg"
|
||||
} else {
|
||||
&artwork.mime
|
||||
}
|
||||
.into();
|
||||
artwork.data = resized.to_vec();
|
||||
}
|
||||
check()?;
|
||||
let (cover_bytes, cover_mime) = artwork.map_or_else(
|
||||
|| (Vec::new(), String::new()),
|
||||
|artwork| (artwork.data, artwork.mime),
|
||||
);
|
||||
Ok(DescriptorLibraryMetadata {
|
||||
metadata_json: metadata.to_string(),
|
||||
cover_bytes,
|
||||
cover_mime,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
#[test]
|
||||
fn descriptor_remains_open_and_audio_does_not_need_a_path_grant() {
|
||||
let path = std::env::temp_dir().join(format!("library-fd-{}.wav", std::process::id()));
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path)
|
||||
.unwrap();
|
||||
let mut wav = b"RIFF".to_vec();
|
||||
wav.extend(38_u32.to_le_bytes());
|
||||
wav.extend(b"WAVEfmt ");
|
||||
wav.extend(16_u32.to_le_bytes());
|
||||
wav.extend(1_u16.to_le_bytes());
|
||||
wav.extend(1_u16.to_le_bytes());
|
||||
wav.extend(44100_u32.to_le_bytes());
|
||||
wav.extend(88200_u32.to_le_bytes());
|
||||
wav.extend(2_u16.to_le_bytes());
|
||||
wav.extend(16_u16.to_le_bytes());
|
||||
wav.extend(b"data");
|
||||
wav.extend(2_u32.to_le_bytes());
|
||||
wav.extend(0_i16.to_le_bytes());
|
||||
file.write_all(&wav).unwrap();
|
||||
drop(file);
|
||||
let source = File::open(&path).unwrap();
|
||||
let read = read_library_metadata_from_descriptor(
|
||||
source.as_raw_fd(),
|
||||
"Track.wav".into(),
|
||||
"2026-09-30T00:00:00Z".into(),
|
||||
true,
|
||||
None,
|
||||
);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
let metadata = read.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&metadata.metadata_json).unwrap();
|
||||
assert_eq!(json["format"], "wav");
|
||||
assert_eq!(json["sampleRate"], 44100);
|
||||
assert_eq!(json["bitDepth"], 16);
|
||||
assert!(metadata.cover_bytes.is_empty());
|
||||
assert!(source.metadata().unwrap().is_file());
|
||||
assert!(
|
||||
read_library_metadata_from_descriptor(-1, "x.flac".into(), "".into(), false, None)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user