diff --git a/db/schema.sql b/db/schema.sql index 4431da5..c1b4290 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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) ); diff --git a/devenv.nix b/devenv.nix index 0a3937b..449c1e9 100644 --- a/devenv.nix +++ b/devenv.nix @@ -27,6 +27,7 @@ packages = with pkgs; [ git just + flac opencode ]; diff --git a/src/local.rs b/src/local.rs index 99fa44f..bd9588a 100644 --- a/src/local.rs +++ b/src/local.rs @@ -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 = vec![]; let mut to_update: Vec = vec![]; let mut to_delete: Vec = vec![]; + let mut to_save_music: Vec = 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>>, 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> { + 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, + 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, diff --git a/src/main.rs b/src/main.rs index 43244b9..b801712 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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; diff --git a/src/music_metadata.rs b/src/music_metadata.rs index 74735bf..f8b1d89 100644 --- a/src/music_metadata.rs +++ b/src/music_metadata.rs @@ -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, + pub header: Vec, + 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 { 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::().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::().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)>, + picture_block_headers: Vec<[u8; 4]>, + picture_data_ranges: Vec<(u64, u64)>, + audio_start: u64, +} + +fn parse_flac(path: &Path) -> Option { + 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)> = 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)>, metadata: &MusicMetadata) -> Vec { + let vorbis = build_vorbis_comment(metadata); + + let mut patched: Vec<(u8, Vec)> = 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, + 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, + 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 { + 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 = 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 +}