Handle SIGTERM, fix mp3 handler

This commit is contained in:
Alexander
2026-06-29 12:03:31 +02:00
parent 24fd81ff9e
commit 0779c0d076
15 changed files with 941 additions and 508 deletions
Generated
+11 -60
View File
@@ -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",
+1 -2
View File
@@ -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"
+2 -1
View File
@@ -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"
'';
+9 -15
View File
@@ -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");
+3 -10
View File
@@ -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<Vec<u8>> =
sorted_pics.iter().map(|p| p.block_header.clone()).collect();
let picture_data_ranges: Vec<(u64, u64)> = sorted_pics
.iter()
+31
View File
@@ -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<Box<dyn MusicMetadataEncoder>> {
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,
}
}
}
+444
View File
@@ -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<MusicMetadata> {
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<MusicMetadata> {
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<u8>)>,
picture_block_headers: Vec<Vec<u8>>,
picture_data_ranges: Vec<(u64, u64)>,
audio_start: u64,
}
fn parse_flac(path: &Path) -> Option<FlacParsed> {
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<u8>)> = vec![];
let mut picture_block_headers: Vec<Vec<u8>> = 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<u8>)>, metadata: &MusicMetadata) -> (Vec<u8>, u64, u64) {
let vorbis = build_vorbis_comment(metadata);
let vorbis_len = vorbis.len() as u64;
let mut patched: Vec<(u8, Vec<u8>)> = 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<u8>)> {
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<u8> {
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<String> = 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);
}
}
+24 -404
View File
@@ -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<String>,
pub header: Vec<u8>,
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<Vec<u8>>,
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<MusicMetadata> {
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::<i32>().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<u8>)>,
picture_block_headers: Vec<[u8; 4]>,
picture_data_ranges: Vec<(u64, u64)>,
audio_start: u64,
}
fn parse_flac(path: &Path) -> Option<FlacParsed> {
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<u8>)> = 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::<i32>().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<u8>)>, metadata: &MusicMetadata) -> (Vec<u8>, u64, u64) {
let vorbis = build_vorbis_comment(metadata);
let vorbis_len = vorbis.len() as u64;
let mut patched: Vec<(u8, Vec<u8>)> = 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<u8>)> {
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<u8> {
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<String> = 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);
}
}
+4
View File
@@ -1,2 +1,6 @@
pub mod db;
pub mod encoder;
pub mod flac;
pub mod metadata;
pub mod mp3;
pub mod parser;
+361
View File
@@ -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<MusicMetadata> {
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<MusicMetadata> {
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<u8> {
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<u8> {
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<u8>>, Vec<(u64, u64)>) {
let mut prefixes: Vec<Vec<u8>> = 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<u8> {
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<u8> {
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::<u8>::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::<u8>::new()]);
}
}
+31
View File
@@ -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<MusicMetadata>;
}
/// 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<Box<dyn MusicMetadataParser>> {
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,
}
}
}
+4 -3
View File
@@ -17,7 +17,7 @@ pub fn read_bytes_at(path: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>
pub fn assemble_flac_read(
original_path: &Path,
header: &[u8],
pic_hdrs: &[[u8; 4]],
pic_hdrs: &[Vec<u8>],
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);
+2 -2
View File
@@ -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(),
+10 -7
View File
@@ -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<Mutex<BTreeMap<INodeNo, Item>>>, 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() {
+4 -4
View File
@@ -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,