Files
MusicFS/src/music/metadata.rs
T
Alexander 24fd81ff9e Add tests
2026-06-27 20:11:33 +02:00

532 lines
17 KiB
Rust

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)
}
#[derive(Debug, Default, Clone)]
pub struct MusicMetadata {
pub artist: Vec<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<[u8; 4]>,
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_data_ranges
.iter()
.map(|(_, len)| 4 + 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));
}
}
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,
})
}
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 {
pub mod music_metadata {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "music_metadata")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub inode: i64,
pub track_title: String,
pub album: String,
pub track_number: i32,
#[sea_orm(column_type = "Blob")]
pub header: Vec<u8>,
pub real_audio_start: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub mod artists {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "music_metadata_artists")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub inode: i64,
#[sea_orm(primary_key, auto_increment = false)]
pub artist: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub mod other_tags {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "music_metadata_other_tags")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub inode: i64,
#[sea_orm(primary_key, auto_increment = false)]
pub position: i32,
pub tag: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
pub mod pictures {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "music_metadata_pictures")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub inode: i64,
#[sea_orm(primary_key, auto_increment = false)]
pub position: i32,
#[sea_orm(column_type = "Blob")]
pub block_header: Vec<u8>,
pub data_offset: i64,
pub data_length: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
}
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_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);
}
#[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);
}
}