Refactor to split client from server

This commit is contained in:
Alexander
2026-07-01 16:31:55 +02:00
parent d35aa2aecb
commit 8dba0c097d
56 changed files with 441 additions and 326 deletions
+88
View File
@@ -0,0 +1,88 @@
use std::{
fs,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use std::os::unix::fs::{MetadataExt, PermissionsExt};
/// Owned, `Clone`-able snapshot of the file attributes that musicfs needs to
/// serve FUSE `getattr` and to compute the per-item identity hash.
///
/// Decoupled from `std::fs::Metadata` so that non-disk origins (e.g. a future
/// `NetworkOrigin` reading a server manifest) can construct equivalent
/// attributes without a real inode on disk. The on-disk origin builds this
/// via `From<&fs::Metadata>`; other origins build it from their own metadata
/// source using the same field types.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileAttrs {
pub size: u64,
pub blocks: u64,
pub atime: SystemTime,
pub mtime: SystemTime,
pub ctime: SystemTime,
pub crtime: SystemTime,
pub perm: u16,
pub nlink: u32,
pub uid: u32,
pub gid: u32,
pub rdev: u32,
pub blksize: u32,
}
impl From<&fs::Metadata> for FileAttrs {
fn from(metadata: &fs::Metadata) -> Self {
return FileAttrs {
size: metadata.size(),
blocks: metadata.blocks(),
atime: metadata.accessed().unwrap_or(UNIX_EPOCH),
mtime: metadata.modified().unwrap_or(UNIX_EPOCH),
ctime: UNIX_EPOCH + Duration::from_secs(metadata.ctime() as u64),
crtime: metadata.created().unwrap_or(UNIX_EPOCH),
perm: metadata.permissions().mode() as u16,
nlink: metadata.nlink() as u32,
uid: metadata.uid(),
gid: metadata.gid(),
rdev: metadata.rdev() as u32,
blksize: metadata.blksize() as u32,
};
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_attrs_from_metadata_copies_size_and_block_fields() {
let tmp = tempfile::tempdir().unwrap();
let metadata = fs::metadata(tmp.path()).unwrap();
let attrs = FileAttrs::from(&metadata);
assert_eq!(attrs.size, metadata.size());
assert_eq!(attrs.blocks, metadata.blocks());
assert_eq!(attrs.blksize, metadata.blksize() as u32);
}
#[test]
fn file_attrs_from_metadata_copies_unix_ownership() {
let tmp = tempfile::tempdir().unwrap();
let metadata = fs::metadata(tmp.path()).unwrap();
let attrs = FileAttrs::from(&metadata);
assert_eq!(attrs.uid, metadata.uid());
assert_eq!(attrs.gid, metadata.gid());
assert_eq!(attrs.perm, metadata.permissions().mode() as u16);
}
#[test]
fn file_attrs_from_metadata_preserves_mtime() {
let tmp = tempfile::tempdir().unwrap();
let metadata = fs::metadata(tmp.path()).unwrap();
let attrs = FileAttrs::from(&metadata);
assert_eq!(attrs.mtime, metadata.modified().unwrap_or(UNIX_EPOCH));
}
}
+20
View File
@@ -0,0 +1,20 @@
use std::hash::Hasher;
use twox_hash::XxHash64;
pub fn compute_item_hash(
inode: u64,
original_path: &[u8],
ctime_secs: u64,
mtime_secs: u64,
crtime_secs: u64,
) -> u64 {
let seed = 1234;
let mut hasher = XxHash64::with_seed(seed);
hasher.write_u64(inode);
hasher.write(original_path);
hasher.write_u64(ctime_secs);
hasher.write_u64(mtime_secs);
hasher.write_u64(crtime_secs);
hasher.finish()
}
+8
View File
@@ -0,0 +1,8 @@
pub mod attrs;
pub mod hash;
pub mod logging;
pub mod music;
pub use attrs::FileAttrs;
pub use hash::compute_item_hash;
pub use music::metadata::MusicMetadata;
+89
View File
@@ -0,0 +1,89 @@
//! Process-wide logging initialization.
//!
//! Wires three `fmt` layers under a single global [`EnvFilter`] (driven by
//! `RUST_LOG`, defaulting to `info`):
//!
//! - **stdout** — `INFO`/`DEBUG`/`TRACE` (the verbose side)
//! - **stderr** — `WARN`/`ERROR` (the severe side)
//! - **file** — everything passing the global filter, daily-rotated with
//! retention, written without ANSI colors so log files stay clean.
//!
//! [`init`] returns the [`WorkerGuard`] that owns the non-blocking file
//! buffer; the caller binds it for the whole process lifetime so the buffer
//! flushes on exit. Dropping it early would silently drop pending log lines.
//!
//! The FUSE/RPC hot paths carry `trace!`/`debug!` points that fire per syscall
//! — thousands per second under load. They are inert unless
//! `RUST_LOG=trace`/`debug` is set; do not enable those levels in production
//! casually.
use std::path::PathBuf;
use tracing::Level;
use tracing_subscriber::{EnvFilter, filter::filter_fn, fmt, prelude::*};
/// Configuration handed to [`init`] by each binary.
pub struct LogConfig {
/// Directory the daily-rotated log files are written into.
pub log_dir: PathBuf,
/// Filename prefix; conventionally the binary name (`musicfs` or
/// `musicfs-server`).
pub file_prefix: String,
/// Retention: keep at most this many rotated log files.
pub max_files: usize,
}
/// Initialize the global tracing subscriber and return the file-writer
/// [`WorkerGuard`]. Bind it for the process lifetime so pending file writes
/// flush on exit.
///
/// Panics if the log directory cannot be created or the rolling file appender
/// cannot be initialized — both are fatal startup conditions worth failing
/// fast on, before any real work begins.
pub fn init(cfg: LogConfig) -> tracing_appender::non_blocking::WorkerGuard {
std::fs::create_dir_all(&cfg.log_dir).unwrap_or_else(|e| {
panic!(
"logging: failed to create log dir {}: {e}",
cfg.log_dir.display()
)
});
// One global gate for all three layers. RUST_LOG wins; default `info`.
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
let file_appender = tracing_appender::rolling::RollingFileAppender::builder()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix(&cfg.file_prefix)
.filename_suffix("log")
.max_log_files(cfg.max_files)
.build(&cfg.log_dir)
.unwrap_or_else(|e| {
panic!(
"logging: failed to init rolling file appender in {}: {e}",
cfg.log_dir.display()
)
});
let (file_writer, guard) = tracing_appender::non_blocking(file_appender);
// tracing's Level ordering is ERROR < WARN < INFO < DEBUG < TRACE, so
// `>= INFO` selects the verbose side (INFO/DEBUG/TRACE) routed to stdout,
// and `<= WARN` selects the severe side (WARN/ERROR) routed to stderr.
let stdout_layer = fmt::layer()
.with_writer(std::io::stdout)
.with_filter(filter_fn(|m| *m.level() >= Level::INFO));
let stderr_layer = fmt::layer()
.with_writer(std::io::stderr)
.with_filter(filter_fn(|m| *m.level() <= Level::WARN));
let file_layer = fmt::layer().with_ansi(false).with_writer(file_writer);
tracing_subscriber::registry()
.with(env_filter)
.with(stdout_layer)
.with(stderr_layer)
.with(file_layer)
.init();
guard
}
+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,
}
}
}
+445
View File
@@ -0,0 +1,445 @@
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;
use tracing::warn;
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 => {
warn!(
path = %path.display(),
"failed to parse FLAC metadata; serving as passthrough"
);
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);
}
}
+62
View File
@@ -0,0 +1,62 @@
use symphonia::core::meta::{MetadataRevision, StandardTagKey};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct MusicMetadata {
pub artist: Vec<String>,
pub album_artist: Option<String>,
pub album: String,
pub track_number: i32,
pub track_title: String,
pub other_tags: Vec<String>,
pub header: Vec<u8>,
pub picture_block_headers: Vec<Vec<u8>>,
pub picture_data_ranges: Vec<(u64, u64)>,
pub real_audio_start: u64,
pub vorbis_comment_offset: u64,
pub vorbis_comment_length: u64,
}
impl MusicMetadata {
pub fn virtual_size(&self, real_file_size: u64) -> u64 {
let pictures_size: u64 = self
.picture_block_headers
.iter()
.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(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::AlbumArtist) => out.album_artist = Some(value),
Some(StandardTagKey::Album) => out.album = value,
Some(StandardTagKey::TrackNumber) => {
out.track_number = value.parse::<i32>().unwrap_or(0)
}
Some(StandardTagKey::TrackTitle) => out.track_title = value,
_ => out.other_tags.push(format!("{}={}", tag.key, value)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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);
assert_eq!(virtual_size, 954);
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod encoder;
pub mod flac;
pub mod metadata;
pub mod mp3;
pub mod parse;
pub mod parser;
+507
View File
@@ -0,0 +1,507 @@
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;
use tracing::warn;
/// 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 => {
warn!(
path = %path.display(),
"failed to parse MP3 metadata; serving as passthrough"
);
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)
}
impl MusicMetadata {
/// Apply a freshly written ID3v2 tag and rebuild the in-memory header.
pub fn update_from_id3_data(&mut self, data: &[u8]) {
parse_id3_tag_frames(data, self);
self.header = build_id3v2_header(self);
}
/// Apply a freshly written ID3v1 tag (128-byte "TAG" block) and rebuild
/// the in-memory ID3v2 header. ID3v1 is Latin-1, max 30 chars per field;
/// non-ASCII bytes are replaced with '?' since Latin-1 ≠ UTF-8.
pub fn update_from_id3v1_data(&mut self, data: &[u8]) {
if data.len() < 128 || &data[0..3] != b"TAG" {
return;
}
fn latin1_str(bytes: &[u8]) -> Option<String> {
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
if end == 0 {
return None;
}
Some(
bytes[..end]
.iter()
.map(|&b| if b < 0x80 { char::from(b) } else { '?' })
.collect(),
)
}
if let Some(t) = latin1_str(&data[3..33]) {
self.track_title = t;
}
if let Some(a) = latin1_str(&data[33..63]) {
self.artist = vec![a];
}
if let Some(a) = latin1_str(&data[63..93]) {
self.album = a;
}
// ID3v1.1: byte 125 == 0 means byte 126 is the track number
if data[125] == 0 && data[126] != 0 {
self.track_number = data[126] as i32;
}
self.header = build_id3v2_header(self);
}
}
fn decode_id3_text(data: &[u8]) -> String {
if data.is_empty() {
return String::new();
}
match data[0] {
0x03 => String::from_utf8_lossy(&data[1..])
.trim_end_matches('\0')
.to_string(),
0x00 => data[1..]
.iter()
.take_while(|&&b| b != 0)
.map(|&b| char::from(b))
.collect(),
0x01 | 0x02 => {
let raw = &data[1..];
let (src, le) = if raw.len() >= 2 && raw[0] == 0xFF && raw[1] == 0xFE {
(&raw[2..], true)
} else if raw.len() >= 2 && raw[0] == 0xFE && raw[1] == 0xFF {
(&raw[2..], false)
} else {
(raw, true)
};
let words: Vec<u16> = src
.chunks_exact(2)
.map(|c| {
if le {
u16::from_le_bytes([c[0], c[1]])
} else {
u16::from_be_bytes([c[0], c[1]])
}
})
.take_while(|&w| w != 0)
.collect();
String::from_utf16_lossy(&words)
}
_ => String::new(),
}
}
fn parse_id3_tag_frames(data: &[u8], out: &mut MusicMetadata) {
if data.len() < 10 || &data[0..3] != b"ID3" {
return;
}
let version = data[3];
if data[5] & 0x80 != 0 {
return; // unsynchronised — can't copy frames verbatim
}
let tag_end = (10 + syncsafe([data[6], data[7], data[8], data[9]]) as usize).min(data.len());
out.artist.clear();
let mut offset = 10usize;
while offset + 10 <= tag_end {
if data[offset..offset + 4].iter().all(|&b| b == 0) {
break; // padding
}
let size = if version >= 4 {
syncsafe([
data[offset + 4],
data[offset + 5],
data[offset + 6],
data[offset + 7],
]) as usize
} else {
u32::from_be_bytes([
data[offset + 4],
data[offset + 5],
data[offset + 6],
data[offset + 7],
]) as usize
};
if size == 0 || offset + 10 + size > tag_end {
break;
}
let body = &data[offset + 10..offset + 10 + size];
match &data[offset..offset + 4] {
b"TIT2" => out.track_title = decode_id3_text(body),
b"TALB" => out.album = decode_id3_text(body),
b"TPE1" => {
let text = decode_id3_text(body);
out.artist = text
.split('\0')
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
}
b"TRCK" => {
let text = decode_id3_text(body);
out.track_number = text
.split('/')
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
}
_ => {}
}
offset += 10 + size;
}
}
/// MP3 encoder. Builds a fresh ID3v2.3 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-16 ID3v2.3 text frame.
fn build_id3_text_frame(id: &[u8; 4], text: &str) -> Vec<u8> {
let mut body = Vec::with_capacity(3 + text.len() * 2);
body.push(0x01); // text encoding: UTF-16 with BOM
body.extend_from_slice(&[0xFF, 0xFE]); // little-endian BOM
for unit in text.encode_utf16() {
body.extend_from_slice(&unit.to_le_bytes());
}
let mut frame = Vec::with_capacity(10 + body.len());
frame.extend_from_slice(id);
frame.extend_from_slice(&(body.len() as u32).to_be_bytes());
frame.extend_from_slice(&[0, 0]); // frame flags
frame.extend_from_slice(&body);
frame
}
/// Build the in-memory portion of the virtual ID3v2.3 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(&[0x03, 0x00, 0x00]); // v2.3.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], &[0x03, 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()]);
}
}
+55
View File
@@ -0,0 +1,55 @@
use std::path::Path;
use tracing::{debug, trace};
use crate::music::encoder::MusicMetadataEncoderFactory;
use crate::music::metadata::MusicMetadata;
use crate::music::parser::MusicMetadataParserFactory;
/// Parse a single media file into a fully-encoded [`MusicMetadata`].
///
/// `None` is returned for files musicfs does not treat as music (unknown
/// extension or unparseable container). When metadata is produced, the
/// matching [`MusicMetadataEncoder`] has already baked the tag fields into
/// the in-memory `header`, so callers can serve virtualized bytes directly.
///
/// A music-extension file that fails to parse is logged at `warn!` by the
/// selected parser (see [`crate::music::flac`] / [`crate::music::mp3`]); this
/// function simply propagates the resulting `None` without re-logging.
///
/// This helper is the single source of truth for "parse + encode" — used by
/// the local FUSE origin's snapshot builder, the network origin's manifest
/// builder, and the server's manifest endpoint.
pub fn parse_music_metadata_for_path(path: &Path) -> Option<MusicMetadata> {
let parser = match MusicMetadataParserFactory::for_path(path) {
Some(p) => p,
None => {
trace!(path = %path.display(), "non-music extension; skipping");
return None;
}
};
debug!(path = %path.display(), "music parser selected");
let mut metadata = parser.parse(path)?;
if let Some(encoder) = MusicMetadataEncoderFactory::for_path(path) {
encoder.encode(&mut metadata);
}
return Some(metadata);
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn none_for_non_music_extension() {
let path = Path::new("notes.txt");
assert!(parse_music_metadata_for_path(path).is_none());
}
#[test]
fn none_for_missing_file_with_music_extension() {
let path = Path::new("/nonexistent/track.flac");
assert!(parse_music_metadata_for_path(path).is_none());
}
}
+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,
}
}
}