532 lines
17 KiB
Rust
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);
|
|
}
|
|
}
|