From 0779c0d076608af10d7e6deef00abf93912f6057 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 29 Jun 2026 12:03:31 +0200 Subject: [PATCH] Handle SIGTERM, fix mp3 handler --- Cargo.lock | 71 +----- Cargo.toml | 3 +- devenv.nix | 3 +- src/main.rs | 24 +- src/music/db.rs | 13 +- src/music/encoder.rs | 31 +++ src/music/flac.rs | 444 ++++++++++++++++++++++++++++++++++ src/music/metadata.rs | 428 ++------------------------------ src/music/mod.rs | 4 + src/music/mp3.rs | 361 +++++++++++++++++++++++++++ src/music/parser.rs | 31 +++ src/origins/local/file_io.rs | 7 +- src/origins/local/mod.rs | 4 +- src/origins/local/snapshot.rs | 17 +- tests/file_io_test.rs | 8 +- 15 files changed, 941 insertions(+), 508 deletions(-) create mode 100644 src/music/encoder.rs create mode 100644 src/music/flac.rs create mode 100644 src/music/mp3.rs create mode 100644 src/music/parser.rs diff --git a/Cargo.lock b/Cargo.lock index 9b6c2b1..c1f4955 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -385,15 +385,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - [[package]] name = "borsh" version = "1.7.0" @@ -669,17 +660,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "ctrlc" -version = "3.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" -dependencies = [ - "dispatch2", - "nix 0.31.3", - "windows-sys 0.61.2", -] - [[package]] name = "ctutils" version = "0.4.2" @@ -775,18 +755,6 @@ dependencies = [ "ctutils", ] -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags 2.13.0", - "block2", - "libc", - "objc2", -] - [[package]] name = "displaydoc" version = "0.2.6" @@ -1532,7 +1500,6 @@ name = "musicfs" version = "0.1.0" dependencies = [ "clap", - "ctrlc", "fuser", "libc", "notify", @@ -1570,18 +1537,6 @@ dependencies = [ "memoffset", ] -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags 2.13.0", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "notify" version = "8.2.0" @@ -1675,21 +1630,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - [[package]] name = "once_cell" version = "1.21.4" @@ -2407,6 +2347,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -2946,6 +2896,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index dd7471a..d7e6c0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,6 @@ edition = "2024" [dependencies] clap = { version = "4.6.1", features = ["derive"] } -ctrlc = "3.5.2" fuser = "0.17.0" libc = "0.2.186" notify = "8.2.0" @@ -16,7 +15,7 @@ symphonia = { version = "0.5", default-features = false, features = [ twox-hash = "2.1.2" sea-orm = { version = "2.0.0-rc", features = [ "sqlx-postgres", "runtime-tokio", "macros" ] } -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } [dev-dependencies] tempfile = "3" diff --git a/devenv.nix b/devenv.nix index a3fec27..5dd44be 100644 --- a/devenv.nix +++ b/devenv.nix @@ -28,6 +28,7 @@ git just flac + ffmpeg opencode ]; @@ -36,7 +37,7 @@ after = [ "devenv:processes:postgres" ]; exec = '' cargo run -- \ - --source /home/fujin/Music/DDT \ + --source /home/fujin/Music \ --mountpoint /tmp/rust-fuse \ --database "postgresql://fujin@localhost/musicfs?host=$PGHOST" ''; diff --git a/src/main.rs b/src/main.rs index 2484f3b..030f7f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,6 @@ use clap::Parser; use musicfs::origins::local::watcher::FileWatcher; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; +use tokio::signal::unix::{SignalKind, signal}; #[derive(Parser, Debug)] #[command(version, about, long_about = None)] @@ -22,15 +20,6 @@ async fn main() { let args = Args::parse(); let mountpoint = args.mountpoint; - let running = Arc::new(AtomicBool::new(true)); - let r_clone = running.clone(); - - ctrlc::set_handler(move || { - println!("Ctrl+C received, shutting down"); - r_clone.store(false, Ordering::SeqCst); - }) - .expect("Error setting Ctrl+C handler"); - let db = sea_orm::Database::connect(&args.database).await.unwrap(); let fs = musicfs::origins::local::LocalOrigin::new(args.source.clone(), mountpoint.clone(), db) @@ -40,10 +29,15 @@ async fn main() { fs.watch(); let cfg = fuser::Config::default(); - let session = fuser::spawn_mount2(fs, &mountpoint, &cfg); + let session = + fuser::spawn_mount2(fs, &mountpoint, &cfg).expect("Failed to mount FUSE filesystem"); - while running.load(Ordering::SeqCst) { - std::thread::sleep(Duration::from_millis(100)); + let mut sigint = signal(SignalKind::interrupt()).expect("Failed to register SIGINT handler"); + let mut sigterm = signal(SignalKind::terminate()).expect("Failed to register SIGTERM handler"); + + tokio::select! { + _ = sigint.recv() => println!("Received SIGINT, shutting down"), + _ = sigterm.recv() => println!("Received SIGTERM, shutting down"), } println!("Unmounting"); diff --git a/src/music/db.rs b/src/music/db.rs index 6c1e108..64de4d1 100644 --- a/src/music/db.rs +++ b/src/music/db.rs @@ -63,7 +63,7 @@ pub async fn save_music_metadata( .map(|(pos, (hdr, (offset, len)))| pictures::ActiveModel { inode: Set(inode), position: Set(pos as i32), - block_header: Set(hdr.to_vec()), + block_header: Set(hdr.clone()), data_offset: Set(*offset as i64), data_length: Set(*len as i64), }), @@ -157,15 +157,8 @@ pub async fn restore_music_metadata_from_db( let mut sorted_pics = pictures_by_inode.remove(&inode).unwrap_or_default(); sorted_pics.sort_by_key(|p| p.position); - let picture_block_headers: Vec<[u8; 4]> = sorted_pics - .iter() - .map(|p| { - let mut hdr = [0u8; 4]; - let len = p.block_header.len().min(4); - hdr[..len].copy_from_slice(&p.block_header[..len]); - hdr - }) - .collect(); + let picture_block_headers: Vec> = + sorted_pics.iter().map(|p| p.block_header.clone()).collect(); let picture_data_ranges: Vec<(u64, u64)> = sorted_pics .iter() diff --git a/src/music/encoder.rs b/src/music/encoder.rs new file mode 100644 index 0000000..4cda48f --- /dev/null +++ b/src/music/encoder.rs @@ -0,0 +1,31 @@ +use std::path::Path; + +use crate::music::flac::FlacMusicMetadataEncoder; +use crate::music::metadata::MusicMetadata; +use crate::music::mp3::Mp3MusicMetadataEncoder; + +/// Bakes the current (possibly overridden) tag fields of a [`MusicMetadata`] +/// into its in-memory `header`. The original media file is never modified; +/// the rebuilt header is served on the fly at read time, ahead of the +/// externalized frames and the original audio. +pub trait MusicMetadataEncoder { + fn encode(&self, metadata: &mut MusicMetadata); +} + +/// Selects the right [`MusicMetadataEncoder`] for a given file. +pub struct MusicMetadataEncoderFactory; + +impl MusicMetadataEncoderFactory { + pub fn for_path(path: &Path) -> Option> { + match path + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("flac") => Some(Box::new(FlacMusicMetadataEncoder)), + Some("mp3") => Some(Box::new(Mp3MusicMetadataEncoder)), + _ => None, + } + } +} diff --git a/src/music/flac.rs b/src/music/flac.rs new file mode 100644 index 0000000..06c4421 --- /dev/null +++ b/src/music/flac.rs @@ -0,0 +1,444 @@ +use std::{ + fs, + io::{Cursor, Read, Seek, SeekFrom}, + path::Path, +}; + +use symphonia::core::{ + formats::FormatOptions, io::MediaSourceStream, meta::MetadataOptions, probe::Hint, +}; + +use crate::music::encoder::MusicMetadataEncoder; +use crate::music::metadata::{MusicMetadata, extract_standard_tags}; +use crate::music::parser::MusicMetadataParser; + +const BLOCK_PADDING: u8 = 1; +const BLOCK_VORBIS_COMMENT: u8 = 4; +const BLOCK_PICTURE: u8 = 6; +const BLOCK_LAST_FLAG: u8 = 0x80; +const BLOCK_TYPE_MASK: u8 = 0x7f; +const PADDING_SIZE: usize = 8192; + +/// FLAC parser. Owns all FLAC container parsing; returns `None` (never panics) +/// on a file it can't read — a corrupt or mid-copy file is logged and served +/// as plain passthrough rather than taking down the snapshot/watcher. +pub struct FlacMusicMetadataParser; + +impl MusicMetadataParser for FlacMusicMetadataParser { + fn parse(&self, path: &Path) -> Option { + match parse_flac_metadata(path) { + Some(mm) => Some(mm), + None => { + eprintln!( + "musicfs: failed to parse FLAC metadata from {}", + path.display() + ); + None + } + } + } +} + +/// FLAC encoder. Rebuilds the FLAC metadata header from the current tag fields, +/// re-injecting the (possibly overridden) Vorbis comment. +pub struct FlacMusicMetadataEncoder; + +impl MusicMetadataEncoder for FlacMusicMetadataEncoder { + fn encode(&self, metadata: &mut MusicMetadata) { + if metadata.header.is_empty() { + return; + } + let blocks = extract_non_vorbis_blocks(&metadata.header); + let (header, vorbis_comment_offset, vorbis_comment_length) = + build_flac_header(blocks, metadata); + metadata.header = header; + metadata.vorbis_comment_offset = vorbis_comment_offset; + metadata.vorbis_comment_length = vorbis_comment_length; + } +} + +fn parse_flac_metadata(path: &Path) -> Option { + let src = fs::File::open(path).ok()?; + let mss = MediaSourceStream::new(Box::new(src), Default::default()); + let mut hint = Hint::new(); + hint.with_extension("flac"); + + let meta_opts: MetadataOptions = Default::default(); + let fmt_opts: FormatOptions = Default::default(); + + let mut format = symphonia::default::get_probe() + .format(&hint, mss, &fmt_opts, &meta_opts) + .ok()?; + + let metadata = format.format.metadata(); + + let mut music_metadata = MusicMetadata::default(); + if let Some(revision) = metadata.current() { + extract_standard_tags(revision, &mut music_metadata); + } + + if let Some(parsed) = parse_flac(path) { + music_metadata.real_audio_start = parsed.audio_start; + music_metadata.picture_block_headers = parsed.picture_block_headers; + music_metadata.picture_data_ranges = parsed.picture_data_ranges; + let (header, vorbis_comment_offset, vorbis_comment_length) = + build_flac_header(parsed.other_blocks, &music_metadata); + music_metadata.header = header; + music_metadata.vorbis_comment_offset = vorbis_comment_offset; + music_metadata.vorbis_comment_length = vorbis_comment_length; + } + + Some(music_metadata) +} + +impl MusicMetadata { + /// Locate the Vorbis comment block within the rebuilt FLAC header so writes + /// to it can be intercepted. No-op for non-FLAC (e.g. ID3) headers. + pub fn find_vorbis_offsets(&mut self) { + let mut cursor = Cursor::new(&self.header); + let mut magic = [0u8; 4]; + if cursor.read_exact(&mut magic).is_err() { + return; + } + if &magic != b"fLaC" { + return; + } + loop { + let mut hdr = [0u8; 4]; + if cursor.read_exact(&mut hdr).is_err() { + break; + } + let (is_last, block_type, length) = read_block_header(&hdr); + if block_type == BLOCK_VORBIS_COMMENT { + self.vorbis_comment_offset = cursor.position(); + self.vorbis_comment_length = length; + return; + } + if cursor.seek(SeekFrom::Current(length as i64)).is_err() { + break; + } + if is_last { + break; + } + } + } + + /// Apply a freshly written Vorbis comment block and rebuild the header. + pub fn update_from_vorbis_comment_data(&mut self, data: &[u8]) { + parse_vorbis_comment_block(data, self); + let other_blocks = extract_non_vorbis_blocks(&self.header); + let (header, vorbis_comment_offset, vorbis_comment_length) = + build_flac_header(other_blocks, self); + self.header = header; + self.vorbis_comment_offset = vorbis_comment_offset; + self.vorbis_comment_length = vorbis_comment_length; + } +} + +/// Parse a 4-byte FLAC metadata block header into (is_last, block_type, data_length). +fn read_block_header(hdr: &[u8; 4]) -> (bool, u8, u64) { + let is_last = (hdr[0] & BLOCK_LAST_FLAG) != 0; + let block_type = hdr[0] & BLOCK_TYPE_MASK; + let length = u32::from_be_bytes([0, hdr[1], hdr[2], hdr[3]]) as u64; + (is_last, block_type, length) +} + +struct FlacParsed { + other_blocks: Vec<(u8, Vec)>, + picture_block_headers: Vec>, + picture_data_ranges: Vec<(u64, u64)>, + audio_start: u64, +} + +fn parse_flac(path: &Path) -> Option { + let mut f = fs::File::open(path).ok()?; + + let mut magic = [0u8; 4]; + f.read_exact(&mut magic).ok()?; + if &magic != b"fLaC" { + return None; + } + + let mut other_blocks: Vec<(u8, Vec)> = vec![]; + let mut picture_block_headers: Vec> = vec![]; + let mut picture_data_ranges: Vec<(u64, u64)> = vec![]; + let mut pos = 4u64; + + loop { + let mut hdr = [0u8; 4]; + f.read_exact(&mut hdr).ok()?; + let (is_last, block_type, length) = read_block_header(&hdr); + pos += 4; + + if block_type == BLOCK_PICTURE { + // PICTURE: keep block header (we'll fix is_last later), record data range + picture_block_headers.push(hdr.to_vec()); + picture_data_ranges.push((pos, length)); + f.seek(SeekFrom::Current(length as i64)).ok()?; + } else { + let mut data = vec![0u8; length as usize]; + f.read_exact(&mut data).ok()?; + // Skip VORBIS_COMMENT — we rebuild it + if block_type != BLOCK_VORBIS_COMMENT { + other_blocks.push((block_type, data)); + } + } + + pos += length; + if is_last { + break; + } + } + + // Fix is_last on the last picture block header: it must be 1 when audio follows + if let Some(last_hdr) = picture_block_headers.last_mut() { + last_hdr[0] = BLOCK_LAST_FLAG | (last_hdr[0] & BLOCK_TYPE_MASK); + } + + Some(FlacParsed { + other_blocks, + picture_block_headers, + picture_data_ranges, + audio_start: pos, + }) +} + +fn build_flac_header(blocks: Vec<(u8, Vec)>, metadata: &MusicMetadata) -> (Vec, u64, u64) { + let vorbis = build_vorbis_comment(metadata); + let vorbis_len = vorbis.len() as u64; + + let mut patched: Vec<(u8, Vec)> = blocks; + patched.push((BLOCK_VORBIS_COMMENT, vorbis)); + patched.push((BLOCK_PADDING, vec![0u8; PADDING_SIZE])); // allows metaflac in-place writes + + let vorbis_idx = patched.len() - 2; + let has_pictures = !metadata.picture_data_ranges.is_empty(); + let mut out = Vec::new(); + out.extend_from_slice(b"fLaC"); + + let last = patched.len() - 1; + let mut vorbis_offset = 0u64; + for (i, (block_type, data)) in patched.iter().enumerate() { + let is_last_block = i == last && !has_pictures; + let flag: u8 = if is_last_block { BLOCK_LAST_FLAG } else { 0x00 }; + let length = data.len() as u32; + if i == vorbis_idx { + vorbis_offset = out.len() as u64 + 4; // data starts after 4-byte block header + } + out.push(flag | block_type); + out.push((length >> 16) as u8); + out.push((length >> 8) as u8); + out.push(length as u8); + out.extend_from_slice(data); + } + + (out, vorbis_offset, vorbis_len) +} + +fn parse_vorbis_comment_block(data: &[u8], out: &mut MusicMetadata) { + let mut cursor = Cursor::new(data); + let mut len_bytes = [0u8; 4]; + + if cursor.read_exact(&mut len_bytes).is_err() { + return; + } + let vendor_len = u32::from_le_bytes(len_bytes) as i64; + if cursor.seek(SeekFrom::Current(vendor_len)).is_err() { + return; + } + if cursor.read_exact(&mut len_bytes).is_err() { + return; + } + let count = u32::from_le_bytes(len_bytes); + + out.artist.clear(); + out.other_tags.clear(); + + for _ in 0..count { + if cursor.read_exact(&mut len_bytes).is_err() { + break; + } + let comment_len = u32::from_le_bytes(len_bytes) as usize; + let mut comment_bytes = vec![0u8; comment_len]; + if cursor.read_exact(&mut comment_bytes).is_err() { + break; + } + let comment = String::from_utf8_lossy(&comment_bytes).into_owned(); + if let Some((key, value)) = comment.split_once('=') { + match key.to_ascii_uppercase().as_str() { + "TITLE" => out.track_title = value.to_string(), + "ALBUM" => out.album = value.to_string(), + "TRACKNUMBER" => out.track_number = value.parse().unwrap_or(0), + "ARTIST" => out.artist.push(value.to_string()), + _ => out.other_tags.push(comment), + } + } + } +} + +fn extract_non_vorbis_blocks(header: &[u8]) -> Vec<(u8, Vec)> { + let mut cursor = Cursor::new(header); + let mut blocks = vec![]; + + let mut magic = [0u8; 4]; + if cursor.read_exact(&mut magic).is_err() { + return blocks; + } + + loop { + let mut hdr = [0u8; 4]; + if cursor.read_exact(&mut hdr).is_err() { + break; + } + let (is_last, block_type, length) = read_block_header(&hdr); + let mut data = vec![0u8; length as usize]; + if cursor.read_exact(&mut data).is_err() { + break; + } + if block_type != BLOCK_VORBIS_COMMENT && block_type != BLOCK_PADDING { + blocks.push((block_type, data)); + } + if is_last { + break; + } + } + + blocks +} + +fn build_vorbis_comment(metadata: &MusicMetadata) -> Vec { + let vendor = b"musicfs"; + let mut out = Vec::new(); + + out.extend_from_slice(&(vendor.len() as u32).to_le_bytes()); + out.extend_from_slice(vendor); + + let mut comments: Vec = vec![ + format!("TITLE={}", metadata.track_title), + format!("ALBUM={}", metadata.album), + format!("TRACKNUMBER={}", metadata.track_number), + ]; + for artist in &metadata.artist { + comments.push(format!("ARTIST={}", artist)); + } + comments.extend(metadata.other_tags.iter().cloned()); + + out.extend_from_slice(&(comments.len() as u32).to_le_bytes()); + for comment in &comments { + let bytes = comment.as_bytes(); + out.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + out.extend_from_slice(bytes); + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_block_header_normal() { + let hdr = [0x04, 0x00, 0x01, 0x00]; + let (is_last, block_type, length) = read_block_header(&hdr); + assert!(!is_last); + assert_eq!(block_type, 4); + assert_eq!(length, 256); + } + + #[test] + fn read_block_header_last() { + let hdr = [0x84, 0x00, 0x00, 0x10]; + let (is_last, block_type, length) = read_block_header(&hdr); + assert!(is_last); + assert_eq!(block_type, 4); + assert_eq!(length, 16); + } + + #[test] + fn build_vorbis_comment_roundtrip() { + let mut metadata = MusicMetadata::default(); + metadata.artist = vec!["Artist1".to_string(), "Artist2".to_string()]; + metadata.album = "Album".to_string(); + metadata.track_number = 3; + metadata.track_title = "Title".to_string(); + metadata.other_tags = vec!["GENRE=Rock".to_string()]; + + let vorbis_bytes = build_vorbis_comment(&metadata); + let mut parsed = MusicMetadata::default(); + parse_vorbis_comment_block(&vorbis_bytes, &mut parsed); + + assert_eq!(parsed.artist, vec!["Artist1", "Artist2"]); + assert_eq!(parsed.album, "Album"); + assert_eq!(parsed.track_number, 3); + assert_eq!(parsed.track_title, "Title"); + assert_eq!(parsed.other_tags, vec!["GENRE=Rock"]); + } + + #[test] + fn parse_vorbis_comment_multi_artist() { + let mut vorbis_bytes = Vec::new(); + let vendor = b"test"; + vorbis_bytes.extend_from_slice(&(vendor.len() as u32).to_le_bytes()); + vorbis_bytes.extend_from_slice(vendor); + + let comments = vec!["ARTIST=Artist1", "ARTIST=Artist2"]; + vorbis_bytes.extend_from_slice(&(comments.len() as u32).to_le_bytes()); + for comment in &comments { + let bytes = comment.as_bytes(); + vorbis_bytes.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + vorbis_bytes.extend_from_slice(bytes); + } + + let mut metadata = MusicMetadata::default(); + parse_vorbis_comment_block(&vorbis_bytes, &mut metadata); + + assert_eq!(metadata.artist.len(), 2); + assert_eq!(metadata.artist[0], "Artist1"); + assert_eq!(metadata.artist[1], "Artist2"); + } + + #[test] + fn extract_non_vorbis_blocks_strips_vc_and_padding() { + let mut header = Vec::new(); + header.extend_from_slice(b"fLaC"); + + let streaminfo_data = vec![0u8; 34]; + header.push(0x00); + header.extend_from_slice(&[0x00, 0x00, 0x22]); + header.extend_from_slice(&streaminfo_data); + + let vorbis_data = vec![0u8; 50]; + header.push(0x04); + header.extend_from_slice(&[0x00, 0x00, 0x32]); + header.extend_from_slice(&vorbis_data); + + let padding_data = vec![0u8; 100]; + header.push(0x81); + header.extend_from_slice(&[0x00, 0x00, 0x64]); + header.extend_from_slice(&padding_data); + + let blocks = extract_non_vorbis_blocks(&header); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].0, 0); + assert_eq!(blocks[0].1.len(), 34); + } + + #[test] + fn find_vorbis_offsets_correct() { + let mut metadata = MusicMetadata::default(); + metadata.artist = vec!["TestArtist".to_string()]; + metadata.album = "TestAlbum".to_string(); + metadata.track_number = 1; + metadata.track_title = "TestTitle".to_string(); + + let other_blocks = vec![(0, vec![0u8; 34])]; + let (header, expected_offset, expected_length) = build_flac_header(other_blocks, &metadata); + metadata.header = header; + + metadata.find_vorbis_offsets(); + + assert_eq!(metadata.vorbis_comment_offset, expected_offset); + assert_eq!(metadata.vorbis_comment_length, expected_length); + } +} diff --git a/src/music/metadata.rs b/src/music/metadata.rs index 71f6f78..d5798af 100644 --- a/src/music/metadata.rs +++ b/src/music/metadata.rs @@ -1,29 +1,4 @@ -use std::{ - fs, - io::{Cursor, Read, Seek, SeekFrom}, - path::Path, -}; - -use symphonia::core::{ - formats::FormatOptions, - io::MediaSourceStream, - meta::{Metadata, MetadataOptions, MetadataRevision, StandardTagKey}, - probe::Hint, -}; - -const BLOCK_PADDING: u8 = 1; -const BLOCK_VORBIS_COMMENT: u8 = 4; -const BLOCK_PICTURE: u8 = 6; -const BLOCK_LAST_FLAG: u8 = 0x80; -const BLOCK_TYPE_MASK: u8 = 0x7f; - -/// Parse a 4-byte FLAC metadata block header into (is_last, block_type, data_length). -fn read_block_header(hdr: &[u8; 4]) -> (bool, u8, u64) { - let is_last = (hdr[0] & BLOCK_LAST_FLAG) != 0; - let block_type = hdr[0] & BLOCK_TYPE_MASK; - let length = u32::from_be_bytes([0, hdr[1], hdr[2], hdr[3]]) as u64; - (is_last, block_type, length) -} +use symphonia::core::meta::{MetadataRevision, StandardTagKey}; #[derive(Debug, Default, Clone)] pub struct MusicMetadata { @@ -33,7 +8,10 @@ pub struct MusicMetadata { pub track_title: String, pub other_tags: Vec, pub header: Vec, - pub picture_block_headers: Vec<[u8; 4]>, + /// Per-externalized-region reinsert prefix kept in memory. FLAC stores its + /// 4-byte PICTURE block header here; MP3 keeps whole frames in the original + /// file and stores an empty prefix. + pub picture_block_headers: Vec>, pub picture_data_ranges: Vec<(u64, u64)>, pub real_audio_start: u64, pub vorbis_comment_offset: u64, @@ -41,262 +19,34 @@ pub struct MusicMetadata { } impl MusicMetadata { + /// Size of the virtual file: rebuilt header + externalized regions + the + /// original audio tail. Format-agnostic. pub fn virtual_size(&self, real_file_size: u64) -> u64 { let pictures_size: u64 = self - .picture_data_ranges + .picture_block_headers .iter() - .map(|(_, len)| 4 + len) + .zip(self.picture_data_ranges.iter()) + .map(|(prefix, (_, len))| prefix.len() as u64 + len) .sum(); self.header.len() as u64 + pictures_size + (real_file_size - self.real_audio_start) } - - pub fn parse_music_metadata(path: &Path) -> Option { - let src = fs::File::open(path).expect("failed to open media"); - let mss = MediaSourceStream::new(Box::new(src), Default::default()); - let mut hint = Hint::new(); - hint.with_extension("flac"); - - let meta_opts: MetadataOptions = Default::default(); - let fmt_opts: FormatOptions = Default::default(); - - let mut format = symphonia::default::get_probe() - .format(&hint, mss, &fmt_opts, &meta_opts) - .expect("unsupported format"); - - let metadata: Metadata = format.format.metadata(); - let revision: &MetadataRevision = metadata.current().unwrap(); - - let mut music_metadata = MusicMetadata::default(); - - for tag in revision.tags() { - let value = tag.value.to_string(); - match tag.std_key { - Some(StandardTagKey::Artist) => music_metadata.artist.push(value), - Some(StandardTagKey::Album) => music_metadata.album = value, - Some(StandardTagKey::TrackNumber) => { - music_metadata.track_number = value.parse::().unwrap_or(0) - } - Some(StandardTagKey::TrackTitle) => music_metadata.track_title = value, - _ => music_metadata - .other_tags - .push(format!("{}={}", tag.key, value)), - } - } - - if let Some(parsed) = parse_flac(path) { - music_metadata.real_audio_start = parsed.audio_start; - music_metadata.picture_block_headers = parsed.picture_block_headers; - music_metadata.picture_data_ranges = parsed.picture_data_ranges; - let (header, vorbis_comment_offset, vorbis_comment_length) = - build_flac_header(parsed.other_blocks, &music_metadata); - music_metadata.header = header; - music_metadata.vorbis_comment_offset = vorbis_comment_offset; - music_metadata.vorbis_comment_length = vorbis_comment_length; - } - - Some(music_metadata) - } - - pub fn find_vorbis_offsets(&mut self) { - let mut cursor = Cursor::new(&self.header); - let mut magic = [0u8; 4]; - if cursor.read_exact(&mut magic).is_err() { - return; - } - loop { - let mut hdr = [0u8; 4]; - if cursor.read_exact(&mut hdr).is_err() { - break; - } - let (is_last, block_type, length) = read_block_header(&hdr); - if block_type == BLOCK_VORBIS_COMMENT { - self.vorbis_comment_offset = cursor.position(); - self.vorbis_comment_length = length; - return; - } - if cursor.seek(SeekFrom::Current(length as i64)).is_err() { - break; - } - if is_last { - break; - } - } - } - - pub fn update_from_vorbis_comment_data(&mut self, data: &[u8]) { - parse_vorbis_comment_block(data, self); - let other_blocks = extract_non_vorbis_blocks(&self.header); - let (header, vorbis_comment_offset, vorbis_comment_length) = - build_flac_header(other_blocks, self); - self.header = header; - self.vorbis_comment_offset = vorbis_comment_offset; - self.vorbis_comment_length = vorbis_comment_length; - } } -struct FlacParsed { - other_blocks: Vec<(u8, Vec)>, - picture_block_headers: Vec<[u8; 4]>, - picture_data_ranges: Vec<(u64, u64)>, - audio_start: u64, -} - -fn parse_flac(path: &Path) -> Option { - let mut f = fs::File::open(path).ok()?; - - let mut magic = [0u8; 4]; - f.read_exact(&mut magic).ok()?; - if &magic != b"fLaC" { - return None; - } - - let mut other_blocks: Vec<(u8, Vec)> = vec![]; - let mut picture_block_headers: Vec<[u8; 4]> = vec![]; - let mut picture_data_ranges: Vec<(u64, u64)> = vec![]; - let mut pos = 4u64; - - loop { - let mut hdr = [0u8; 4]; - f.read_exact(&mut hdr).ok()?; - let (is_last, block_type, length) = read_block_header(&hdr); - pos += 4; - - if block_type == BLOCK_PICTURE { - // PICTURE: keep block header (we'll fix is_last later), record data range - picture_block_headers.push(hdr); - picture_data_ranges.push((pos, length)); - f.seek(SeekFrom::Current(length as i64)).ok()?; - } else { - let mut data = vec![0u8; length as usize]; - f.read_exact(&mut data).ok()?; - // Skip VORBIS_COMMENT — we rebuild it - if block_type != BLOCK_VORBIS_COMMENT { - other_blocks.push((block_type, data)); +/// Map symphonia's normalized tags onto the shared `MusicMetadata` fields. +/// Used by every format parser so the field mapping lives in one place. +pub(crate) fn extract_standard_tags(revision: &MetadataRevision, out: &mut MusicMetadata) { + for tag in revision.tags() { + let value = tag.value.to_string(); + match tag.std_key { + Some(StandardTagKey::Artist) => out.artist.push(value), + Some(StandardTagKey::Album) => out.album = value, + Some(StandardTagKey::TrackNumber) => { + out.track_number = value.parse::().unwrap_or(0) } - } - - pos += length; - if is_last { - break; + Some(StandardTagKey::TrackTitle) => out.track_title = value, + _ => out.other_tags.push(format!("{}={}", tag.key, value)), } } - - // Fix is_last on the last picture block header: it must be 1 when audio follows - if let Some(last_hdr) = picture_block_headers.last_mut() { - last_hdr[0] = BLOCK_LAST_FLAG | (last_hdr[0] & BLOCK_TYPE_MASK); - } - - Some(FlacParsed { - other_blocks, - picture_block_headers, - picture_data_ranges, - audio_start: pos, - }) -} - -const PADDING_SIZE: usize = 8192; - -fn build_flac_header(blocks: Vec<(u8, Vec)>, metadata: &MusicMetadata) -> (Vec, u64, u64) { - let vorbis = build_vorbis_comment(metadata); - let vorbis_len = vorbis.len() as u64; - - let mut patched: Vec<(u8, Vec)> = blocks; - patched.push((BLOCK_VORBIS_COMMENT, vorbis)); - patched.push((BLOCK_PADDING, vec![0u8; PADDING_SIZE])); // allows metaflac in-place writes - - let vorbis_idx = patched.len() - 2; - let has_pictures = !metadata.picture_data_ranges.is_empty(); - let mut out = Vec::new(); - out.extend_from_slice(b"fLaC"); - - let last = patched.len() - 1; - let mut vorbis_offset = 0u64; - for (i, (block_type, data)) in patched.iter().enumerate() { - let is_last_block = i == last && !has_pictures; - let flag: u8 = if is_last_block { BLOCK_LAST_FLAG } else { 0x00 }; - let length = data.len() as u32; - if i == vorbis_idx { - vorbis_offset = out.len() as u64 + 4; // data starts after 4-byte block header - } - out.push(flag | block_type); - out.push((length >> 16) as u8); - out.push((length >> 8) as u8); - out.push(length as u8); - out.extend_from_slice(data); - } - - (out, vorbis_offset, vorbis_len) -} - -fn parse_vorbis_comment_block(data: &[u8], out: &mut MusicMetadata) { - let mut cursor = Cursor::new(data); - let mut len_bytes = [0u8; 4]; - - if cursor.read_exact(&mut len_bytes).is_err() { - return; - } - let vendor_len = u32::from_le_bytes(len_bytes) as i64; - if cursor.seek(SeekFrom::Current(vendor_len)).is_err() { - return; - } - if cursor.read_exact(&mut len_bytes).is_err() { - return; - } - let count = u32::from_le_bytes(len_bytes); - - out.artist.clear(); - out.other_tags.clear(); - - for _ in 0..count { - if cursor.read_exact(&mut len_bytes).is_err() { - break; - } - let comment_len = u32::from_le_bytes(len_bytes) as usize; - let mut comment_bytes = vec![0u8; comment_len]; - if cursor.read_exact(&mut comment_bytes).is_err() { - break; - } - let comment = String::from_utf8_lossy(&comment_bytes).into_owned(); - if let Some((key, value)) = comment.split_once('=') { - match key.to_ascii_uppercase().as_str() { - "TITLE" => out.track_title = value.to_string(), - "ALBUM" => out.album = value.to_string(), - "TRACKNUMBER" => out.track_number = value.parse().unwrap_or(0), - "ARTIST" => out.artist.push(value.to_string()), - _ => out.other_tags.push(comment), - } - } - } -} - -fn extract_non_vorbis_blocks(header: &[u8]) -> Vec<(u8, Vec)> { - let mut cursor = Cursor::new(header); - let mut blocks = vec![]; - - let mut magic = [0u8; 4]; - if cursor.read_exact(&mut magic).is_err() { - return blocks; - } - - loop { - let mut hdr = [0u8; 4]; - if cursor.read_exact(&mut hdr).is_err() { - break; - } - let (is_last, block_type, length) = read_block_header(&hdr); - let mut data = vec![0u8; length as usize]; - if cursor.read_exact(&mut data).is_err() { - break; - } - if block_type != BLOCK_VORBIS_COMMENT && block_type != BLOCK_PADDING { - blocks.push((block_type, data)); - } - if is_last { - break; - } - } - - blocks } pub mod db { @@ -382,150 +132,20 @@ pub mod db { } } -fn build_vorbis_comment(metadata: &MusicMetadata) -> Vec { - let vendor = b"musicfs"; - let mut out = Vec::new(); - - out.extend_from_slice(&(vendor.len() as u32).to_le_bytes()); - out.extend_from_slice(vendor); - - let mut comments: Vec = vec![ - format!("TITLE={}", metadata.track_title), - format!("ALBUM={}", metadata.album), - format!("TRACKNUMBER={}", metadata.track_number), - ]; - for artist in &metadata.artist { - comments.push(format!("ARTIST={}", artist)); - } - comments.extend(metadata.other_tags.iter().cloned()); - - out.extend_from_slice(&(comments.len() as u32).to_le_bytes()); - for comment in &comments { - let bytes = comment.as_bytes(); - out.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); - out.extend_from_slice(bytes); - } - - out -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn read_block_header_normal() { - let hdr = [0x04, 0x00, 0x01, 0x00]; - let (is_last, block_type, length) = read_block_header(&hdr); - assert!(!is_last); - assert_eq!(block_type, 4); - assert_eq!(length, 256); - } - - #[test] - fn read_block_header_last() { - let hdr = [0x84, 0x00, 0x00, 0x10]; - let (is_last, block_type, length) = read_block_header(&hdr); - assert!(is_last); - assert_eq!(block_type, 4); - assert_eq!(length, 16); - } - #[test] fn virtual_size_calculation() { let mut metadata = MusicMetadata::default(); metadata.header = vec![0u8; 100]; + metadata.picture_block_headers = vec![vec![0u8; 4]]; metadata.picture_data_ranges = vec![(0, 50)]; metadata.real_audio_start = 200; let file_size = 1000u64; let virtual_size = metadata.virtual_size(file_size); + // 100 (header) + 4 (prefix) + 50 (picture data) + 800 (audio tail) assert_eq!(virtual_size, 954); } - - #[test] - fn build_vorbis_comment_roundtrip() { - let mut metadata = MusicMetadata::default(); - metadata.artist = vec!["Artist1".to_string(), "Artist2".to_string()]; - metadata.album = "Album".to_string(); - metadata.track_number = 3; - metadata.track_title = "Title".to_string(); - metadata.other_tags = vec!["GENRE=Rock".to_string()]; - - let vorbis_bytes = build_vorbis_comment(&metadata); - let mut parsed = MusicMetadata::default(); - parse_vorbis_comment_block(&vorbis_bytes, &mut parsed); - - assert_eq!(parsed.artist, vec!["Artist1", "Artist2"]); - assert_eq!(parsed.album, "Album"); - assert_eq!(parsed.track_number, 3); - assert_eq!(parsed.track_title, "Title"); - assert_eq!(parsed.other_tags, vec!["GENRE=Rock"]); - } - - #[test] - fn parse_vorbis_comment_multi_artist() { - let mut vorbis_bytes = Vec::new(); - let vendor = b"test"; - vorbis_bytes.extend_from_slice(&(vendor.len() as u32).to_le_bytes()); - vorbis_bytes.extend_from_slice(vendor); - - let comments = vec!["ARTIST=Artist1", "ARTIST=Artist2"]; - vorbis_bytes.extend_from_slice(&(comments.len() as u32).to_le_bytes()); - for comment in &comments { - let bytes = comment.as_bytes(); - vorbis_bytes.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); - vorbis_bytes.extend_from_slice(bytes); - } - - let mut metadata = MusicMetadata::default(); - parse_vorbis_comment_block(&vorbis_bytes, &mut metadata); - - assert_eq!(metadata.artist.len(), 2); - assert_eq!(metadata.artist[0], "Artist1"); - assert_eq!(metadata.artist[1], "Artist2"); - } - - #[test] - fn extract_non_vorbis_blocks_strips_vc_and_padding() { - let mut header = Vec::new(); - header.extend_from_slice(b"fLaC"); - - let streaminfo_data = vec![0u8; 34]; - header.push(0x00); - header.extend_from_slice(&[0x00, 0x00, 0x22]); - header.extend_from_slice(&streaminfo_data); - - let vorbis_data = vec![0u8; 50]; - header.push(0x04); - header.extend_from_slice(&[0x00, 0x00, 0x32]); - header.extend_from_slice(&vorbis_data); - - let padding_data = vec![0u8; 100]; - header.push(0x81); - header.extend_from_slice(&[0x00, 0x00, 0x64]); - header.extend_from_slice(&padding_data); - - let blocks = extract_non_vorbis_blocks(&header); - assert_eq!(blocks.len(), 1); - assert_eq!(blocks[0].0, 0); - assert_eq!(blocks[0].1.len(), 34); - } - - #[test] - fn find_vorbis_offsets_correct() { - let mut metadata = MusicMetadata::default(); - metadata.artist = vec!["TestArtist".to_string()]; - metadata.album = "TestAlbum".to_string(); - metadata.track_number = 1; - metadata.track_title = "TestTitle".to_string(); - - let other_blocks = vec![(0, vec![0u8; 34])]; - let (header, expected_offset, expected_length) = build_flac_header(other_blocks, &metadata); - metadata.header = header; - - metadata.find_vorbis_offsets(); - - assert_eq!(metadata.vorbis_comment_offset, expected_offset); - assert_eq!(metadata.vorbis_comment_length, expected_length); - } } diff --git a/src/music/mod.rs b/src/music/mod.rs index ab71815..f8435dc 100644 --- a/src/music/mod.rs +++ b/src/music/mod.rs @@ -1,2 +1,6 @@ pub mod db; +pub mod encoder; +pub mod flac; pub mod metadata; +pub mod mp3; +pub mod parser; diff --git a/src/music/mp3.rs b/src/music/mp3.rs new file mode 100644 index 0000000..adadde4 --- /dev/null +++ b/src/music/mp3.rs @@ -0,0 +1,361 @@ +use std::{ + fs, + io::{Read, Seek, SeekFrom}, + path::Path, +}; + +use symphonia::core::{ + formats::FormatOptions, io::MediaSourceStream, meta::MetadataOptions, probe::Hint, +}; + +use crate::music::encoder::MusicMetadataEncoder; +use crate::music::metadata::{MusicMetadata, extract_standard_tags}; +use crate::music::parser::MusicMetadataParser; + +/// Frames we replace from our own tag fields. Every other frame in the source +/// ID3 tag is preserved verbatim via externalization. +const OVERRIDE_FRAME_IDS: [[u8; 4]; 4] = [*b"TIT2", *b"TALB", *b"TPE1", *b"TRCK"]; + +/// MP3 parser. Reads ID3 tags (via symphonia) and locates where the audio +/// frames begin (end of the ID3v2 tag). +pub struct Mp3MusicMetadataParser; + +impl MusicMetadataParser for Mp3MusicMetadataParser { + fn parse(&self, path: &Path) -> Option { + match parse_mp3_metadata(path) { + Some(mm) => Some(mm), + None => { + eprintln!( + "musicfs: failed to parse MP3 metadata from {}", + path.display() + ); + None + } + } + } +} + +/// Decode a 28-bit syncsafe integer (7 bits per byte) as used by ID3v2 sizes. +fn syncsafe(b: [u8; 4]) -> u64 { + ((b[0] as u64) << 21) | ((b[1] as u64) << 14) | ((b[2] as u64) << 7) | (b[3] as u64) +} + +/// Byte offset where the MP3 audio frames begin: the end of the ID3v2 tag, or +/// 0 when there is no tag. Reads only the 10-byte ID3 header. +fn id3_audio_start(path: &Path) -> u64 { + let mut f = match fs::File::open(path) { + Ok(f) => f, + Err(_) => return 0, + }; + let mut hdr = [0u8; 10]; + if f.read_exact(&mut hdr).is_err() { + return 0; + } + if &hdr[0..3] != b"ID3" { + return 0; + } + let size = syncsafe([hdr[6], hdr[7], hdr[8], hdr[9]]); + // ID3v2.4 footer flag adds another 10 bytes after the tag body. + let footer = if hdr[5] & 0x10 != 0 { 10 } else { 0 }; + 10 + size + footer +} + +fn parse_mp3_metadata(path: &Path) -> Option { + let src = fs::File::open(path).ok()?; + let mss = MediaSourceStream::new(Box::new(src), Default::default()); + let mut hint = Hint::new(); + hint.with_extension("mp3"); + + let meta_opts: MetadataOptions = Default::default(); + let fmt_opts: FormatOptions = Default::default(); + + let mut probed = symphonia::default::get_probe() + .format(&hint, mss, &fmt_opts, &meta_opts) + .ok()?; + + let mut music_metadata = MusicMetadata::default(); + + // ID3v2 tags at the start of the file surface in the probe-level metadata; + // fall back to the in-stream metadata otherwise. + let mut got_tags = false; + if let Some(metadata) = probed.metadata.get() { + if let Some(revision) = metadata.current() { + extract_standard_tags(revision, &mut music_metadata); + got_tags = true; + } + } + if !got_tags { + let metadata = probed.format.metadata(); + if let Some(revision) = metadata.current() { + extract_standard_tags(revision, &mut music_metadata); + } + } + + music_metadata.real_audio_start = id3_audio_start(path); + + // Record the original frames we will preserve (cover art, lyrics, …) so the + // encoder can stitch them back in from the original file at read time. + let (prefixes, ranges) = parse_id3_preserved_frames(path); + music_metadata.picture_block_headers = prefixes; + music_metadata.picture_data_ranges = ranges; + + // Untagged files would otherwise build a degenerate empty artist/album path. + if music_metadata.artist.is_empty() { + music_metadata.artist = vec!["Unknown Artist".to_string()]; + } + if music_metadata.album.is_empty() { + music_metadata.album = "Unknown Album".to_string(); + } + + Some(music_metadata) +} + +/// MP3 encoder. Builds a fresh ID3v2.4 tag from the current (possibly +/// overridden) tag fields. Preserved frames are not copied into the header; +/// their lengths are counted so the tag's size field spans them. +pub struct Mp3MusicMetadataEncoder; + +impl MusicMetadataEncoder for Mp3MusicMetadataEncoder { + fn encode(&self, metadata: &mut MusicMetadata) { + metadata.header = build_id3v2_header(metadata); + } +} + +/// Encode a 28-bit syncsafe integer (7 bits per byte) for ID3v2 size fields. +fn syncsafe_encode(n: u64) -> [u8; 4] { + [ + ((n >> 21) & 0x7f) as u8, + ((n >> 14) & 0x7f) as u8, + ((n >> 7) & 0x7f) as u8, + (n & 0x7f) as u8, + ] +} + +/// Build a single UTF-8 ID3v2.4 text frame. +fn build_id3_text_frame(id: &[u8; 4], text: &str) -> Vec { + let mut body = Vec::with_capacity(text.len() + 1); + body.push(0x03); // text encoding: UTF-8 + body.extend_from_slice(text.as_bytes()); + + let mut frame = Vec::with_capacity(10 + body.len()); + frame.extend_from_slice(id); + frame.extend_from_slice(&syncsafe_encode(body.len() as u64)); + frame.extend_from_slice(&[0, 0]); // frame flags + frame.extend_from_slice(&body); + frame +} + +/// Build the in-memory portion of the virtual ID3v2.4 tag: the 10-byte tag +/// header plus our four override frames. Preserved frames and audio are +/// stitched in by the read path; the tag size field accounts for them. +fn build_id3v2_header(m: &MusicMetadata) -> Vec { + let mut frames = Vec::new(); + frames.extend(build_id3_text_frame(b"TIT2", &m.track_title)); + frames.extend(build_id3_text_frame(b"TALB", &m.album)); + frames.extend(build_id3_text_frame(b"TPE1", &m.artist.join("\0"))); + frames.extend(build_id3_text_frame(b"TRCK", &m.track_number.to_string())); + + let preserved_len: u64 = m + .picture_block_headers + .iter() + .zip(m.picture_data_ranges.iter()) + .map(|(prefix, (_, len))| prefix.len() as u64 + len) + .sum(); + + let body_len = frames.len() as u64 + preserved_len; + + let mut header = Vec::with_capacity(10 + frames.len()); + header.extend_from_slice(b"ID3"); + header.extend_from_slice(&[0x04, 0x00, 0x00]); // v2.4.0, no flags + header.extend_from_slice(&syncsafe_encode(body_len)); + header.extend_from_slice(&frames); + header +} + +/// Walk the source ID3v2 tag and record every frame we do NOT override +/// (cover art, lyrics, other text frames) as a whole-frame range in the +/// original file. Each gets an empty in-memory prefix — the frame is copied +/// verbatim from disk at read time. Returns empty lists when there is no tag +/// or the tag is unsynchronised (which can't be externalized verbatim). +fn parse_id3_preserved_frames(path: &Path) -> (Vec>, Vec<(u64, u64)>) { + let mut prefixes: Vec> = Vec::new(); + let mut ranges: Vec<(u64, u64)> = Vec::new(); + + let mut f = match fs::File::open(path) { + Ok(f) => f, + Err(_) => return (prefixes, ranges), + }; + let mut hdr = [0u8; 10]; + if f.read_exact(&mut hdr).is_err() || &hdr[0..3] != b"ID3" { + return (prefixes, ranges); + } + let version = hdr[3]; + // Unsynchronised tags store bytes we can't copy verbatim; fall back to + // passthrough by preserving nothing (the whole original tag is dropped, + // but that only loses art on a rare encoding — acceptable for v1). + if hdr[5] & 0x80 != 0 { + return (prefixes, ranges); + } + + let tag_end = id3_audio_start(path); + let mut offset: u64 = 10; + while offset + 10 <= tag_end { + if f.seek(SeekFrom::Start(offset)).is_err() { + break; + } + let mut fh = [0u8; 10]; + if f.read_exact(&mut fh).is_err() { + break; + } + // A zero frame id marks the start of padding. + if fh[0..4].iter().all(|&b| b == 0) { + break; + } + let size = if version >= 4 { + syncsafe([fh[4], fh[5], fh[6], fh[7]]) + } else { + u32::from_be_bytes([fh[4], fh[5], fh[6], fh[7]]) as u64 + }; + let frame_total = 10 + size; + if size == 0 || offset + frame_total > tag_end { + break; + } + let id = &fh[0..4]; + let is_override = OVERRIDE_FRAME_IDS.iter().any(|o| &o[..] == id); + if !is_override { + prefixes.push(Vec::new()); + ranges.push((offset, frame_total)); + } + offset += frame_total; + } + + (prefixes, ranges) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn syncsafe_decodes_seven_bit_groups() { + assert_eq!(syncsafe([0, 0, 0, 0x23]), 35); + assert_eq!(syncsafe([0, 0, 1, 0]), 128); + } + + fn write_temp(bytes: &[u8]) -> tempfile::NamedTempFile { + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(bytes).unwrap(); + f.flush().unwrap(); + f + } + + #[test] + fn id3_audio_start_after_tag() { + let mut buf = Vec::new(); + buf.extend_from_slice(b"ID3"); + buf.extend_from_slice(&[0x04, 0x00, 0x00]); // version + flags + buf.extend_from_slice(&[0x00, 0x00, 0x00, 0x23]); // syncsafe size = 35 + buf.extend_from_slice(&[0u8; 35]); // tag body + let f = write_temp(&buf); + assert_eq!(id3_audio_start(f.path()), 45); + } + + #[test] + fn id3_audio_start_with_footer() { + let mut buf = Vec::new(); + buf.extend_from_slice(b"ID3"); + buf.extend_from_slice(&[0x04, 0x00, 0x10]); // footer flag set + buf.extend_from_slice(&[0x00, 0x00, 0x00, 0x23]); // size = 35 + buf.extend_from_slice(&[0u8; 45]); + let f = write_temp(&buf); + assert_eq!(id3_audio_start(f.path()), 55); + } + + #[test] + fn id3_audio_start_no_tag() { + // Raw MP3 frame sync, no ID3 tag. + let f = write_temp(&[0xFF, 0xFB, 0x40, 0xC0, 0x00, 0x00]); + assert_eq!(id3_audio_start(f.path()), 0); + } + + #[test] + fn syncsafe_encode_inverts_syncsafe() { + for n in [0u64, 35, 128, 30696, 0x0FFF_FFFF] { + assert_eq!(syncsafe(syncsafe_encode(n)), n); + } + } + + #[test] + fn build_id3v2_header_structure() { + let mm = MusicMetadata { + track_title: "Title".to_string(), + album: "Album".to_string(), + artist: vec!["A".to_string(), "B".to_string()], + track_number: 7, + // one preserved frame of total length 20 + picture_block_headers: vec![Vec::new()], + picture_data_ranges: vec![(100, 20)], + ..MusicMetadata::default() + }; + let header = build_id3v2_header(&mm); + + assert_eq!(&header[0..3], b"ID3"); + assert_eq!(&header[3..6], &[0x04, 0x00, 0x00]); + + let frames_len = header.len() as u64 - 10; + let declared = syncsafe([header[6], header[7], header[8], header[9]]); + // size field spans our frames + the preserved frame's 20 bytes + assert_eq!(declared, frames_len + 20); + + assert!(header.windows(4).any(|w| w == b"TIT2")); + assert!(header.windows(4).any(|w| w == b"TPE1")); + } + + fn id3_frame(id: &[u8; 4], data: &[u8], syncsafe_size: bool) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(id); + if syncsafe_size { + v.extend_from_slice(&syncsafe_encode(data.len() as u64)); + } else { + v.extend_from_slice(&(data.len() as u32).to_be_bytes()); + } + v.extend_from_slice(&[0, 0]); // flags + v.extend_from_slice(data); + v + } + + fn build_tag(version: u8, frames: &[u8]) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(b"ID3"); + buf.extend_from_slice(&[version, 0x00, 0x00]); + buf.extend_from_slice(&syncsafe_encode(frames.len() as u64)); + buf.extend_from_slice(frames); + buf + } + + #[test] + fn preserved_frames_v23_keeps_apic_drops_overrides() { + let mut frames = Vec::new(); + frames.extend(id3_frame(b"APIC", &[1, 2, 3, 4, 5], false)); + frames.extend(id3_frame(b"TIT2", &[0x03, b'h', b'i'], false)); + let f = write_temp(&build_tag(0x03, &frames)); + + let (prefixes, ranges) = parse_id3_preserved_frames(f.path()); + // APIC starts right after the 10-byte tag header; total = 10 + 5 + assert_eq!(ranges, vec![(10, 15)]); + assert_eq!(prefixes, vec![Vec::::new()]); + } + + #[test] + fn preserved_frames_v24_syncsafe_sizes() { + let mut frames = Vec::new(); + frames.extend(id3_frame(b"USLT", &[9, 9, 9], true)); + frames.extend(id3_frame(b"TALB", &[0x03, b'x'], true)); + let f = write_temp(&build_tag(0x04, &frames)); + + let (prefixes, ranges) = parse_id3_preserved_frames(f.path()); + assert_eq!(ranges, vec![(10, 13)]); // USLT: 10 + 3 + assert_eq!(prefixes, vec![Vec::::new()]); + } +} diff --git a/src/music/parser.rs b/src/music/parser.rs new file mode 100644 index 0000000..4171c40 --- /dev/null +++ b/src/music/parser.rs @@ -0,0 +1,31 @@ +use std::path::Path; + +use crate::music::flac::FlacMusicMetadataParser; +use crate::music::metadata::MusicMetadata; +use crate::music::mp3::Mp3MusicMetadataParser; + +/// A parser that turns a single media file into our shared [`MusicMetadata`]. +/// Each supported container format provides one implementation. +pub trait MusicMetadataParser { + fn parse(&self, path: &Path) -> Option; +} + +/// Selects the right [`MusicMetadataParser`] for a given file. +pub struct MusicMetadataParserFactory; + +impl MusicMetadataParserFactory { + /// Returns a parser based on the file extension, or `None` for files we + /// don't treat as music. + pub fn for_path(path: &Path) -> Option> { + match path + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("flac") => Some(Box::new(FlacMusicMetadataParser)), + Some("mp3") => Some(Box::new(Mp3MusicMetadataParser)), + _ => None, + } + } +} diff --git a/src/origins/local/file_io.rs b/src/origins/local/file_io.rs index 6c8027b..8bc71fb 100644 --- a/src/origins/local/file_io.rs +++ b/src/origins/local/file_io.rs @@ -17,7 +17,7 @@ pub fn read_bytes_at(path: &Path, offset: u64, len: usize) -> io::Result pub fn assemble_flac_read( original_path: &Path, header: &[u8], - pic_hdrs: &[[u8; 4]], + pic_hdrs: &[Vec], pic_ranges: &[(u64, u64)], real_audio_start: u64, offset: u64, @@ -38,7 +38,8 @@ pub fn assemble_flac_read( let mut virt_pos = header_end; for (pic_hdr, (data_real_offset, data_len)) in pic_hdrs.iter().zip(pic_ranges.iter()) { - let pic_hdr_end = virt_pos + 4; + let pic_hdr_len = pic_hdr.len() as u64; + let pic_hdr_end = virt_pos + pic_hdr_len; let pic_end = pic_hdr_end + data_len; if end <= virt_pos { @@ -52,7 +53,7 @@ pub fn assemble_flac_read( let hdr_from = (offset.max(virt_pos) - virt_pos) as usize; let hdr_to = ((end.min(pic_hdr_end)) - virt_pos) as usize; if hdr_from < hdr_to { - buf.extend_from_slice(&pic_hdr[hdr_from..hdr_to.min(4)]); + buf.extend_from_slice(&pic_hdr[hdr_from..hdr_to.min(pic_hdr.len())]); } let data_start = offset.max(pic_hdr_end); diff --git a/src/origins/local/mod.rs b/src/origins/local/mod.rs index e62940e..1a3ed95 100644 --- a/src/origins/local/mod.rs +++ b/src/origins/local/mod.rs @@ -88,7 +88,7 @@ impl LocalOrigin { }; let size = match &item.music_metadata { - Some(mm) if mm.real_audio_start > 0 => mm.virtual_size(metadata.size()), + Some(mm) if !mm.header.is_empty() => mm.virtual_size(metadata.size()), _ => metadata.size(), }; @@ -407,7 +407,7 @@ impl Filesystem for LocalOrigin { let flac = item .music_metadata .as_ref() - .filter(|mm| mm.real_audio_start > 0) + .filter(|mm| !mm.header.is_empty()) .map(|mm| { ( mm.header.clone(), diff --git a/src/origins/local/snapshot.rs b/src/origins/local/snapshot.rs index c88ebd6..395e427 100644 --- a/src/origins/local/snapshot.rs +++ b/src/origins/local/snapshot.rs @@ -9,7 +9,8 @@ use fuser::INodeNo; use std::os::unix::fs::MetadataExt; use crate::item::{FileType, Item}; -use crate::music::metadata::MusicMetadata; +use crate::music::encoder::MusicMetadataEncoderFactory; +use crate::music::parser::MusicMetadataParserFactory; use crate::virtual_dirs::ensure_virtual_dirs; pub fn fill_fileset(map: &Arc>>, source: &Path, destination: &Path) { @@ -69,12 +70,14 @@ pub fn read_into_map( } else { FileType::File }; - let music_metadata = - if item_path.extension().and_then(std::ffi::OsStr::to_str) == Some("flac") { - MusicMetadata::parse_music_metadata(&item_path) - } else { - None - }; + let music_metadata = MusicMetadataParserFactory::for_path(&item_path) + .and_then(|parser| parser.parse(&item_path)) + .map(|mut mm| { + if let Some(encoder) = MusicMetadataEncoderFactory::for_path(&item_path) { + encoder.encode(&mut mm); + } + mm + }); let mut local_path = PathBuf::new(); if music_metadata.is_some() { diff --git a/tests/file_io_test.rs b/tests/file_io_test.rs index 52edb47..44ff46d 100644 --- a/tests/file_io_test.rs +++ b/tests/file_io_test.rs @@ -11,14 +11,14 @@ fn assemble_flac_read_picture_header_region() { let path = f.path(); let header = b"ABCD"; - let pic_hdr = [0x86, 0x00, 0x00, 0x05]; + let pic_hdr = vec![0x86u8, 0x00, 0x00, 0x05]; let pic_ranges = [(10u64, 5u64)]; let real_audio_start = 50u64; let result = assemble_flac_read( path, header, - &[pic_hdr], + &[pic_hdr.clone()], &pic_ranges, real_audio_start, 4, @@ -38,14 +38,14 @@ fn assemble_flac_read_picture_data_region() { let path = f.path(); let header = b"ABCD"; - let pic_hdr = [0x86, 0x00, 0x00, 0x05]; + let pic_hdr = vec![0x86u8, 0x00, 0x00, 0x05]; let pic_ranges = [(10u64, 5u64)]; let real_audio_start = 50u64; let result = assemble_flac_read( path, header, - &[pic_hdr], + &[pic_hdr.clone()], &pic_ranges, real_audio_start, 8,