Refactor to split client from server
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod encoder;
|
||||
pub mod flac;
|
||||
pub mod metadata;
|
||||
pub mod mp3;
|
||||
pub mod parse;
|
||||
pub mod parser;
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user