diff --git a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/NativeDownloadContainerTest.kt b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/NativeDownloadContainerTest.kt index 0cb845b7..be7d0629 100644 --- a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/NativeDownloadContainerTest.kt +++ b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/NativeDownloadContainerTest.kt @@ -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 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 d0d2ca86..d422387f 100644 --- a/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt +++ b/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt @@ -58,6 +58,7 @@ internal object RustCoreBackend : CoreBackend { private var runtimeState: Pair? = null private val directoryScopes = mutableMapOf() 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 withMediaFiles(paths: List, 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 withLibraryDirectories(paths: List, 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) diff --git a/ios/Runner/CoreBackend.swift b/ios/Runner/CoreBackend.swift index 6401180e..ef070d7a 100644 --- a/ios/Runner/CoreBackend.swift +++ b/ios/Runner/CoreBackend.swift @@ -318,6 +318,14 @@ final class RustCoreBackend: CoreBackend { return try block(current) } + private func withMediaFiles(_ 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": diff --git a/rust_backend/crates/mobile/src/lib.rs b/rust_backend/crates/mobile/src/lib.rs index 5b08e4f9..44c3ee5a 100644 --- a/rust_backend/crates/mobile/src/lib.rs +++ b/rust_backend/crates/mobile/src/lib.rs @@ -6,6 +6,7 @@ mod ffmpeg; mod filename; mod hires; mod index; +mod library_metadata; mod logging; mod lyrics; mod manager; diff --git a/rust_backend/crates/mobile/src/library_metadata.rs b/rust_backend/crates/mobile/src/library_metadata.rs new file mode 100644 index 00000000..336c48ca --- /dev/null +++ b/rust_backend/crates/mobile/src/library_metadata.rs @@ -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, + 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>, +) -> Result { + 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() + ); + } +}