Read and store flac metadata

This commit is contained in:
Alexander
2026-06-26 23:27:33 +02:00
parent 4f52549e46
commit 9737e26bc9
5 changed files with 487 additions and 27 deletions
+25 -6
View File
@@ -8,13 +8,32 @@ CREATE TABLE items (
);
CREATE TABLE music_metadata (
inode BIGINT PRIMARY KEY REFERENCES items(inode) ON DELETE CASCADE,
track_title TEXT NOT NULL,
album TEXT NOT NULL,
track_number INTEGER NOT NULL
inode BIGINT PRIMARY KEY REFERENCES items(inode) ON DELETE CASCADE,
track_title TEXT NOT NULL,
album TEXT NOT NULL,
track_number INTEGER NOT NULL,
header BYTEA NOT NULL,
real_audio_start BIGINT NOT NULL
);
CREATE TABLE music_metadata_artists (
inode BIGINT NOT NULL REFERENCES music_metadata(inode) ON DELETE CASCADE,
artist TEXT NOT NULL
inode BIGINT NOT NULL REFERENCES music_metadata(inode) ON DELETE CASCADE,
artist TEXT NOT NULL,
PRIMARY KEY (inode, artist)
);
CREATE TABLE music_metadata_other_tags (
inode BIGINT NOT NULL REFERENCES music_metadata(inode) ON DELETE CASCADE,
position INTEGER NOT NULL,
tag TEXT NOT NULL,
PRIMARY KEY (inode, position)
);
CREATE TABLE music_metadata_pictures (
inode BIGINT NOT NULL REFERENCES music_metadata(inode) ON DELETE CASCADE,
position INTEGER NOT NULL,
block_header BYTEA NOT NULL,
data_offset BIGINT NOT NULL,
data_length BIGINT NOT NULL,
PRIMARY KEY (inode, position)
);
+1
View File
@@ -27,6 +27,7 @@
packages = with pkgs; [
git
just
flac
opencode
];
+226 -6
View File
@@ -1,8 +1,8 @@
use std::{
collections::BTreeMap,
fs,
hash::{Hash, Hasher},
io,
hash::Hasher,
io::{self, Read, Seek, SeekFrom},
os::unix::{
ffi::OsStrExt,
fs::{MetadataExt, PermissionsExt},
@@ -18,8 +18,11 @@ use notify::{Event, EventKind, RecursiveMode, Watcher};
use sea_orm::entity::prelude::*;
use twox_hash::XxHash64;
use crate::file_watcher::FileWatcher;
use crate::music_metadata::MusicMetadata;
use crate::{file_watcher::FileWatcher, music_metadata};
use crate::music_metadata::db::{
artists, music_metadata as music_metadata_entity, other_tags, pictures,
};
#[derive(PartialEq, Eq, Copy, Clone, Debug)]
enum FileType {
@@ -109,13 +112,22 @@ impl LocalOrigin {
let mut to_insert: Vec<ActiveModel> = vec![];
let mut to_update: Vec<ActiveModel> = vec![];
let mut to_delete: Vec<i64> = vec![];
let mut to_save_music: Vec<i64> = vec![];
for (ino, item) in &snapshot {
let ino_i64 = ino.0 as i64;
match db_items.get(&ino_i64) {
None => to_insert.push(ActiveModel::from(item)),
None => {
to_insert.push(ActiveModel::from(item));
if item.music_metadata.is_some() {
to_save_music.push(ino_i64);
}
}
Some(db_item) if db_item.hash != item.hash as i64 => {
to_update.push(ActiveModel::from(item))
to_update.push(ActiveModel::from(item));
if item.music_metadata.is_some() {
to_save_music.push(ino_i64);
}
}
_ => {}
}
@@ -137,6 +149,18 @@ impl LocalOrigin {
Entity::delete_by_id(ino).exec(&client).await.unwrap();
}
for ino_i64 in &to_save_music {
let ino = INodeNo(*ino_i64 as u64);
if let Some(music_metadata) = snapshot
.get(&ino)
.and_then(|item| item.music_metadata.as_ref())
{
LocalOrigin::save_music_metadata(*ino_i64, music_metadata, &client)
.await
.unwrap();
}
}
// For unchanged items (hash matches), restore virtual paths from DB
// so that renames performed in a previous session are preserved.
let restorations: Vec<(INodeNo, String, PathBuf)> = db_items
@@ -177,6 +201,73 @@ impl LocalOrigin {
Ok(local_origin)
}
async fn save_music_metadata(
inode: i64,
music_metadata: &MusicMetadata,
client: &sea_orm::DatabaseConnection,
) -> Result<(), sea_orm::DbErr> {
use sea_orm::ActiveValue::Set;
music_metadata_entity::Entity::delete_by_id(inode)
.exec(client)
.await?;
music_metadata_entity::Entity::insert(music_metadata_entity::ActiveModel {
inode: Set(inode),
track_title: Set(music_metadata.track_title.clone()),
album: Set(music_metadata.album.clone()),
track_number: Set(music_metadata.track_number),
header: Set(music_metadata.header.clone()),
real_audio_start: Set(music_metadata.real_audio_start as i64),
})
.exec(client)
.await?;
if !music_metadata.artist.is_empty() {
artists::Entity::insert_many(music_metadata.artist.iter().map(|a| {
artists::ActiveModel {
inode: Set(inode),
artist: Set(a.clone()),
}
}))
.exec(client)
.await?;
}
if !music_metadata.other_tags.is_empty() {
other_tags::Entity::insert_many(music_metadata.other_tags.iter().enumerate().map(
|(pos, tag)| other_tags::ActiveModel {
inode: Set(inode),
position: Set(pos as i32),
tag: Set(tag.clone()),
},
))
.exec(client)
.await?;
}
if !music_metadata.picture_block_headers.is_empty() {
pictures::Entity::insert_many(
music_metadata
.picture_block_headers
.iter()
.zip(music_metadata.picture_data_ranges.iter())
.enumerate()
.map(|(pos, (hdr, (offset, len)))| pictures::ActiveModel {
inode: Set(inode),
position: Set(pos as i32),
block_header: Set(hdr.to_vec()),
data_offset: Set(*offset as i64),
data_length: Set(*len as i64),
}),
)
.exec(client)
.await?;
}
Ok(())
}
fn fill_fileset(
map: &Arc<Mutex<BTreeMap<INodeNo, LocalItem>>>,
source: &Path,
@@ -293,9 +384,14 @@ impl LocalOrigin {
let crtime = metadata.created().unwrap_or(SystemTime::UNIX_EPOCH);
let kind = LocalOrigin::file_type(local_file.file_type);
let size = match &local_file.music_metadata {
Some(mm) if mm.real_audio_start > 0 => mm.virtual_size(metadata.size()),
_ => metadata.size(),
};
return FileAttr {
ino: local_file.inode,
size: metadata.size(),
size,
blocks: metadata.blocks(),
atime: atime,
mtime: mtime,
@@ -319,6 +415,15 @@ impl LocalOrigin {
};
}
fn read_bytes_at(path: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>> {
let mut f = fs::File::open(path)?;
f.seek(SeekFrom::Start(offset))?;
let mut buf = vec![0u8; len];
let n = f.read(&mut buf)?;
buf.truncate(n);
Ok(buf)
}
fn parent_inode_from_path(local_path: &Path) -> INodeNo {
let components: Vec<_> = local_path.components().collect();
if components.len() <= 1 {
@@ -540,6 +645,121 @@ impl Filesystem for LocalOrigin {
reply.ok();
}
fn read(
&self,
_req: &Request,
ino: INodeNo,
_fh: fuser::FileHandle,
offset: u64,
size: u32,
_flags: fuser::OpenFlags,
_lock_owner: Option<fuser::LockOwner>,
reply: fuser::ReplyData,
) {
let files = self.files.lock().unwrap();
let item = match files.get(&ino) {
Some(f) => f,
None => {
reply.error(Errno::ENOENT);
return;
}
};
let original_path = item.original_path.clone();
let mm = item
.music_metadata
.as_ref()
.filter(|mm| mm.real_audio_start > 0);
let is_flac = mm.is_some();
let header = mm.map(|mm| mm.header.clone()).unwrap_or_default();
let pic_hdrs = mm
.map(|mm| mm.picture_block_headers.clone())
.unwrap_or_default();
let pic_ranges = mm
.map(|mm| mm.picture_data_ranges.clone())
.unwrap_or_default();
let real_audio_start = mm.map(|mm| mm.real_audio_start).unwrap_or(0);
drop(files);
if !is_flac {
match LocalOrigin::read_bytes_at(&original_path, offset, size as usize) {
Ok(bytes) => reply.data(&bytes),
Err(_) => reply.error(Errno::EIO),
}
return;
}
let end = offset + size as u64;
let header_end = header.len() as u64;
// Pure header read — most common for tag readers
if end <= header_end {
reply.data(&header[offset as usize..end as usize]);
return;
}
// Multi-region read: walk [header][pic_hdr+pic_data]...[audio] and collect bytes
let mut buf = Vec::with_capacity(size as usize);
if offset < header_end {
buf.extend_from_slice(&header[offset as usize..]);
}
let mut virt_pos = header_end;
for (pic_hdr, (data_real_offset, data_len)) in pic_hdrs.iter().zip(pic_ranges.iter()) {
let pic_hdr_end = virt_pos + 4;
let pic_end = pic_hdr_end + data_len;
if end <= virt_pos {
break;
}
if offset >= pic_end {
virt_pos = pic_end;
continue;
}
let hdr_from = (offset.max(virt_pos) - virt_pos) as usize;
let hdr_to = ((end.min(pic_hdr_end)) - virt_pos) as usize;
if hdr_from < hdr_to {
buf.extend_from_slice(&pic_hdr[hdr_from..hdr_to.min(4)]);
}
let data_start = offset.max(pic_hdr_end);
let data_end = end.min(pic_end);
if data_start < data_end {
let real_off = data_real_offset + (data_start - pic_hdr_end);
match LocalOrigin::read_bytes_at(
&original_path,
real_off,
(data_end - data_start) as usize,
) {
Ok(bytes) => buf.extend_from_slice(&bytes),
Err(_) => {
reply.error(Errno::EIO);
return;
}
}
}
virt_pos = pic_end;
}
let audio_start = offset.max(virt_pos);
if audio_start < end {
let real_off = real_audio_start + (audio_start - virt_pos);
match LocalOrigin::read_bytes_at(&original_path, real_off, (end - audio_start) as usize)
{
Ok(bytes) => buf.extend_from_slice(&bytes),
Err(_) => {
reply.error(Errno::EIO);
return;
}
}
}
reply.data(&buf);
}
fn lookup(
&self,
_req: &Request,
-1
View File
@@ -1,6 +1,5 @@
use clap::Parser;
use file_watcher::FileWatcher;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
+235 -14
View File
@@ -1,4 +1,8 @@
use std::{fs, path::Path};
use std::{
fs,
io::{Read, Seek, SeekFrom},
path::Path,
};
use symphonia::core::{
formats::FormatOptions,
@@ -13,9 +17,23 @@ pub struct MusicMetadata {
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,
}
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());
@@ -32,25 +50,228 @@ impl MusicMetadata {
let metadata: Metadata = format.format.metadata();
let revision: &MetadataRevision = metadata.current().unwrap();
let mut music_metadata = MusicMetadata {
..Default::default()
};
let mut music_metadata = MusicMetadata::default();
for tag in revision.tags() {
let tag_type = tag.std_key.expect("no std_key for tag found");
let value = tag.value.to_string();
match tag_type {
StandardTagKey::Artist => music_metadata.artist.push(value),
StandardTagKey::Album => music_metadata.album = value,
StandardTagKey::TrackNumber => {
music_metadata.track_number = value.parse::<i32>().unwrap()
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)
}
StandardTagKey::TrackTitle => music_metadata.track_title = value,
_ => {}
Some(StandardTagKey::TrackTitle) => music_metadata.track_title = value,
_ => music_metadata
.other_tags
.push(format!("{}={}", tag.key, value)),
}
}
return Some(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;
music_metadata.header = build_flac_header(parsed.other_blocks, &music_metadata);
}
Some(music_metadata)
}
}
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 = (hdr[0] & 0x80) != 0;
let block_type = hdr[0] & 0x7f;
let length = u32::from_be_bytes([0, hdr[1], hdr[2], hdr[3]]) as u64;
pos += 4;
if block_type == 6 {
// 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 (type 4) — we rebuild it
if block_type != 4 {
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] = 0x80 | (last_hdr[0] & 0x7f);
}
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> {
let vorbis = build_vorbis_comment(metadata);
let mut patched: Vec<(u8, Vec<u8>)> = blocks;
patched.push((4, vorbis));
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;
for (i, (block_type, data)) in patched.iter().enumerate() {
// is_last only if this is the final block AND no picture blocks follow
let is_last_block = i == last && !has_pictures;
let flag: u8 = if is_last_block { 0x80 } else { 0x00 };
let length = data.len() as u32;
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
}
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
}