Refactor to split client from server

This commit is contained in:
Alexander
2026-07-01 16:31:55 +02:00
parent d35aa2aecb
commit 8dba0c097d
56 changed files with 441 additions and 326 deletions
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "musicfs-client"
version.workspace = true
edition.workspace = true
default-run = "musicfs"
[lib]
name = "musicfs"
path = "src/lib.rs"
[[bin]]
name = "musicfs"
path = "src/main.rs"
[dependencies]
musicfs-core.workspace = true
musicfs-proto.workspace = true
fuser.workspace = true
libc.workspace = true
sea-orm.workspace = true
tokio.workspace = true
anyhow.workspace = true
async-trait.workspace = true
clap.workspace = true
notify.workspace = true
tokio-stream.workspace = true
tonic.workspace = true
bytes.workspace = true
http.workspace = true
tracing.workspace = true
log.workspace = true
twox-hash.workspace = true
chrono.workspace = true
[dev-dependencies]
tempfile.workspace = true
+115
View File
@@ -0,0 +1,115 @@
use sea_orm::{
ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter,
sea_query::OnConflict,
};
use tracing::{debug, error};
use crate::db::entities::cached_file_bytes::{ActiveModel, Column, Entity, Model};
/// Return the cached bytes for `inode` if present. `None` means "not cached";
/// the caller should fetch from the server and call [`put_cached_bytes`].
pub async fn get_cached_bytes(inode: i64, db: &DatabaseConnection) -> Option<Vec<u8>> {
let row: Option<Model> = match Entity::find_by_id(inode).one(db).await {
Ok(m) => m,
Err(e) => {
error!(%inode, error = %e, "get_cached_bytes: DB read failed");
None
}
};
return row.map(|m| m.data);
}
/// Insert or replace the cached bytes for `inode`. Called after a successful
/// `GetFile` round-trip so subsequent reads of the same range hit the cache.
pub async fn put_cached_bytes(inode: i64, data: Vec<u8>, db: &DatabaseConnection) {
let active = ActiveModel {
inode: Set(inode),
data: Set(data),
fetched_at: Set(chrono::Utc::now()),
};
if let Err(e) = Entity::insert(active)
.on_conflict(
OnConflict::column(Column::Inode)
.update_columns([Column::Data, Column::FetchedAt])
.to_owned(),
)
.exec(db)
.await
{
error!(%inode, error = %e, "put_cached_bytes: DB upsert failed");
}
}
/// Remove cache rows for the given inodes. Called by reconcile when a file's
/// hash has changed (the cached bytes are now stale).
pub async fn delete_cached_bytes_for(inodes: &[i64], db: &DatabaseConnection) {
if inodes.is_empty() {
return;
}
debug!(count = inodes.len(), "deleting cached bytes");
if let Err(e) = Entity::delete_many()
.filter(Column::Inode.is_in(inodes.to_vec()))
.exec(db)
.await
{
error!(error = %e, "delete_cached_bytes_for: DB delete failed");
}
}
#[cfg(test)]
mod tests {
use super::*;
use sea_orm::ConnectionTrait;
async fn connect() -> Option<DatabaseConnection> {
let pg_host = std::env::var("PGHOST").ok()?;
let url = format!("postgresql://fujin@localhost/musicfs?host={pg_host}");
let db = sea_orm::Database::connect(&url).await.ok()?;
db.execute_unprepared("SELECT 1 FROM cached_file_bytes LIMIT 0")
.await
.ok()?;
Some(db)
}
async fn setup_parent_item(db: &DatabaseConnection, inode: i64) {
db.execute_unprepared(&format!(
"INSERT INTO items (inode, name, original_path, local_path, file_type, hash) \
VALUES ({inode}, 'test', '/test', '/test', 'file', 0) \
ON CONFLICT (inode) DO NOTHING"
))
.await
.unwrap();
}
async fn teardown(db: &DatabaseConnection, inode: i64) {
let _ = db
.execute_unprepared(&format!("DELETE FROM items WHERE inode = {inode}"))
.await;
}
#[tokio::test]
async fn cache_round_trip_with_timestamptz() {
let db = match connect().await {
Some(db) => db,
None => {
eprintln!("skip: no database (PGHOST unset or unreachable)");
return;
}
};
let inode = -999_999_i64;
let test_data = b"regression-test-payload".to_vec();
setup_parent_item(&db, inode).await;
put_cached_bytes(inode, test_data.clone(), &db).await;
let cached = get_cached_bytes(inode, &db).await;
assert_eq!(
cached,
Some(test_data),
"get_cached_bytes must decode fetched_at (TIMESTAMPTZ), not fail with type mismatch"
);
teardown(&db, inode).await;
}
}
+155
View File
@@ -0,0 +1,155 @@
use sea_orm::entity::prelude::*;
use crate::item::{FileType, Item};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "items")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub inode: i64,
pub name: String,
pub original_path: String,
pub local_path: String,
pub file_type: String,
pub hash: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
impl From<&Item> for ActiveModel {
fn from(item: &Item) -> Self {
use sea_orm::ActiveValue::Set;
ActiveModel {
inode: Set(item.inode.0 as i64),
name: Set(item.name.clone()),
original_path: Set(item.original_path.to_string_lossy().into_owned()),
local_path: Set(item.local_path.to_string_lossy().into_owned()),
file_type: Set(match item.file_type {
FileType::Directory => "directory".to_string(),
FileType::File => "file".to_string(),
}),
hash: Set(item.hash as i64),
}
}
}
pub mod cached_file_bytes {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "cached_file_bytes")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub inode: i64,
#[sea_orm(column_type = "Blob")]
pub data: Vec<u8>,
pub fetched_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::item::{FileType, Item};
use crate::origins::attrs::FileAttrs;
use fuser::INodeNo;
use std::path::PathBuf;
fn attrs_for_tempdir() -> FileAttrs {
let tmp = tempfile::tempdir().unwrap();
let metadata = std::fs::metadata(tmp.path()).unwrap();
return FileAttrs::from(&metadata);
}
#[test]
fn item_to_active_model_fields_match() {
let item = Item::new(
INodeNo(42),
INodeNo::ROOT,
"test".to_string(),
PathBuf::from("/original/path"),
PathBuf::from("local/path"),
FileType::Directory,
attrs_for_tempdir(),
None,
);
let am = ActiveModel::from(&item);
if let sea_orm::ActiveValue::Set(v) = &am.inode {
assert_eq!(*v, 42i64);
} else {
panic!("inode field not set");
}
if let sea_orm::ActiveValue::Set(v) = &am.name {
assert_eq!(v, "test");
} else {
panic!("name field not set");
}
if let sea_orm::ActiveValue::Set(v) = &am.original_path {
assert_eq!(v, "/original/path");
} else {
panic!("original_path field not set");
}
if let sea_orm::ActiveValue::Set(v) = &am.local_path {
assert_eq!(v, "local/path");
} else {
panic!("local_path field not set");
}
if let sea_orm::ActiveValue::Set(v) = &am.hash {
assert_eq!(*v, item.hash as i64);
} else {
panic!("hash field not set");
}
}
#[test]
fn file_type_string_mapping() {
let item_dir = Item::new(
INodeNo(1),
INodeNo::ROOT,
"dir".to_string(),
PathBuf::from("/original/dir"),
PathBuf::from("local/dir"),
FileType::Directory,
attrs_for_tempdir(),
None,
);
let am_dir = ActiveModel::from(&item_dir);
if let sea_orm::ActiveValue::Set(v) = &am_dir.file_type {
assert_eq!(v, "directory");
} else {
panic!("file_type field not set for directory");
}
let item_file = Item::new(
INodeNo(2),
INodeNo::ROOT,
"file".to_string(),
PathBuf::from("/original/file"),
PathBuf::from("local/file"),
FileType::File,
attrs_for_tempdir(),
None,
);
let am_file = ActiveModel::from(&item_file);
if let sea_orm::ActiveValue::Set(v) = &am_file.file_type {
assert_eq!(v, "file");
} else {
panic!("file_type field not set for file");
}
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod cache;
pub mod entities;
pub mod sync;
+92
View File
@@ -0,0 +1,92 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use fuser::INodeNo;
use sea_orm::entity::prelude::*;
use tracing::{debug, error};
use crate::db::entities::{ActiveModel, Entity, Model};
use crate::item::Item;
use crate::music::db::save_music_metadata;
pub async fn sync_items_to_db(
snapshot: &BTreeMap<INodeNo, Item>,
db_items: &HashMap<i64, Model>,
client: &sea_orm::DatabaseConnection,
) {
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));
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));
if item.music_metadata.is_some() {
to_save_music.push(ino_i64);
}
}
_ => {}
}
}
let fresh_inodes: HashSet<i64> = snapshot.keys().map(|i| i.0 as i64).collect();
for ino in db_items.keys().filter(|i| !fresh_inodes.contains(i)) {
to_delete.push(*ino);
}
debug!(
insert = to_insert.len(),
update = to_update.len(),
delete = to_delete.len(),
save_music = to_save_music.len(),
"sync_items_to_db"
);
if !to_insert.is_empty() {
if let Err(e) = Entity::insert_many(to_insert).exec(client).await {
error!(error = %e, "sync: insert_many failed");
}
}
for model in to_update {
if let Err(e) = model.update(client).await {
error!(error = %e, "sync: update failed");
}
}
for ino in to_delete {
if let Err(e) = Entity::delete_by_id(ino).exec(client).await {
error!(%ino, error = %e, "sync: delete failed");
}
}
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())
{
if let Err(e) = save_music_metadata(*ino_i64, music_metadata, client).await {
error!(ino = *ino_i64, error = %e, "sync: save_music_metadata failed");
}
}
}
}
pub fn run_db_blocking<F: std::future::Future>(future: F) -> F::Output {
return tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.unwrap_or_else(|e| {
error!(error = %e, "run_db_blocking: failed to build runtime");
panic!("run_db_blocking: failed to build runtime: {e}");
})
.block_on(future);
}
+146
View File
@@ -0,0 +1,146 @@
use std::{
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};
use std::os::unix::ffi::OsStrExt;
use fuser::INodeNo;
use crate::music::metadata::MusicMetadata;
use crate::origins::attrs::FileAttrs;
#[derive(PartialEq, Eq, Copy, Clone, Debug)]
pub enum FileType {
Directory,
File,
}
#[derive(Debug, Clone)]
pub struct Item {
pub inode: INodeNo,
pub parent_inode: INodeNo,
pub name: String,
pub original_path: PathBuf,
pub local_path: PathBuf,
pub file_type: FileType,
pub attrs: FileAttrs,
pub music_metadata: Option<MusicMetadata>,
pub hash: u64,
}
fn secs_since_epoch(time: SystemTime) -> u64 {
return time
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
}
impl Item {
#[allow(clippy::too_many_arguments)]
pub fn new(
inode: INodeNo,
parent_inode: INodeNo,
name: String,
original_path: PathBuf,
local_path: PathBuf,
file_type: FileType,
attrs: FileAttrs,
music_metadata: Option<MusicMetadata>,
) -> Item {
let mut item = Item {
inode,
parent_inode,
name,
original_path,
local_path,
file_type,
attrs,
music_metadata,
hash: 0,
};
item.hash = item.compute_hash();
return item;
}
pub fn compute_hash(&self) -> u64 {
musicfs_core::compute_item_hash(
self.inode.0,
self.original_path.as_os_str().as_bytes(),
secs_since_epoch(self.attrs.ctime),
secs_since_epoch(self.attrs.mtime),
secs_since_epoch(self.attrs.crtime),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::origins::attrs::FileAttrs;
#[test]
fn compute_hash_deterministic() {
let attrs = file_attrs_for_tempdir();
let item1 = Item::new(
INodeNo(42),
INodeNo::ROOT,
"test".to_string(),
PathBuf::from("/some/path"),
PathBuf::from("test"),
FileType::File,
attrs.clone(),
None,
);
let item2 = Item::new(
INodeNo(42),
INodeNo::ROOT,
"test".to_string(),
PathBuf::from("/some/path"),
PathBuf::from("test"),
FileType::File,
attrs,
None,
);
assert_eq!(item1.hash, item2.hash);
}
#[test]
fn compute_hash_changes_on_path_change() {
let attrs = file_attrs_for_tempdir();
let item1 = Item::new(
INodeNo(42),
INodeNo::ROOT,
"test".to_string(),
PathBuf::from("/some/path"),
PathBuf::from("test"),
FileType::File,
attrs.clone(),
None,
);
let item2 = Item::new(
INodeNo(42),
INodeNo::ROOT,
"test".to_string(),
PathBuf::from("/different/path"),
PathBuf::from("test"),
FileType::File,
attrs,
None,
);
assert_ne!(item1.hash, item2.hash);
}
fn file_attrs_for_tempdir() -> FileAttrs {
let tmp = tempfile::tempdir().unwrap();
let metadata = std::fs::metadata(tmp.path()).unwrap();
return FileAttrs::from(&metadata);
}
}
+8
View File
@@ -0,0 +1,8 @@
pub mod db;
pub mod item;
pub mod music;
pub mod origins;
pub mod virtual_dirs;
pub use musicfs_core::logging;
pub use musicfs_proto as proto;
+151
View File
@@ -0,0 +1,151 @@
use std::{collections::HashMap, path::Path, sync::Arc};
use clap::Parser;
use fuser::INodeNo;
use musicfs::db::entities::{Entity, Model};
use musicfs::db::sync::sync_items_to_db;
use musicfs::item::Item;
use musicfs::logging::{LogConfig, init};
use musicfs::music::db::restore_music_metadata_from_db;
use musicfs::origins::local::LocalOrigin;
use musicfs::origins::network::NetworkOrigin;
use musicfs::origins::{FuseFs, Origin};
use musicfs::virtual_dirs::restore_virtual_paths;
use sea_orm::entity::prelude::*;
use tokio::signal::unix::{SignalKind, signal};
use tracing::{debug, error, info};
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
#[arg(short, long, required = true)]
mountpoint: String,
/// Local directory path (→ LocalOrigin) OR `http://host:port` URL of a
/// musicfs-server (→ NetworkOrigin).
#[arg(short, long, required = true)]
source: String,
#[arg(short, long, required = true)]
database: String,
/// Directory for daily-rotated log files.
#[arg(long, default_value = "./logs")]
log_dir: std::path::PathBuf,
}
#[tokio::main]
async fn main() {
let args = Args::parse();
// Bind the guard for the whole process so the non-blocking file writer
// flushes on exit. Initialized before anything else so even early
// failures land in the log.
let _guard = init(LogConfig {
log_dir: args.log_dir.clone(),
file_prefix: "musicfs".to_string(),
max_files: 7,
});
let mountpoint = args.mountpoint.clone();
info!(
mountpoint = %mountpoint,
source = %args.source,
database = %args.database,
log_dir = %args.log_dir.display(),
"musicfs starting"
);
// sea-orm logs every statement at INFO by default, which floods normal
// output; demote to DEBUG so queries stay hidden under RUST_LOG=info.
let mut db_opts = sea_orm::ConnectOptions::new(args.database.clone());
db_opts.sqlx_logging_level(log::LevelFilter::Debug);
let db = sea_orm::Database::connect(db_opts)
.await
.unwrap_or_else(|e| {
error!(database = %args.database, error = %e, "database connect failed");
panic!("database connect: {e}");
});
info!(database = %args.database, "database connected");
let (snapshot, byte_source, watcher) = if looks_like_url(&args.source) {
debug!(source = %args.source, "using NetworkOrigin");
let origin = NetworkOrigin::new(args.source.clone(), mountpoint.clone(), db.clone())
.unwrap_or_else(|e| {
error!(source = %args.source, error = %e, "network origin init failed");
panic!("network origin init: {e}");
});
// Await directly on this runtime; the sync `snapshot()` wrapper would
// try to build a nested runtime and panic (we're inside #[tokio::main]).
let snapshot = origin.snapshot_async().await.unwrap_or_else(|e| {
error!(source = %args.source, error = %e, "network initial snapshot failed");
panic!("network initial snapshot: {e}");
});
info!(files = snapshot.len(), "network snapshot complete");
(snapshot, origin.byte_source(), origin.watcher())
} else {
debug!(source = %args.source, "using LocalOrigin");
let origin = LocalOrigin::new(args.source.clone(), mountpoint.clone());
let mut snapshot = origin.snapshot().unwrap_or_else(|e| {
error!(source = %args.source, error = %e, "local initial snapshot failed");
panic!("local initial snapshot: {e}");
});
let db_items: HashMap<i64, Model> = Entity::find()
.all(&db)
.await
.unwrap_or_else(|e| {
error!(error = %e, "loading db items failed");
panic!("loading db items: {e}");
})
.into_iter()
.map(|e| (e.inode, e))
.collect();
sync_items_to_db(&snapshot, &db_items, &db).await;
restore_music_metadata_from_db(&mut snapshot, &db_items, &db).await;
restore_virtual_paths(&mut snapshot, &db_items, Path::new(&args.source));
info!(files = snapshot.len(), "local snapshot complete");
(snapshot, origin.byte_source(), origin.watcher())
};
let files: Arc<std::sync::Mutex<std::collections::BTreeMap<INodeNo, Item>>> =
Arc::new(std::sync::Mutex::new(snapshot));
let watcher_handle = watcher.watch(files.clone());
let fs = FuseFs {
files,
bytes: byte_source,
client: db,
runtime_handle: tokio::runtime::Handle::current(),
};
let cfg = fuser::Config::default();
let session = fuser::spawn_mount2(fs, &mountpoint, &cfg).unwrap_or_else(|e| {
error!(mountpoint = %mountpoint, error = %e, "failed to mount FUSE filesystem");
panic!("failed to mount FUSE filesystem: {e}");
});
info!(mountpoint = %mountpoint, "FUSE mounted");
let mut sigint = signal(SignalKind::interrupt()).unwrap_or_else(|e| {
error!(error = %e, "failed to register SIGINT handler");
panic!("register SIGINT handler: {e}");
});
let mut sigterm = signal(SignalKind::terminate()).unwrap_or_else(|e| {
error!(error = %e, "failed to register SIGTERM handler");
panic!("register SIGTERM handler: {e}");
});
tokio::select! {
_ = sigint.recv() => info!("received SIGINT, shutting down"),
_ = sigterm.recv() => info!("received SIGTERM, shutting down"),
}
info!(mountpoint = %mountpoint, "unmounting");
watcher_handle.stop();
drop(session);
}
fn looks_like_url(s: &str) -> bool {
return s.starts_with("http://") || s.starts_with("https://");
}
+292
View File
@@ -0,0 +1,292 @@
use std::collections::{BTreeMap, HashMap};
use fuser::INodeNo;
use sea_orm::entity::prelude::*;
use crate::db::entities::Model;
use crate::item::Item;
use crate::music::metadata::MusicMetadata;
use tracing::{debug, error};
mod entities {
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 {}
}
}
use entities::{artists, music_metadata as music_metadata_entity, other_tags, pictures};
pub 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.clone()),
data_offset: Set(*offset as i64),
data_length: Set(*len as i64),
}),
)
.exec(client)
.await?;
}
return Ok(());
}
pub async fn restore_music_metadata_from_db(
snapshot: &mut BTreeMap<INodeNo, Item>,
db_items: &HashMap<i64, Model>,
client: &sea_orm::DatabaseConnection,
) {
use artists::Column as ArtCol;
use music_metadata_entity::Column as MmCol;
use other_tags::Column as OtCol;
use pictures::Column as PicCol;
let unchanged_music_inodes: Vec<i64> = db_items
.values()
.filter_map(|db_item| {
let ino = INodeNo(db_item.inode as u64);
snapshot
.get(&ino)
.filter(|item| item.hash as i64 == db_item.hash && item.music_metadata.is_some())
.map(|_| db_item.inode)
})
.collect();
if unchanged_music_inodes.is_empty() {
return;
}
let mm_rows: HashMap<i64, music_metadata_entity::Model> =
match music_metadata_entity::Entity::find()
.filter(MmCol::Inode.is_in(unchanged_music_inodes.clone()))
.all(client)
.await
{
Ok(rows) => rows.into_iter().map(|m| (m.inode, m)).collect(),
Err(e) => {
error!(error = %e, "restore_music_metadata: mm rows query failed");
return;
}
};
let mut artists_by_inode: HashMap<i64, Vec<String>> = HashMap::new();
let artist_rows = match artists::Entity::find()
.filter(ArtCol::Inode.is_in(unchanged_music_inodes.clone()))
.all(client)
.await
{
Ok(r) => r,
Err(e) => {
error!(error = %e, "restore_music_metadata: artists query failed");
vec![]
}
};
for row in artist_rows {
artists_by_inode
.entry(row.inode)
.or_default()
.push(row.artist);
}
let mut other_tags_by_inode: HashMap<i64, Vec<(i32, String)>> = HashMap::new();
let other_tag_rows = match other_tags::Entity::find()
.filter(OtCol::Inode.is_in(unchanged_music_inodes.clone()))
.all(client)
.await
{
Ok(r) => r,
Err(e) => {
error!(error = %e, "restore_music_metadata: other_tags query failed");
vec![]
}
};
for row in other_tag_rows {
other_tags_by_inode
.entry(row.inode)
.or_default()
.push((row.position, row.tag));
}
let mut pictures_by_inode: HashMap<i64, Vec<pictures::Model>> = HashMap::new();
let picture_rows = match pictures::Entity::find()
.filter(PicCol::Inode.is_in(unchanged_music_inodes))
.all(client)
.await
{
Ok(r) => r,
Err(e) => {
error!(error = %e, "restore_music_metadata: pictures query failed");
vec![]
}
};
for row in picture_rows {
pictures_by_inode.entry(row.inode).or_default().push(row);
}
debug!(restored = mm_rows.len(), "restored music metadata from db");
for (inode, mm_row) in mm_rows {
let ino = INodeNo(inode as u64);
let Some(item) = snapshot.get_mut(&ino) else {
continue;
};
let mut sorted_tags = other_tags_by_inode.remove(&inode).unwrap_or_default();
sorted_tags.sort_by_key(|(pos, _)| *pos);
let mut sorted_pics = pictures_by_inode.remove(&inode).unwrap_or_default();
sorted_pics.sort_by_key(|p| p.position);
let picture_block_headers: Vec<Vec<u8>> =
sorted_pics.iter().map(|p| p.block_header.clone()).collect();
let picture_data_ranges: Vec<(u64, u64)> = sorted_pics
.iter()
.map(|p| (p.data_offset as u64, p.data_length as u64))
.collect();
let mut music_metadata = MusicMetadata {
artist: artists_by_inode.remove(&inode).unwrap_or_default(),
album_artist: None,
album: mm_row.album,
track_number: mm_row.track_number,
track_title: mm_row.track_title,
other_tags: sorted_tags.into_iter().map(|(_, tag)| tag).collect(),
header: mm_row.header,
picture_block_headers,
picture_data_ranges,
real_audio_start: mm_row.real_audio_start as u64,
vorbis_comment_offset: 0,
vorbis_comment_length: 0,
};
music_metadata.find_vorbis_offsets();
item.music_metadata = Some(music_metadata);
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod db;
pub use musicfs_core::music::{metadata, parse};
@@ -0,0 +1,215 @@
use std::{
fs,
io::{self, Read, Seek, SeekFrom},
path::Path,
};
pub 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);
return Ok(buf);
}
fn assemble_virtual_read(
reader: &dyn Fn(u64, usize) -> io::Result<Vec<u8>>,
header: &[u8],
pic_hdrs: &[Vec<u8>],
pic_ranges: &[(u64, u64)],
real_audio_start: u64,
offset: u64,
size: u32,
) -> io::Result<Vec<u8>> {
let end = offset + size as u64;
let header_end = header.len() as u64;
if end <= header_end {
return Ok(header[offset as usize..end as usize].to_vec());
}
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_len = pic_hdr.len() as u64;
let pic_hdr_end = virt_pos + pic_hdr_len;
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(pic_hdr.len())]);
}
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);
let bytes = reader(real_off, (data_end - data_start) as usize)?;
buf.extend_from_slice(&bytes);
}
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);
let bytes = reader(real_off, (end - audio_start) as usize)?;
buf.extend_from_slice(&bytes);
}
return Ok(buf);
}
pub fn assemble_flac_read(
reader: &dyn Fn(u64, usize) -> io::Result<Vec<u8>>,
header: &[u8],
pic_hdrs: &[Vec<u8>],
pic_ranges: &[(u64, u64)],
real_audio_start: u64,
offset: u64,
size: u32,
) -> io::Result<Vec<u8>> {
assemble_virtual_read(
reader,
header,
pic_hdrs,
pic_ranges,
real_audio_start,
offset,
size,
)
}
pub fn assemble_mp3_read(
reader: &dyn Fn(u64, usize) -> io::Result<Vec<u8>>,
header: &[u8],
pic_hdrs: &[Vec<u8>],
pic_ranges: &[(u64, u64)],
real_audio_start: u64,
offset: u64,
size: u32,
) -> io::Result<Vec<u8>> {
assemble_virtual_read(
reader,
header,
pic_hdrs,
pic_ranges,
real_audio_start,
offset,
size,
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::path::PathBuf;
fn file_reader(path: PathBuf) -> impl Fn(u64, usize) -> io::Result<Vec<u8>> {
return move |offset, len| read_bytes_at(&path, offset, len);
}
#[test]
fn read_bytes_at_full_file() {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(b"hello world").unwrap();
f.flush().unwrap();
let path = f.path();
let result = read_bytes_at(path, 0, 11).unwrap();
assert_eq!(result, b"hello world");
}
#[test]
fn read_bytes_at_offset() {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(b"abcdefghij").unwrap();
f.flush().unwrap();
let path = f.path();
let result = read_bytes_at(path, 3, 4).unwrap();
assert_eq!(result, b"defg");
}
#[test]
fn assemble_flac_read_header_only() {
let header = b"HEADERDATA";
let noop = |_, _| Ok(vec![]);
let result = assemble_flac_read(&noop, header, &[], &[], 0, 2, 4).unwrap();
assert_eq!(result, b"ADER");
}
#[test]
fn assemble_flac_read_audio_region() {
let mut f = tempfile::NamedTempFile::new().unwrap();
let content: Vec<u8> = (0..20).collect();
f.write_all(&content).unwrap();
f.flush().unwrap();
let path = f.path().to_path_buf();
let reader = file_reader(path);
let header = b"HDR";
let result = assemble_flac_read(&reader, header, &[], &[], 10, 5, 4).unwrap();
// offset=5, header_len=3, so we read from header[3..] (0 bytes) + audio at real_off=10+(5-3)=12
// content[12..16] = [12, 13, 14, 15]
assert_eq!(result, vec![12, 13, 14, 15]);
}
#[test]
fn assemble_flac_read_header_audio_boundary() {
let mut f = tempfile::NamedTempFile::new().unwrap();
let content: Vec<u8> = (0..110).collect();
f.write_all(&content).unwrap();
f.flush().unwrap();
let path = f.path().to_path_buf();
let reader = file_reader(path);
let header = b"ABCD";
let result = assemble_flac_read(&reader, header, &[], &[], 100, 2, 6).unwrap();
// offset=2, size=6, header_len=4
// First 2 bytes from header[2..4] = "CD"
// Remaining 4 bytes from audio at real_off=100+(2+2-4)=100
// content[100..104] = [100, 101, 102, 103]
let expected = [b'C', b'D', 100, 101, 102, 103];
assert_eq!(result, expected);
}
#[test]
fn assemble_mp3_read_header_only() {
let header = b"ID3\x04\x00DATAHERE";
let noop = |_, _| Ok(vec![]);
let result = assemble_mp3_read(&noop, header, &[], &[], 0, 4, 4).unwrap();
assert_eq!(result, &header[4..8]);
}
#[test]
fn assemble_mp3_read_audio_region() {
let mut f = tempfile::NamedTempFile::new().unwrap();
let content: Vec<u8> = (0..20).collect();
f.write_all(&content).unwrap();
f.flush().unwrap();
let path = f.path().to_path_buf();
let reader = file_reader(path);
let header = b"ID3";
let result = assemble_mp3_read(&reader, header, &[], &[], 10, 5, 4).unwrap();
assert_eq!(result, vec![12, 13, 14, 15]);
}
}
@@ -0,0 +1,73 @@
pub mod file_io;
pub mod snapshot;
pub mod watcher;
use std::{
collections::BTreeMap,
io,
path::{Path, PathBuf},
sync::Arc,
};
use fuser::INodeNo;
use crate::item::Item;
use crate::origins::{ByteSource, FileWatcher, Origin};
pub struct LocalOrigin {
pub(crate) source: PathBuf,
pub(crate) destination: PathBuf,
}
impl std::fmt::Debug for LocalOrigin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return f
.debug_struct("LocalOrigin")
.field("source", &self.source)
.field("destination", &self.destination)
.finish();
}
}
impl LocalOrigin {
pub fn new(source: String, destination: String) -> LocalOrigin {
return LocalOrigin {
source: source.into(),
destination: destination.into(),
};
}
pub fn source(&self) -> &Path {
return &self.source;
}
}
impl Origin for LocalOrigin {
fn snapshot(&self) -> io::Result<BTreeMap<INodeNo, Item>> {
return snapshot::build_snapshot(&self.source, &self.destination);
}
fn byte_source(&self) -> Arc<dyn ByteSource> {
return Arc::new(LocalByteSource);
}
fn watcher(&self) -> Box<dyn FileWatcher> {
return Box::new(watcher::LocalOriginFileWatcher::new(
self.source.clone(),
self.destination.clone(),
));
}
}
pub struct LocalByteSource;
impl ByteSource for LocalByteSource {
fn read_at(
&self,
_inode: INodeNo,
locator: &Path,
offset: u64,
len: usize,
) -> io::Result<Vec<u8>> {
return file_io::read_bytes_at(locator, offset, len);
}
}
@@ -0,0 +1,110 @@
use std::{
collections::BTreeMap,
fs, io,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use fuser::INodeNo;
use std::os::unix::fs::MetadataExt;
use crate::item::{FileType, Item};
use crate::music::parse::parse_music_metadata_for_path;
use crate::origins::attrs::FileAttrs;
use crate::virtual_dirs::ensure_virtual_dirs;
use tracing::{error, info};
pub fn fill_fileset(map: &Arc<Mutex<BTreeMap<INodeNo, Item>>>, source: &Path, destination: &Path) {
match build_snapshot(source, destination) {
Ok(new_snapshot) => {
let count = new_snapshot.len();
let mut files = map.lock().unwrap();
files.retain(|ino, _| new_snapshot.contains_key(ino));
for (ino, new_item) in new_snapshot {
match files.get(&ino) {
Some(existing) if existing.hash == new_item.hash => {}
_ => {
files.insert(ino, new_item);
}
}
}
info!(count, "snapshot rebuilt");
}
Err(e) => error!(source = %source.display(), error = %e, "error while reading source"),
}
}
pub fn build_snapshot(
source: &Path,
destination: &Path,
) -> Result<BTreeMap<INodeNo, Item>, io::Error> {
let mut map = BTreeMap::new();
let local_root = Item::new(
INodeNo::ROOT,
INodeNo::ROOT,
"/".to_string(),
source.to_path_buf(),
destination.to_path_buf(),
FileType::Directory,
FileAttrs::from(&fs::metadata(source)?),
None,
);
map.insert(INodeNo::ROOT, local_root);
read_into_map(source, destination, &mut map)?;
return Ok(map);
}
pub fn read_into_map(
source: &Path,
destination: &Path,
map: &mut BTreeMap<INodeNo, Item>,
) -> Result<(), io::Error> {
for item in fs::read_dir(source)? {
let entry = item?;
let item_path = entry.path();
if entry.file_type()?.is_dir() {
read_into_map(&item_path, destination, map)?;
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let metadata = entry.metadata()?;
let music_metadata = parse_music_metadata_for_path(&item_path);
let mut local_path = PathBuf::new();
if let Some(ref mm) = music_metadata {
let joined;
let artist_dir = match mm.album_artist.as_deref() {
Some(a) => a,
None => {
joined = mm.artist.join("-");
&joined
}
};
local_path.push(artist_dir);
local_path.push(&mm.album);
}
local_path.push(&name);
let inode = INodeNo(metadata.ino());
let parent_inode = ensure_virtual_dirs(&local_path, source, map);
let local_item = Item::new(
inode,
parent_inode,
name,
item_path,
local_path,
FileType::File,
FileAttrs::from(&metadata),
music_metadata,
);
map.insert(inode, local_item);
}
return Ok(());
}
@@ -0,0 +1,80 @@
use std::{
collections::BTreeMap,
path::PathBuf,
sync::{Arc, Mutex, mpsc},
thread,
};
use fuser::INodeNo;
use notify::{Event, EventKind, RecursiveMode, Watcher};
use crate::item::Item;
use crate::origins::{FileWatcher, WatcherHandle};
use tracing::{debug, error, info, trace};
pub struct LocalOriginFileWatcher {
source: PathBuf,
destination: PathBuf,
}
impl LocalOriginFileWatcher {
pub fn new(source: PathBuf, destination: PathBuf) -> Self {
return LocalOriginFileWatcher {
source,
destination,
};
}
}
impl FileWatcher for LocalOriginFileWatcher {
fn watch(&self, files: Arc<Mutex<BTreeMap<INodeNo, Item>>>) -> WatcherHandle {
let source = self.source.clone();
let destination = self.destination.clone();
info!(source = %source.display(), "starting file watcher");
thread::spawn(move || {
let (tx, rx): (
mpsc::Sender<Result<Event, notify::Error>>,
mpsc::Receiver<Result<Event, notify::Error>>,
) = mpsc::channel();
let mut watcher: notify::INotifyWatcher = match notify::recommended_watcher(tx) {
Ok(w) => w,
Err(e) => {
error!(error = %e, "watcher: failed to create notify watcher");
return;
}
};
if let Err(e) = watcher.watch(&source, RecursiveMode::Recursive) {
error!(source = %source.display(), error = %e, "watcher: failed to watch source");
return;
}
for res in rx {
match res {
Ok(event) => {
trace!(?event, "watcher event");
match event.kind {
EventKind::Any => {
debug!("watcher: EventKind::Any, ignoring");
}
EventKind::Access(_access_kind) => {
debug!("watcher: item accessed");
}
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
debug!("watcher: create/modify/remove; rebuilding snapshot");
super::snapshot::fill_fileset(&files, &source, &destination);
}
EventKind::Other => {
debug!("watcher: other event, ignoring");
}
}
}
Err(e) => error!(error = %e, "watcher: notify error"),
}
}
});
return WatcherHandle::detached();
}
}
+631
View File
@@ -0,0 +1,631 @@
pub use musicfs_core::attrs;
pub mod local;
pub mod network;
use std::{
collections::BTreeMap,
io,
path::Path,
sync::{Arc, Mutex},
time::Duration,
};
use fuser::{Errno, FileAttr, Filesystem, Generation, INodeNo, Request};
use sea_orm::{ActiveModelTrait, DatabaseConnection};
use tracing::{debug, error, trace};
use crate::item::{FileType, Item};
use crate::music::db::save_music_metadata;
use crate::origins::local::file_io;
use crate::virtual_dirs::parent_inode_from_path;
/// Transport-agnostic byte-range reader. `locator` is whatever string the
/// origin treats as a file key: a filesystem path for `LocalOrigin`, a remote
/// key for the future network origin. Both produce bytes at `(offset, len)`.
///
/// `inode` is the FUSE-level inode; NetworkOrigin uses it as the server-side
/// file id (and as the Postgres cache key). LocalOrigin ignores it.
pub trait ByteSource: Send + Sync {
fn read_at(
&self,
inode: INodeNo,
locator: &Path,
offset: u64,
len: usize,
) -> io::Result<Vec<u8>>;
}
pub trait FileWatcher: Send + Sync {
fn watch(&self, files: Arc<Mutex<BTreeMap<INodeNo, Item>>>) -> WatcherHandle;
}
/// Returned by [`FileWatcher::watch`] so the caller can stop a background
/// watcher before the process (and its tokio runtime) shuts down. Without
/// this, an async watcher blocked on a timer/stream panics when the runtime is
/// torn down out from under it.
pub struct WatcherHandle {
stop: Option<Box<dyn FnOnce() + Send>>,
}
impl WatcherHandle {
pub fn new(stop: impl FnOnce() + Send + 'static) -> Self {
return WatcherHandle {
stop: Some(Box::new(stop)),
};
}
/// A watcher with no shutdown work — its thread exits on its own.
pub fn detached() -> Self {
return WatcherHandle { stop: None };
}
/// Signal the watcher to stop and wait for it to finish.
pub fn stop(mut self) {
if let Some(stop) = self.stop.take() {
stop();
}
}
}
pub trait Origin: Send + Sync {
fn snapshot(&self) -> io::Result<BTreeMap<INodeNo, Item>>;
fn byte_source(&self) -> Arc<dyn ByteSource>;
fn watcher(&self) -> Box<dyn FileWatcher>;
}
pub struct FuseFs {
pub files: Arc<Mutex<BTreeMap<INodeNo, Item>>>,
pub bytes: Arc<dyn ByteSource>,
pub client: DatabaseConnection,
pub runtime_handle: tokio::runtime::Handle,
}
pub(crate) fn file_to_attr(item: &Item) -> FileAttr {
let attrs = &item.attrs;
let kind = match item.file_type {
FileType::Directory => fuser::FileType::Directory,
FileType::File => fuser::FileType::RegularFile,
};
let size = match &item.music_metadata {
Some(mm) if !mm.header.is_empty() => mm.virtual_size(attrs.size),
_ => attrs.size,
};
return FileAttr {
ino: item.inode,
size,
blocks: attrs.blocks,
atime: attrs.atime,
mtime: attrs.mtime,
ctime: attrs.ctime,
crtime: attrs.crtime,
kind,
perm: attrs.perm,
nlink: attrs.nlink,
uid: attrs.uid,
gid: attrs.gid,
rdev: attrs.rdev,
blksize: attrs.blksize,
flags: 0,
};
}
impl Filesystem for FuseFs {
fn open(
&self,
_req: &Request,
ino: INodeNo,
_flags: fuser::OpenFlags,
reply: fuser::ReplyOpen,
) {
trace!(%ino, "open");
if self.files.lock().unwrap().contains_key(&ino) {
reply.opened(fuser::FileHandle(ino.0), fuser::FopenFlags::empty());
} else {
debug!(%ino, "open: not found");
reply.error(Errno::ENOENT);
}
}
fn setattr(
&self,
_req: &Request,
ino: INodeNo,
_mode: Option<u32>,
_uid: Option<u32>,
_gid: Option<u32>,
_size: Option<u64>,
_atime: Option<fuser::TimeOrNow>,
_mtime: Option<fuser::TimeOrNow>,
_ctime: Option<std::time::SystemTime>,
_fh: Option<fuser::FileHandle>,
_crtime: Option<std::time::SystemTime>,
_chgtime: Option<std::time::SystemTime>,
_bkuptime: Option<std::time::SystemTime>,
_flags: Option<fuser::BsdFileFlags>,
reply: fuser::ReplyAttr,
) {
trace!(%ino, "setattr");
match self.files.lock().unwrap().get(&ino) {
Some(file) => reply.attr(&Duration::new(1, 0), &file_to_attr(file)),
None => {
debug!(%ino, "setattr: not found");
reply.error(Errno::ENOENT);
}
}
}
fn write(
&self,
_req: &Request,
ino: INodeNo,
_fh: fuser::FileHandle,
offset: u64,
data: &[u8],
_write_flags: fuser::WriteFlags,
_flags: fuser::OpenFlags,
_lock_owner: Option<fuser::LockOwner>,
reply: fuser::ReplyWrite,
) {
trace!(%ino, offset, len = data.len(), "write");
let written = data.len() as u32;
let write_start = offset;
let write_end = write_start + data.len() as u64;
let updated_music_metadata = {
let mut files = self.files.lock().unwrap();
let item = match files.get_mut(&ino) {
Some(item) => item,
None => {
debug!(%ino, "write: not found");
reply.written(written);
return;
}
};
match &mut item.music_metadata {
Some(mm) if mm.vorbis_comment_length > 0 => {
let vc_data_offset = mm.vorbis_comment_offset;
let vc_hdr_offset = vc_data_offset - 4;
if write_start <= vc_hdr_offset && write_end >= vc_data_offset {
let hdr_from = (vc_hdr_offset - write_start) as usize;
let new_length = u32::from_be_bytes([
0,
data[hdr_from + 1],
data[hdr_from + 2],
data[hdr_from + 3],
]) as u64;
let vc_data_end = vc_data_offset + new_length;
if write_end >= vc_data_end {
let from = (vc_data_offset - write_start) as usize;
let to = (vc_data_end - write_start) as usize;
mm.update_from_vorbis_comment_data(&data[from..to]);
debug!(%ino, "write: vorbis comment tag update detected");
Some(mm.clone())
} else {
None
}
} else {
None
}
}
Some(mm)
if !mm.header.is_empty()
&& write_start == 0
&& data.len() >= 3
&& &data[0..3] == b"ID3" =>
{
mm.update_from_id3_data(data);
debug!(%ino, "write: ID3v2 tag update detected");
Some(mm.clone())
}
Some(mm) if mm.header.is_empty() && data.len() == 128 && &data[0..3] == b"TAG" => {
mm.update_from_id3v1_data(data);
debug!(%ino, "write: ID3v1 tag update detected");
Some(mm.clone())
}
_ => None,
}
};
if let Some(music_metadata) = updated_music_metadata {
let client = self.client.clone();
let ino_i64 = ino.0 as i64;
self.runtime_handle.block_on(async move {
if let Err(e) = save_music_metadata(ino_i64, &music_metadata, &client).await {
error!(ino = ino_i64, error = %e, "write: save_music_metadata failed");
} else {
debug!(ino = ino_i64, "write: persisted updated music metadata");
}
});
}
reply.written(written);
}
fn getattr(
&self,
_req: &fuser::Request,
ino: INodeNo,
_fh: Option<fuser::FileHandle>,
reply: fuser::ReplyAttr,
) {
trace!(%ino, "getattr");
match self.files.lock().unwrap().get(&ino) {
Some(file) => {
let ttl = Duration::new(1, 0);
let attr = file_to_attr(file);
reply.attr(&ttl, &attr);
}
None => {
debug!(%ino, "getattr: not found");
reply.error(Errno::ENOENT);
}
}
}
fn readdir(
&self,
_req: &fuser::Request,
ino: INodeNo,
fh: fuser::FileHandle,
offset: u64,
mut reply: fuser::ReplyDirectory,
) {
trace!(%ino, %fh, offset, "readdir");
let files = self.files.lock().unwrap();
let parent_inode = match files.get(&ino) {
Some(dir) => dir.parent_inode,
None => {
debug!(%ino, "readdir: not found");
reply.error(Errno::ENOENT);
return;
}
};
if offset < 1 {
if reply.add(ino, 1, fuser::FileType::Directory, ".") {
reply.ok();
return;
}
}
if offset < 2 {
if reply.add(parent_inode, 2, fuser::FileType::Directory, "..") {
reply.ok();
return;
}
}
let skip_count = if offset <= 2 {
0
} else {
(offset - 2) as usize
};
for (i, (key, value)) in files
.iter()
.filter(|(_, v)| v.parent_inode == ino && v.inode != ino)
.skip(skip_count)
.enumerate()
{
let entry_offset = (skip_count + i + 3) as u64;
let file_type = match value.file_type {
FileType::Directory => fuser::FileType::Directory,
FileType::File => fuser::FileType::RegularFile,
};
if reply.add(*key, entry_offset, file_type, &value.name) {
break;
}
}
reply.ok();
}
fn rename(
&self,
_req: &Request,
parent: INodeNo,
name: &std::ffi::OsStr,
newparent: INodeNo,
newname: &std::ffi::OsStr,
_flags: fuser::RenameFlags,
reply: fuser::ReplyEmpty,
) {
trace!(
%parent,
name = %name.display(),
%newparent,
newname = %newname.display(),
"rename"
);
let name_str = match name.to_str() {
Some(s) => s,
None => {
error!(%parent, "rename: source name is not valid UTF-8");
reply.error(Errno::EINVAL);
return;
}
};
let newname_str = match newname.to_str() {
Some(s) => s,
None => {
error!(%newparent, "rename: target name is not valid UTF-8");
reply.error(Errno::EINVAL);
return;
}
};
let mut files = self.files.lock().unwrap();
let item_ino = match files
.iter()
.find(|(_, v)| v.parent_inode == parent && v.name == name_str)
{
Some((ino, _)) => *ino,
None => {
debug!(%parent, name = %name_str, "rename: source not found");
reply.error(Errno::ENOENT);
return;
}
};
let new_local_path = if newparent == INodeNo::ROOT {
std::path::PathBuf::from(newname_str)
} else {
match files.get(&newparent) {
Some(dir) => dir.local_path.join(newname_str),
None => {
debug!(%newparent, "rename: target parent not found");
reply.error(Errno::ENOENT);
return;
}
}
};
debug!(ino = %item_ino, from = %name_str, to = %newname_str, "rename");
let new_parent_inode = parent_inode_from_path(&new_local_path);
let item = files.get_mut(&item_ino).unwrap();
item.name = newname_str.to_string();
item.local_path = new_local_path.clone();
item.parent_inode = new_parent_inode;
let inode_i64 = item_ino.0 as i64;
let new_name_owned = newname_str.to_string();
let new_local_path_str = new_local_path.to_string_lossy().into_owned();
let client = self.client.clone();
drop(files);
self.runtime_handle.block_on(async move {
use sea_orm::ActiveValue::Set;
if let Err(e) = (crate::db::entities::ActiveModel {
inode: Set(inode_i64),
name: Set(new_name_owned),
local_path: Set(new_local_path_str),
..Default::default()
}
.update(&client)
.await)
{
error!(ino = inode_i64, error = %e, "rename: db update failed");
}
});
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,
) {
trace!(%ino, offset, size, "read");
let (inode, locator, virtual_layout) = {
let files = self.files.lock().unwrap();
let item = match files.get(&ino) {
Some(f) => f,
None => {
debug!(%ino, "read: not found");
reply.error(Errno::ENOENT);
return;
}
};
let inode = item.inode;
let locator = item.original_path.clone();
let virtual_layout = item
.music_metadata
.as_ref()
.filter(|mm| !mm.header.is_empty())
.map(|mm| {
(
mm.header.starts_with(b"ID3"),
mm.header.clone(),
mm.picture_block_headers.clone(),
mm.picture_data_ranges.clone(),
mm.real_audio_start,
)
});
(inode, locator, virtual_layout)
};
let Some((is_mp3, header, pic_hdrs, pic_ranges, real_audio_start)) = virtual_layout else {
match self.bytes.read_at(inode, &locator, offset, size as usize) {
Ok(bytes) => reply.data(&bytes),
Err(e) => {
error!(%inode, offset, size, error = %e, "read: read_at failed; returning EIO");
reply.error(Errno::EIO);
}
}
return;
};
let bytes = &self.bytes;
let reader = |off: u64, len: usize| bytes.read_at(inode, &locator, off, len);
let result = if is_mp3 {
file_io::assemble_mp3_read(
&reader,
&header,
&pic_hdrs,
&pic_ranges,
real_audio_start,
offset,
size,
)
} else {
file_io::assemble_flac_read(
&reader,
&header,
&pic_hdrs,
&pic_ranges,
real_audio_start,
offset,
size,
)
};
match result {
Ok(bytes) => reply.data(&bytes),
Err(e) => {
error!(%inode, offset, size, error = %e, "read: assembly failed; returning EIO");
reply.error(Errno::EIO);
}
}
}
fn lookup(
&self,
_req: &Request,
parent: INodeNo,
name: &std::ffi::OsStr,
reply: fuser::ReplyEntry,
) {
trace!(%parent, name = %name.display(), "lookup");
match self
.files
.lock()
.unwrap()
.iter()
.find(|item| item.1.parent_inode == parent && item.1.name == name.to_str().unwrap())
{
Some(item) => {
let ttl = Duration::new(1, 0);
let attr = file_to_attr(item.1);
reply.entry(&ttl, &attr, Generation(0));
}
None => {
debug!(%parent, name = %name.display(), "lookup: not found");
reply.error(Errno::ENOENT);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::item::FileType;
use crate::music::metadata::MusicMetadata;
use crate::origins::attrs::FileAttrs;
use fuser::INodeNo;
use std::os::unix::fs::MetadataExt;
use std::path::PathBuf;
#[test]
fn file_to_attr_uses_real_size_for_non_flac() {
let tmp = tempfile::tempdir().unwrap();
let metadata = std::fs::metadata(tmp.path()).unwrap();
let attrs = FileAttrs::from(&metadata);
let item = Item::new(
INodeNo(42),
INodeNo::ROOT,
"test".to_string(),
tmp.path().to_path_buf(),
PathBuf::from("test"),
FileType::File,
attrs.clone(),
None,
);
let attr = file_to_attr(&item);
assert_eq!(attr.size, metadata.size());
}
#[test]
fn file_to_attr_uses_virtual_size_for_flac() {
let tmp = tempfile::tempdir().unwrap();
let test_file = tmp.path().join("test.flac");
std::fs::write(&test_file, vec![0u8; 1000]).unwrap();
let metadata = std::fs::metadata(&test_file).unwrap();
let attrs = FileAttrs::from(&metadata);
let mm = MusicMetadata {
real_audio_start: 500,
header: vec![0u8; 100],
picture_data_ranges: vec![(0, 50)],
..MusicMetadata::default()
};
let item = Item::new(
INodeNo(43),
INodeNo::ROOT,
"test_flac".to_string(),
test_file.clone(),
PathBuf::from("test_flac"),
FileType::File,
attrs.clone(),
Some(mm.clone()),
);
let attr = file_to_attr(&item);
let expected_virtual_size = mm.virtual_size(attrs.size);
assert_eq!(attr.size, expected_virtual_size);
}
#[test]
fn file_to_attr_kind_directory() {
let tmp = tempfile::tempdir().unwrap();
let metadata = std::fs::metadata(tmp.path()).unwrap();
let item = Item::new(
INodeNo(44),
INodeNo::ROOT,
"test_dir".to_string(),
tmp.path().to_path_buf(),
PathBuf::from("test_dir"),
FileType::Directory,
FileAttrs::from(&metadata),
None,
);
let attr = file_to_attr(&item);
assert_eq!(attr.kind, fuser::FileType::Directory);
}
#[test]
fn file_to_attr_kind_file() {
let tmp = tempfile::tempdir().unwrap();
let metadata = std::fs::metadata(tmp.path()).unwrap();
let item = Item::new(
INodeNo(45),
INodeNo::ROOT,
"test_file".to_string(),
tmp.path().to_path_buf(),
PathBuf::from("test_file"),
FileType::File,
FileAttrs::from(&metadata),
None,
);
let attr = file_to_attr(&item);
assert_eq!(attr.kind, fuser::FileType::RegularFile);
}
}
@@ -0,0 +1,373 @@
pub mod transport;
pub mod watcher;
use std::{
collections::BTreeMap,
io,
path::{Path, PathBuf},
sync::{Arc, RwLock},
time::{Duration, SystemTime},
};
use fuser::INodeNo;
use sea_orm::EntityTrait;
use crate::db::cache::{delete_cached_bytes_for, get_cached_bytes, put_cached_bytes};
use crate::db::entities as item_entities;
use crate::db::sync::{run_db_blocking, sync_items_to_db};
use crate::item::{FileType, Item};
use crate::music::db::restore_music_metadata_from_db;
use crate::music::metadata::MusicMetadata;
use crate::origins::attrs::FileAttrs;
use crate::origins::{ByteSource, FileWatcher, Origin};
use crate::proto::ManifestEntry as ProtoManifestEntry;
use crate::virtual_dirs::{ensure_virtual_dirs, restore_virtual_paths};
use tracing::{debug, error, info, trace};
use self::transport::NetworkTransport;
pub struct NetworkOrigin {
pub(crate) endpoint: String,
pub(crate) destination: PathBuf,
/// Handle to the process-wide runtime (main's `#[tokio::main]`). Used by the
/// sync FUSE byte/watcher paths to drive async transport via `block_on`.
/// A Handle (not an owned Runtime) so dropping NetworkOrigin in async
/// context can't panic, and the runtime outlives any clones we hand out.
handle: tokio::runtime::Handle,
transport: NetworkTransport,
client: sea_orm::DatabaseConnection,
/// In-memory cache of the latest manifest, keyed by server id. Written by
/// `snapshot()`, read by the watcher to know what state to reconcile
/// against on the next pass without round-tripping through the DB.
latest_manifest: Arc<RwLock<BTreeMap<u64, ProtoManifestEntry>>>,
}
impl std::fmt::Debug for NetworkOrigin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
return f
.debug_struct("NetworkOrigin")
.field("endpoint", &self.endpoint)
.field("destination", &self.destination)
.finish();
}
}
impl NetworkOrigin {
pub fn new(
endpoint: String,
destination: String,
client: sea_orm::DatabaseConnection,
) -> io::Result<Self> {
// Must be called from within the process runtime (main's #[tokio::main]).
let handle = tokio::runtime::Handle::current();
let transport = NetworkTransport::new(endpoint.clone()).map_err(io_err)?;
return Ok(NetworkOrigin {
endpoint,
destination: destination.into(),
handle,
transport,
client,
latest_manifest: Arc::new(RwLock::new(BTreeMap::new())),
});
}
pub fn runtime_handle(&self) -> tokio::runtime::Handle {
return self.handle.clone();
}
}
impl Origin for NetworkOrigin {
fn snapshot(&self) -> io::Result<BTreeMap<INodeNo, Item>> {
return run_db_blocking(self.snapshot_async()).map_err(io_err);
}
fn byte_source(&self) -> Arc<dyn ByteSource> {
return Arc::new(NetworkByteSource {
transport: self.transport.clone(),
runtime_handle: self.runtime_handle(),
client: self.client.clone(),
});
}
fn watcher(&self) -> Box<dyn FileWatcher> {
return Box::new(watcher::NetworkOriginFileWatcher::new(
self.transport.clone(),
self.runtime_handle(),
self.client.clone(),
self.destination.clone(),
self.latest_manifest.clone(),
));
}
}
impl NetworkOrigin {
/// Async snapshot driven directly on the caller's runtime. `main` (already
/// `#[tokio::main]`) awaits this; the sync `Origin::snapshot()` wrapper is
/// only for non-async callers and must not be invoked from within a runtime
/// (it builds and `block_on`s a throwaway one).
pub async fn snapshot_async(&self) -> io::Result<BTreeMap<INodeNo, Item>> {
// 1. Pull client's current (inode, hash) pairs from the DB.
let client_entries: Vec<(u64, u64)> = item_entities::Entity::find()
.all(&self.client)
.await
.map_err(|e| {
error!(error = %e, "network snapshot: client entries DB read failed");
io_err(e)
})?
.into_iter()
.filter(|m| m.file_type == "file")
.map(|m| (m.inode as u64, m.hash as u64))
.collect();
// 2. Ask server what changed.
let response = self
.transport
.reconcile(client_entries)
.await
.map_err(|e| {
error!(error = %e, "network snapshot: reconcile with server failed");
io_err(e)
})?;
// 3. Invalidate cached bytes for changed + deleted inodes — they are
// stale by definition.
let mut changed_or_deleted: Vec<i64> =
response.changed.iter().map(|ih| ih.inode as i64).collect();
changed_or_deleted.extend(response.deleted.iter().map(|i| *i as i64));
delete_cached_bytes_for(&changed_or_deleted, &self.client).await;
// latest_manifest is in-memory; on restart it's empty so the reconcile
// delta misses unchanged files. Fetch full manifest then, delta otherwise.
let mut current_manifest = self.latest_manifest.read().unwrap().clone();
if current_manifest.is_empty() {
let entries = self.transport.get_manifest().await.map_err(|e| {
error!(error = %e, "network snapshot: get_manifest from server failed");
io_err(e)
})?;
current_manifest = entries.into_iter().map(|e| (e.id, e)).collect();
} else {
let wanted: Vec<u64> = response.changed.iter().map(|ih| ih.inode).collect();
if !wanted.is_empty() {
let wanted_count = wanted.len();
let entries = self.transport.get_metadata(wanted).await.map_err(|e| {
error!(
wanted = wanted_count,
error = %e,
"network snapshot: get_metadata from server failed"
);
io_err(e)
})?;
for entry in entries {
current_manifest.insert(entry.id, entry);
}
}
}
for inode in &response.deleted {
current_manifest.remove(inode);
}
*self.latest_manifest.write().unwrap() = current_manifest.clone();
let snapshot =
build_snapshot_from_manifest(&current_manifest, &self.destination, &self.client)
.await?;
info!(
changed = response.changed.len(),
deleted = response.deleted.len(),
files = snapshot.len(),
"network snapshot complete"
);
return Ok(snapshot);
}
}
/// Build a complete Items snapshot from the server manifest, sync it to the
/// DB, and restore music metadata + virtual paths from existing DB rows.
///
/// Shared between `snapshot_async` (initial mount) and the watcher's
/// `reconcile_once` (runtime updates) so both paths produce identical
/// snapshots and keep the DB in sync.
pub(crate) async fn build_snapshot_from_manifest(
manifest: &BTreeMap<u64, ProtoManifestEntry>,
destination: &Path,
client: &sea_orm::DatabaseConnection,
) -> io::Result<BTreeMap<INodeNo, Item>> {
let source_root = PathBuf::from("/");
let mut snapshot = BTreeMap::new();
let root_attrs = FileAttrs {
size: 0,
blocks: 0,
atime: SystemTime::UNIX_EPOCH,
mtime: SystemTime::UNIX_EPOCH,
ctime: SystemTime::UNIX_EPOCH,
crtime: SystemTime::UNIX_EPOCH,
perm: 0o755,
nlink: 2,
uid: 0,
gid: 0,
rdev: 0,
blksize: 4096,
};
snapshot.insert(
INodeNo::ROOT,
Item::new(
INodeNo::ROOT,
INodeNo::ROOT,
"/".to_string(),
destination.to_path_buf(),
destination.to_path_buf(),
FileType::Directory,
root_attrs,
None,
),
);
for (_id, entry) in manifest {
let item = manifest_entry_to_item(entry, &source_root, &mut snapshot);
snapshot.insert(item.inode, item);
}
let db_items: std::collections::HashMap<i64, item_entities::Model> =
item_entities::Entity::find()
.all(client)
.await
.map_err(|e| {
error!(error = %e, "build_snapshot_from_manifest: DB read failed");
io_err(e)
})?
.into_iter()
.map(|e| (e.inode, e))
.collect();
sync_items_to_db(&snapshot, &db_items, client).await;
restore_music_metadata_from_db(&mut snapshot, &db_items, client).await;
restore_virtual_paths(&mut snapshot, &db_items, &source_root);
return Ok(snapshot);
}
fn manifest_entry_to_item(
entry: &ProtoManifestEntry,
source_root: &Path,
snapshot: &mut BTreeMap<INodeNo, Item>,
) -> Item {
let inode = INodeNo(entry.id);
let original_path = PathBuf::from(&entry.rel_path);
let attrs = FileAttrs {
size: entry.size,
blocks: 0,
atime: SystemTime::UNIX_EPOCH + Duration::from_secs(entry.mtime),
mtime: SystemTime::UNIX_EPOCH + Duration::from_secs(entry.mtime),
ctime: SystemTime::UNIX_EPOCH + Duration::from_secs(entry.ctime),
crtime: SystemTime::UNIX_EPOCH + Duration::from_secs(entry.crtime),
perm: 0o644,
nlink: 1,
uid: 0,
gid: 0,
rdev: 0,
blksize: 4096,
};
let music_metadata: Option<MusicMetadata> =
entry.music_metadata.clone().map(music_metadata_from_proto);
let mut local_path = PathBuf::new();
if let Some(mm) = &music_metadata {
let joined;
let artist_dir = match mm.album_artist.as_deref() {
Some(a) => a,
None => {
joined = mm.artist.join("-");
&joined
}
};
local_path.push(artist_dir);
local_path.push(&mm.album);
}
let name = Path::new(&entry.rel_path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| entry.rel_path.clone());
local_path.push(&name);
let parent_inode = ensure_virtual_dirs(&local_path, source_root, snapshot);
return Item::new(
inode,
parent_inode,
name,
original_path,
local_path,
FileType::File,
attrs,
music_metadata,
);
}
fn music_metadata_from_proto(mm: crate::proto::MusicMetadata) -> MusicMetadata {
MusicMetadata {
artist: mm.artist,
album_artist: mm.album_artist,
album: mm.album,
track_number: mm.track_number,
track_title: mm.track_title,
other_tags: mm.other_tags,
header: mm.header,
picture_block_headers: mm.picture_block_headers,
picture_data_ranges: mm
.picture_data_ranges
.into_iter()
.map(|p| (p.offset, p.length))
.collect(),
real_audio_start: mm.real_audio_start,
vorbis_comment_offset: mm.vorbis_comment_offset,
vorbis_comment_length: mm.vorbis_comment_length,
}
}
fn io_err<E: std::fmt::Display>(e: E) -> io::Error {
return io::Error::new(io::ErrorKind::Other, e.to_string());
}
pub struct NetworkByteSource {
transport: NetworkTransport,
runtime_handle: tokio::runtime::Handle,
client: sea_orm::DatabaseConnection,
}
impl ByteSource for NetworkByteSource {
fn read_at(
&self,
inode: INodeNo,
_locator: &Path,
offset: u64,
len: usize,
) -> io::Result<Vec<u8>> {
let inode_i64 = inode.0 as i64;
trace!(%inode, offset, len, "network read_at");
let cached = self
.runtime_handle
.block_on(get_cached_bytes(inode_i64, &self.client));
if let Some(data) = cached {
debug!(%inode, "read_at cache hit");
return slice_range(&data, offset, len);
}
debug!(%inode, "read_at cache miss; fetching from server");
let (data, _total_size) = self
.runtime_handle
.block_on(self.transport.fetch_file_range(inode.0, 0, 0))
.map_err(|e| {
error!(%inode, error = %e, "read_at: fetch_file_range failed");
io_err(e)
})?;
self.runtime_handle
.block_on(put_cached_bytes(inode_i64, data.clone(), &self.client));
return slice_range(&data, offset, len);
}
}
fn slice_range(data: &[u8], offset: u64, len: usize) -> io::Result<Vec<u8>> {
let start = (offset as usize).min(data.len());
let end = (start + len).min(data.len());
return Ok(data[start..end].to_vec());
}
@@ -0,0 +1,134 @@
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use tokio_stream::StreamExt;
use tonic::codec::Streaming;
use crate::proto::{
ChangeEvent, GetFileRequest, GetManifestRequest, GetMetadataRequest, InodeHash, ManifestEntry,
MusicFsClient, ReconcileRequest, ReconcileResponse, SubscribeEventsRequest,
};
/// Thin wrapper over the generated tonic client. Owns the connection and
/// exposes four operations matching the four RPCs NetworkOrigin needs.
///
/// All methods are async and must be polled on the runtime whose Handle was
/// passed in at construction (or a child of it). The sync FUSE path uses
/// `runtime_handle.block_on(...)` to enter this runtime.
#[derive(Clone)]
pub struct NetworkTransport {
client: Arc<tokio::sync::Mutex<MusicFsClient<tonic::transport::Channel>>>,
endpoint: tonic::transport::Endpoint,
}
impl NetworkTransport {
/// Build a transport that connects lazily. The first RPC establishes the
/// connection; reconnects happen automatically if the channel drops.
pub fn new(url: String) -> Result<Self> {
let endpoint: tonic::transport::Endpoint = url
.try_into()
.map_err(|e| anyhow!("invalid server URL: {e}"))?;
let endpoint = endpoint
.timeout(Duration::from_secs(3))
.connect_timeout(Duration::from_secs(5))
.http2_keep_alive_interval(Duration::from_secs(10))
.keep_alive_timeout(Duration::from_secs(5));
let channel = endpoint.connect_lazy();
let client = Arc::new(tokio::sync::Mutex::new(MusicFsClient::new(channel)));
return Ok(NetworkTransport { client, endpoint });
}
pub async fn reconcile(&self, entries: Vec<(u64, u64)>) -> Result<ReconcileResponse> {
let request = ReconcileRequest {
entries: entries
.into_iter()
.map(|(inode, hash)| InodeHash { inode, hash })
.collect(),
};
let mut client = self.client.lock().await;
let response = client
.reconcile(request)
.await
.context("Reconcile RPC failed")?;
return Ok(response.into_inner());
}
pub async fn get_metadata(&self, inodes: Vec<u64>) -> Result<Vec<ManifestEntry>> {
let request = GetMetadataRequest { inodes };
let mut client = self.client.lock().await;
let mut stream: Streaming<ManifestEntry> = client
.get_metadata(request)
.await
.context("GetMetadata RPC failed")?
.into_inner();
let mut out = Vec::new();
while let Some(entry) = stream.next().await {
out.push(entry.context("GetMetadata stream error")?);
}
return Ok(out);
}
pub async fn get_manifest(&self) -> Result<Vec<ManifestEntry>> {
let mut client = self.client.lock().await;
let mut stream: Streaming<ManifestEntry> = client
.get_manifest(GetManifestRequest {})
.await
.context("GetManifest RPC failed")?
.into_inner();
let mut out = Vec::new();
while let Some(entry) = stream.next().await {
out.push(entry.context("GetManifest stream error")?);
}
return Ok(out);
}
pub async fn fetch_file_range(
&self,
id: u64,
start: u64,
length: u64,
) -> Result<(Vec<u8>, u64)> {
let request = GetFileRequest { id, start, length };
let mut client = self.client.lock().await;
let mut stream = client
.get_file(request)
.await
.context("GetFile RPC failed")?
.into_inner();
let first = stream
.next()
.await
.ok_or_else(|| anyhow!("GetFile returned empty stream for id {id}"))?
.context("GetFile stream error")?;
let total_size = first.total_size;
let mut data = first.data;
while let Some(chunk) = stream.next().await {
let chunk = chunk.context("GetFile stream error")?;
data.extend_from_slice(&chunk.data);
}
return Ok((data, total_size));
}
/// Take a fresh receiver on the SubscribeEvents stream. Each call opens a
/// new server-streaming RPC; the caller owns the lifetime.
pub async fn subscribe_events(&self) -> Result<Streaming<ChangeEvent>> {
let mut client = self.client.lock().await;
let stream = client
.subscribe_events(SubscribeEventsRequest {})
.await
.context("SubscribeEvents RPC failed")?
.into_inner();
return Ok(stream);
}
/// Reconnect — used by the watcher when the channel has gone bad.
#[allow(dead_code)]
pub async fn reconnect(&self) -> Result<()> {
let channel = self.endpoint.connect().await.context("reconnect failed")?;
let new_client = MusicFsClient::new(channel);
let mut guard = self.client.lock().await;
*guard = new_client;
return Ok(());
}
}
@@ -0,0 +1,185 @@
use std::{
collections::BTreeMap,
path::PathBuf,
sync::{Arc, RwLock},
thread,
time::Duration,
};
use fuser::INodeNo;
use sea_orm::{DatabaseConnection, EntityTrait};
use tokio::sync::Notify;
use tokio_stream::StreamExt;
use crate::item::Item;
use crate::origins::network::transport::NetworkTransport;
use crate::origins::{FileWatcher, WatcherHandle};
use crate::proto::ManifestEntry as ProtoManifestEntry;
use tracing::{info, warn};
const INITIAL_RETRY_DELAY: Duration = Duration::from_secs(1);
const MAX_RETRY_DELAY: Duration = Duration::from_secs(30);
pub struct NetworkOriginFileWatcher {
transport: NetworkTransport,
runtime_handle: tokio::runtime::Handle,
client: DatabaseConnection,
destination: PathBuf,
latest_manifest: Arc<RwLock<BTreeMap<u64, ProtoManifestEntry>>>,
}
impl NetworkOriginFileWatcher {
#[allow(clippy::too_many_arguments)]
pub fn new(
transport: NetworkTransport,
runtime_handle: tokio::runtime::Handle,
client: DatabaseConnection,
destination: PathBuf,
latest_manifest: Arc<RwLock<BTreeMap<u64, ProtoManifestEntry>>>,
) -> Self {
return NetworkOriginFileWatcher {
transport,
runtime_handle,
client,
destination,
latest_manifest,
};
}
}
impl FileWatcher for NetworkOriginFileWatcher {
fn watch(&self, files: Arc<std::sync::Mutex<BTreeMap<INodeNo, Item>>>) -> WatcherHandle {
let runtime_handle = self.runtime_handle.clone();
let shutdown = Arc::new(Notify::new());
let state = WatcherState {
transport: self.transport.clone(),
client: self.client.clone(),
destination: self.destination.clone(),
latest_manifest: self.latest_manifest.clone(),
files,
};
let loop_shutdown = shutdown.clone();
let join = thread::spawn(move || {
runtime_handle.block_on(state.run_loop(loop_shutdown));
});
return WatcherHandle::new(move || {
shutdown.notify_one();
let _ = join.join();
});
}
}
#[derive(Clone)]
struct WatcherState {
transport: NetworkTransport,
client: DatabaseConnection,
destination: PathBuf,
latest_manifest: Arc<RwLock<BTreeMap<u64, ProtoManifestEntry>>>,
files: Arc<std::sync::Mutex<BTreeMap<INodeNo, Item>>>,
}
impl WatcherState {
async fn run_loop(self, shutdown: Arc<Notify>) {
let mut retry_delay = INITIAL_RETRY_DELAY;
loop {
let subscribed = tokio::select! {
biased;
_ = shutdown.notified() => return,
s = self.transport.subscribe_events() => s,
};
match subscribed {
Ok(mut stream) => {
info!("network watcher: subscribed to /events");
retry_delay = INITIAL_RETRY_DELAY;
loop {
let item = tokio::select! {
biased;
_ = shutdown.notified() => return,
item = stream.next() => item,
};
match item {
Some(Ok(_event)) => {
if let Err(e) = self.reconcile_once().await {
warn!(error = %e, "network watcher: reconcile after event failed");
}
}
Some(Err(e)) => {
warn!(error = %e, "network watcher: stream error; reconnecting");
break;
}
None => break,
}
}
}
Err(e) => {
warn!(error = %e, "network watcher: subscribe failed; will retry");
}
}
tokio::select! {
biased;
_ = shutdown.notified() => return,
_ = tokio::time::sleep(retry_delay) => {}
}
retry_delay = (retry_delay * 2).min(MAX_RETRY_DELAY);
if let Err(e) = self.reconcile_once().await {
warn!(error = %e, "network watcher: poll reconcile failed");
}
}
}
async fn reconcile_once(&self) -> anyhow::Result<()> {
let client_entries: Vec<(u64, u64)> = crate::db::entities::Entity::find()
.all(&self.client)
.await?
.into_iter()
.filter(|m| m.file_type == "file")
.map(|m| (m.inode as u64, m.hash as u64))
.collect();
let response = self.transport.reconcile(client_entries).await?;
let mut changed_or_deleted: Vec<i64> =
response.changed.iter().map(|ih| ih.inode as i64).collect();
changed_or_deleted.extend(response.deleted.iter().map(|i| *i as i64));
crate::db::cache::delete_cached_bytes_for(&changed_or_deleted, &self.client).await;
let wanted: Vec<u64> = response.changed.iter().map(|ih| ih.inode).collect();
let mut current_manifest = self.latest_manifest.read().unwrap().clone();
if !wanted.is_empty() {
let entries = self.transport.get_metadata(wanted).await?;
for entry in entries {
current_manifest.insert(entry.id, entry);
}
}
for inode in &response.deleted {
current_manifest.remove(inode);
}
*self.latest_manifest.write().unwrap() = current_manifest.clone();
let new_snapshot =
super::build_snapshot_from_manifest(&current_manifest, &self.destination, &self.client)
.await?;
{
let mut files = self.files.lock().unwrap();
files.retain(|ino, _| new_snapshot.contains_key(ino));
for (ino, new_item) in &new_snapshot {
match files.get(ino) {
Some(existing) if existing.hash == new_item.hash => {}
_ => {
files.insert(*ino, new_item.clone());
}
}
}
}
info!(
changed = response.changed.len(),
deleted = response.deleted.len(),
total = current_manifest.len(),
"network watcher: reconcile applied"
);
return Ok(());
}
}
+267
View File
@@ -0,0 +1,267 @@
use std::{
collections::BTreeMap,
fs,
hash::Hasher,
path::{Path, PathBuf},
};
use fuser::INodeNo;
use twox_hash::XxHash64;
use crate::db::entities::Model;
use crate::item::{FileType, Item};
use crate::origins::attrs::FileAttrs;
pub fn virtual_inode(path: &str) -> INodeNo {
let mut hasher = XxHash64::with_seed(5678);
hasher.write(path.as_bytes());
return INodeNo(hasher.finish() | (1u64 << 63));
}
pub fn parent_inode_from_path(local_path: &Path) -> INodeNo {
let components: Vec<_> = local_path
.components()
.filter(|c| matches!(c, std::path::Component::Normal(_)))
.collect();
if components.len() <= 1 {
return INodeNo::ROOT;
}
let mut parent_path = String::new();
for (i, comp) in components[..components.len() - 1].iter().enumerate() {
if i > 0 {
parent_path.push('/');
}
parent_path.push_str(comp.as_os_str().to_str().unwrap_or_default());
}
return virtual_inode(&parent_path);
}
pub fn ensure_virtual_dirs(
local_path: &Path,
source: &Path,
map: &mut BTreeMap<INodeNo, Item>,
) -> INodeNo {
let components: Vec<_> = local_path
.components()
.filter(|c| matches!(c, std::path::Component::Normal(_)))
.collect();
if components.len() <= 1 {
return INodeNo::ROOT;
}
let mut current_parent = INodeNo::ROOT;
let mut current_path = String::new();
for component in &components[..components.len() - 1] {
let comp_str = component.as_os_str().to_str().unwrap_or_default();
if !current_path.is_empty() {
current_path.push('/');
}
current_path.push_str(comp_str);
let virt_ino = virtual_inode(&current_path);
if !map.contains_key(&virt_ino) {
let virt_item = Item::new(
virt_ino,
current_parent,
comp_str.to_string(),
source.to_path_buf(),
PathBuf::from(&current_path),
FileType::Directory,
FileAttrs::from(&fs::metadata(source).unwrap()),
None,
);
map.insert(virt_ino, virt_item);
}
current_parent = virt_ino;
}
return current_parent;
}
pub fn restore_virtual_paths(
snapshot: &mut BTreeMap<INodeNo, Item>,
db_items: &std::collections::HashMap<i64, Model>,
source: &Path,
) {
let restorations: Vec<(INodeNo, String, PathBuf)> = db_items
.values()
.filter_map(|db_item| {
let ino = INodeNo(db_item.inode as u64);
if ino == INodeNo::ROOT {
return None;
}
snapshot
.get(&ino)
.filter(|item| item.hash as i64 == db_item.hash)
.map(|_| {
(
ino,
db_item.name.clone(),
PathBuf::from(&db_item.local_path),
)
})
})
.collect();
for (_, _, local_path) in &restorations {
ensure_virtual_dirs(local_path, source, snapshot);
}
for (ino, name, local_path) in restorations {
if let Some(item) = snapshot.get_mut(&ino) {
item.name = name;
item.parent_inode = parent_inode_from_path(&local_path);
item.local_path = local_path;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn virtual_inode_deterministic() {
let ino1 = virtual_inode("foo");
let ino2 = virtual_inode("foo");
assert_eq!(ino1, ino2);
}
#[test]
fn virtual_inode_high_bit_set() {
let ino = virtual_inode("test");
assert_eq!(ino.0 & (1u64 << 63), 1u64 << 63);
}
#[test]
fn virtual_inode_different_inputs() {
let ino1 = virtual_inode("foo");
let ino2 = virtual_inode("bar");
assert_ne!(ino1, ino2);
}
#[test]
fn parent_inode_single_component() {
let ino = parent_inode_from_path(Path::new("file.txt"));
assert_eq!(ino, INodeNo::ROOT);
}
#[test]
fn parent_inode_multi_component() {
let ino = parent_inode_from_path(Path::new("a/b/c"));
let expected = virtual_inode("a/b");
assert_eq!(ino, expected);
}
#[test]
fn parent_inode_absolute_path_filtered() {
let ino = parent_inode_from_path(Path::new("/file"));
assert_eq!(ino, INodeNo::ROOT);
}
#[test]
fn ensure_virtual_dirs_single_component() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path();
let mut map = BTreeMap::new();
let ino = ensure_virtual_dirs(Path::new("file.txt"), source, &mut map);
assert_eq!(ino, INodeNo::ROOT);
assert!(map.is_empty());
}
#[test]
fn ensure_virtual_dirs_creates_hierarchy() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path();
let mut map = BTreeMap::new();
let ino = ensure_virtual_dirs(Path::new("a/b/file"), source, &mut map);
let a_ino = virtual_inode("a");
let ab_ino = virtual_inode("a/b");
assert_eq!(ino, ab_ino);
assert_eq!(map.len(), 2);
assert!(map.contains_key(&a_ino));
assert!(map.contains_key(&ab_ino));
let a_item = &map[&a_ino];
assert_eq!(a_item.parent_inode, INodeNo::ROOT);
let ab_item = &map[&ab_ino];
assert_eq!(ab_item.parent_inode, a_ino);
}
#[test]
fn ensure_virtual_dirs_idempotent() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path();
let mut map = BTreeMap::new();
ensure_virtual_dirs(Path::new("a/b/file"), source, &mut map);
let size_after_first = map.len();
ensure_virtual_dirs(Path::new("a/b/file"), source, &mut map);
let size_after_second = map.len();
assert_eq!(size_after_first, size_after_second);
}
#[test]
fn restore_virtual_paths_skips_root() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path();
let mut snapshot = BTreeMap::new();
let real_ino = INodeNo(42);
let real_item = Item::new(
real_ino,
INodeNo::ROOT,
"real_file".to_string(),
source.to_path_buf(),
PathBuf::from("real_file"),
FileType::File,
FileAttrs::from(&fs::metadata(source).unwrap()),
None,
);
snapshot.insert(real_ino, real_item);
let mut db_items = HashMap::new();
db_items.insert(
1i64,
Model {
inode: 1,
hash: 0,
name: "root".to_string(),
original_path: "/tmp/test".to_string(),
local_path: "/tmp/test".to_string(),
file_type: "directory".to_string(),
},
);
db_items.insert(
42i64,
Model {
inode: 42,
hash: 0,
name: "real_file".to_string(),
original_path: "real_file".to_string(),
local_path: "real_file".to_string(),
file_type: "file".to_string(),
},
);
restore_virtual_paths(&mut snapshot, &db_items, source);
for item in snapshot.values() {
assert_ne!(item.name, "/");
assert_ne!(item.name, "tmp");
}
}
}
@@ -0,0 +1,83 @@
use std::io::Write;
use std::path::PathBuf;
use musicfs::origins::local::file_io::{assemble_flac_read, read_bytes_at};
fn file_reader(path: PathBuf) -> impl Fn(u64, usize) -> std::io::Result<Vec<u8>> {
return move |offset, len| read_bytes_at(&path, offset, len);
}
#[test]
fn assemble_flac_read_picture_header_region() {
let mut f = tempfile::NamedTempFile::new().unwrap();
let data: Vec<u8> = (0..100).collect();
f.write_all(&data).unwrap();
f.flush().unwrap();
let path = f.path().to_path_buf();
let header = b"ABCD";
let pic_hdr = vec![0x86u8, 0x00, 0x00, 0x05];
let pic_ranges = [(10u64, 5u64)];
let real_audio_start = 50u64;
let reader = file_reader(path);
let result = assemble_flac_read(
&reader,
header,
&[pic_hdr.clone()],
&pic_ranges,
real_audio_start,
4,
4,
)
.unwrap();
assert_eq!(result, pic_hdr);
}
#[test]
fn assemble_flac_read_picture_data_region() {
let mut f = tempfile::NamedTempFile::new().unwrap();
let data: Vec<u8> = (0..100).collect();
f.write_all(&data).unwrap();
f.flush().unwrap();
let path = f.path().to_path_buf();
let header = b"ABCD";
let pic_hdr = vec![0x86u8, 0x00, 0x00, 0x05];
let pic_ranges = [(10u64, 5u64)];
let real_audio_start = 50u64;
let reader = file_reader(path);
let result = assemble_flac_read(
&reader,
header,
&[pic_hdr.clone()],
&pic_ranges,
real_audio_start,
8,
5,
)
.unwrap();
assert_eq!(result, vec![10, 11, 12, 13, 14]);
}
#[test]
fn assemble_flac_read_full_virtual_layout() {
let mut f = tempfile::NamedTempFile::new().unwrap();
let data: Vec<u8> = (0..200).map(|i| i as u8).collect();
f.write_all(&data).unwrap();
f.flush().unwrap();
let path = f.path().to_path_buf();
let header = vec![0xAA; 8];
let real_audio_start = 100u64;
let reader = file_reader(path);
let result = assemble_flac_read(&reader, &header, &[], &[], real_audio_start, 0, 18).unwrap();
let mut expected = vec![0xAA; 8];
expected.extend_from_slice(&(100..110).map(|i| i as u8).collect::<Vec<u8>>());
assert_eq!(result, expected);
}
@@ -0,0 +1,109 @@
use std::fs;
use std::io::Write;
use std::sync::{Arc, Mutex};
use fuser::INodeNo;
use musicfs::item::FileType;
use musicfs::origins::local::snapshot::{build_snapshot, fill_fileset};
#[test]
fn build_snapshot_empty_dir() {
let source = tempfile::tempdir().unwrap();
let dest = tempfile::tempdir().unwrap();
let snapshot = build_snapshot(source.path(), dest.path()).unwrap();
assert_eq!(snapshot.len(), 1);
assert!(snapshot.contains_key(&INodeNo::ROOT));
let root = &snapshot[&INodeNo::ROOT];
assert_eq!(root.inode, INodeNo::ROOT);
assert_eq!(root.parent_inode, INodeNo::ROOT);
assert_eq!(root.file_type, FileType::Directory);
}
#[test]
fn build_snapshot_flat_files() {
let source = tempfile::tempdir().unwrap();
let dest = tempfile::tempdir().unwrap();
let mut f = fs::File::create(source.path().join("a.txt")).unwrap();
f.write_all(b"content a").unwrap();
let mut f = fs::File::create(source.path().join("b.txt")).unwrap();
f.write_all(b"content b").unwrap();
let mut f = fs::File::create(source.path().join("c.txt")).unwrap();
f.write_all(b"content c").unwrap();
let snapshot = build_snapshot(source.path(), dest.path()).unwrap();
assert_eq!(snapshot.len(), 4);
assert!(snapshot.contains_key(&INodeNo::ROOT));
let file_entries: Vec<_> = snapshot
.values()
.filter(|item| item.file_type == FileType::File)
.collect();
assert_eq!(file_entries.len(), 3);
for file_entry in file_entries {
assert_eq!(file_entry.parent_inode, INodeNo::ROOT);
}
}
#[test]
fn build_snapshot_nested_dirs() {
let source = tempfile::tempdir().unwrap();
let dest = tempfile::tempdir().unwrap();
fs::create_dir(source.path().join("subdir")).unwrap();
let nested_path = source.path().join("subdir").join("file.txt");
let mut f = fs::File::create(&nested_path).unwrap();
f.write_all(b"nested content").unwrap();
let snapshot = build_snapshot(source.path(), dest.path()).unwrap();
assert!(snapshot.contains_key(&INodeNo::ROOT));
let file_entry = snapshot
.values()
.find(|item| item.name == "file.txt" && item.file_type == FileType::File)
.expect("file.txt not found");
assert_eq!(file_entry.parent_inode, INodeNo::ROOT);
assert_eq!(file_entry.original_path, nested_path);
}
#[test]
fn fill_fileset_removes_deleted() {
let source = tempfile::tempdir().unwrap();
let dest = tempfile::tempdir().unwrap();
let mut f = fs::File::create(source.path().join("file1.txt")).unwrap();
f.write_all(b"content 1").unwrap();
let mut f = fs::File::create(source.path().join("file2.txt")).unwrap();
f.write_all(b"content 2").unwrap();
let initial_snapshot = build_snapshot(source.path(), dest.path()).unwrap();
let map = Arc::new(Mutex::new(initial_snapshot));
assert_eq!(map.lock().unwrap().len(), 3);
fs::remove_file(source.path().join("file1.txt")).unwrap();
fill_fileset(&map, source.path(), dest.path());
let final_map = map.lock().unwrap();
assert_eq!(final_map.len(), 2);
assert!(final_map.contains_key(&INodeNo::ROOT));
let remaining_files: Vec<_> = final_map
.values()
.filter(|item| item.file_type == FileType::File)
.collect();
assert_eq!(remaining_files.len(), 1);
assert_eq!(remaining_files[0].name, "file2.txt");
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "musicfs-core"
version.workspace = true
edition.workspace = true
[dependencies]
symphonia.workspace = true
twox-hash.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
tracing-appender.workspace = true
[dev-dependencies]
tempfile.workspace = true
+88
View File
@@ -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));
}
}
+20
View File
@@ -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()
}
+8
View File
@@ -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;
+89
View File
@@ -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
}
+31
View File
@@ -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,
}
}
}
+445
View File
@@ -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);
}
}
+62
View File
@@ -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);
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod encoder;
pub mod flac;
pub mod metadata;
pub mod mp3;
pub mod parse;
pub mod parser;
+507
View File
@@ -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()]);
}
}
+55
View File
@@ -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());
}
}
+31
View File
@@ -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,
}
}
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "musicfs-proto"
version.workspace = true
edition.workspace = true
[dependencies]
prost.workspace = true
tonic.workspace = true
tonic-prost.workspace = true
@@ -0,0 +1,459 @@
// @generated
// This file is @generated by prost-build.
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ReconcileRequest {
#[prost(message, repeated, tag = "1")]
pub entries: ::prost::alloc::vec::Vec<InodeHash>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ReconcileResponse {
#[prost(message, repeated, tag = "1")]
pub changed: ::prost::alloc::vec::Vec<InodeHash>,
#[prost(uint64, repeated, tag = "2")]
pub deleted: ::prost::alloc::vec::Vec<u64>,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
pub struct InodeHash {
#[prost(uint64, tag = "1")]
pub inode: u64,
#[prost(uint64, tag = "2")]
pub hash: u64,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct GetMetadataRequest {
#[prost(uint64, repeated, tag = "1")]
pub inodes: ::prost::alloc::vec::Vec<u64>,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
pub struct GetManifestRequest {}
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
pub struct GetFileRequest {
#[prost(uint64, tag = "1")]
pub id: u64,
/// When both are zero the server streams the entire file.
#[prost(uint64, tag = "2")]
pub start: u64,
#[prost(uint64, tag = "3")]
pub length: u64,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
pub struct SubscribeEventsRequest {}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ManifestEntry {
#[prost(uint64, tag = "1")]
pub id: u64,
#[prost(string, tag = "2")]
pub rel_path: ::prost::alloc::string::String,
#[prost(uint64, tag = "3")]
pub size: u64,
#[prost(uint64, tag = "4")]
pub mtime: u64,
#[prost(uint64, tag = "5")]
pub ctime: u64,
#[prost(uint64, tag = "6")]
pub crtime: u64,
#[prost(message, optional, tag = "7")]
pub music_metadata: ::core::option::Option<MusicMetadata>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct MusicMetadata {
#[prost(string, repeated, tag = "1")]
pub artist: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
#[prost(string, optional, tag = "2")]
pub album_artist: ::core::option::Option<::prost::alloc::string::String>,
#[prost(string, tag = "3")]
pub album: ::prost::alloc::string::String,
#[prost(int32, tag = "4")]
pub track_number: i32,
#[prost(string, tag = "5")]
pub track_title: ::prost::alloc::string::String,
#[prost(string, repeated, tag = "6")]
pub other_tags: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
#[prost(bytes = "vec", tag = "7")]
pub header: ::prost::alloc::vec::Vec<u8>,
#[prost(bytes = "vec", repeated, tag = "8")]
pub picture_block_headers: ::prost::alloc::vec::Vec<::prost::alloc::vec::Vec<u8>>,
#[prost(message, repeated, tag = "9")]
pub picture_data_ranges: ::prost::alloc::vec::Vec<PictureDataRange>,
#[prost(uint64, tag = "10")]
pub real_audio_start: u64,
#[prost(uint64, tag = "11")]
pub vorbis_comment_offset: u64,
#[prost(uint64, tag = "12")]
pub vorbis_comment_length: u64,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)]
pub struct PictureDataRange {
#[prost(uint64, tag = "1")]
pub offset: u64,
#[prost(uint64, tag = "2")]
pub length: u64,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct FileChunk {
#[prost(bytes = "vec", tag = "1")]
pub data: ::prost::alloc::vec::Vec<u8>,
#[prost(uint64, tag = "2")]
pub offset: u64,
#[prost(uint64, tag = "3")]
pub total_size: u64,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct ChangeEvent {
/// "create", "modify", or "remove".
#[prost(string, tag = "1")]
pub kind: ::prost::alloc::string::String,
}
/// Encoded file descriptor set for the `musicfs` package
pub const FILE_DESCRIPTOR_SET: &[u8] = &[
0x0a, 0xb0, 0x2b, 0x0a, 0x0d, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x66, 0x73, 0x2e, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x12, 0x07, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x66, 0x73, 0x22, 0x40, 0x0a, 0x10, 0x52,
0x65, 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
0x2c, 0x0a, 0x07, 0x65, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b,
0x32, 0x12, 0x2e, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x66, 0x73, 0x2e, 0x49, 0x6e, 0x6f, 0x64, 0x65,
0x48, 0x61, 0x73, 0x68, 0x52, 0x07, 0x65, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x22, 0x5b, 0x0a,
0x11, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x2c, 0x0a, 0x07, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x18, 0x01, 0x20,
0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x66, 0x73, 0x2e, 0x49, 0x6e,
0x6f, 0x64, 0x65, 0x48, 0x61, 0x73, 0x68, 0x52, 0x07, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64,
0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x03, 0x28,
0x04, 0x52, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x22, 0x35, 0x0a, 0x09, 0x49, 0x6e,
0x6f, 0x64, 0x65, 0x48, 0x61, 0x73, 0x68, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6e, 0x6f, 0x64, 0x65,
0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x69, 0x6e, 0x6f, 0x64, 0x65, 0x12, 0x12, 0x0a,
0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x68, 0x61, 0x73,
0x68, 0x22, 0x2c, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x6e, 0x6f, 0x64, 0x65,
0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x04, 0x52, 0x06, 0x69, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x22,
0x14, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x4e, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x46, 0x69, 0x6c, 0x65,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20,
0x01, 0x28, 0x04, 0x52, 0x02, 0x69, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74,
0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x16, 0x0a,
0x06, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x6c,
0x65, 0x6e, 0x67, 0x74, 0x68, 0x22, 0x18, 0x0a, 0x16, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69,
0x62, 0x65, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22,
0xd1, 0x01, 0x0a, 0x0d, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x45, 0x6e, 0x74, 0x72,
0x79, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x02, 0x69,
0x64, 0x12, 0x19, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20,
0x01, 0x28, 0x09, 0x52, 0x07, 0x72, 0x65, 0x6c, 0x50, 0x61, 0x74, 0x68, 0x12, 0x12, 0x0a, 0x04,
0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65,
0x12, 0x14, 0x0a, 0x05, 0x6d, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x04, 0x52,
0x05, 0x6d, 0x74, 0x69, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x74, 0x69, 0x6d, 0x65, 0x18,
0x05, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x63, 0x74, 0x69, 0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06,
0x63, 0x72, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x72,
0x74, 0x69, 0x6d, 0x65, 0x12, 0x3d, 0x0a, 0x0e, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x5f, 0x6d, 0x65,
0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d,
0x75, 0x73, 0x69, 0x63, 0x66, 0x73, 0x2e, 0x4d, 0x75, 0x73, 0x69, 0x63, 0x4d, 0x65, 0x74, 0x61,
0x64, 0x61, 0x74, 0x61, 0x52, 0x0d, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x4d, 0x65, 0x74, 0x61, 0x64,
0x61, 0x74, 0x61, 0x22, 0x82, 0x04, 0x0a, 0x0d, 0x4d, 0x75, 0x73, 0x69, 0x63, 0x4d, 0x65, 0x74,
0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x72, 0x74, 0x69, 0x73, 0x74, 0x18,
0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x61, 0x72, 0x74, 0x69, 0x73, 0x74, 0x12, 0x26, 0x0a,
0x0c, 0x61, 0x6c, 0x62, 0x75, 0x6d, 0x5f, 0x61, 0x72, 0x74, 0x69, 0x73, 0x74, 0x18, 0x02, 0x20,
0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0b, 0x61, 0x6c, 0x62, 0x75, 0x6d, 0x41, 0x72, 0x74, 0x69,
0x73, 0x74, 0x88, 0x01, 0x01, 0x12, 0x14, 0x0a, 0x05, 0x61, 0x6c, 0x62, 0x75, 0x6d, 0x18, 0x03,
0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x61, 0x6c, 0x62, 0x75, 0x6d, 0x12, 0x21, 0x0a, 0x0c, 0x74,
0x72, 0x61, 0x63, 0x6b, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28,
0x05, 0x52, 0x0b, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x1f,
0x0a, 0x0b, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x5f, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, 0x05, 0x20,
0x01, 0x28, 0x09, 0x52, 0x0a, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x54, 0x69, 0x74, 0x6c, 0x65, 0x12,
0x1d, 0x0a, 0x0a, 0x6f, 0x74, 0x68, 0x65, 0x72, 0x5f, 0x74, 0x61, 0x67, 0x73, 0x18, 0x06, 0x20,
0x03, 0x28, 0x09, 0x52, 0x09, 0x6f, 0x74, 0x68, 0x65, 0x72, 0x54, 0x61, 0x67, 0x73, 0x12, 0x16,
0x0a, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06,
0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x12, 0x32, 0x0a, 0x15, 0x70, 0x69, 0x63, 0x74, 0x75, 0x72,
0x65, 0x5f, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x5f, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x18,
0x08, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x13, 0x70, 0x69, 0x63, 0x74, 0x75, 0x72, 0x65, 0x42, 0x6c,
0x6f, 0x63, 0x6b, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x12, 0x49, 0x0a, 0x13, 0x70, 0x69,
0x63, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x5f, 0x72, 0x61, 0x6e, 0x67, 0x65,
0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x66,
0x73, 0x2e, 0x50, 0x69, 0x63, 0x74, 0x75, 0x72, 0x65, 0x44, 0x61, 0x74, 0x61, 0x52, 0x61, 0x6e,
0x67, 0x65, 0x52, 0x11, 0x70, 0x69, 0x63, 0x74, 0x75, 0x72, 0x65, 0x44, 0x61, 0x74, 0x61, 0x52,
0x61, 0x6e, 0x67, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x72, 0x65, 0x61, 0x6c, 0x5f, 0x61, 0x75,
0x64, 0x69, 0x6f, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x04, 0x52,
0x0e, 0x72, 0x65, 0x61, 0x6c, 0x41, 0x75, 0x64, 0x69, 0x6f, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12,
0x32, 0x0a, 0x15, 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e,
0x74, 0x5f, 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x04, 0x52, 0x13,
0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, 0x43, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x4f, 0x66, 0x66,
0x73, 0x65, 0x74, 0x12, 0x32, 0x0a, 0x15, 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, 0x5f, 0x63, 0x6f,
0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, 0x0c, 0x20, 0x01,
0x28, 0x04, 0x52, 0x13, 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, 0x43, 0x6f, 0x6d, 0x6d, 0x65, 0x6e,
0x74, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x61, 0x6c, 0x62, 0x75,
0x6d, 0x5f, 0x61, 0x72, 0x74, 0x69, 0x73, 0x74, 0x22, 0x42, 0x0a, 0x10, 0x50, 0x69, 0x63, 0x74,
0x75, 0x72, 0x65, 0x44, 0x61, 0x74, 0x61, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x16, 0x0a, 0x06,
0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x6f, 0x66,
0x66, 0x73, 0x65, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, 0x02,
0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x22, 0x56, 0x0a, 0x09,
0x46, 0x69, 0x6c, 0x65, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74,
0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x16, 0x0a,
0x06, 0x6f, 0x66, 0x66, 0x73, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x6f,
0x66, 0x66, 0x73, 0x65, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x73,
0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x74, 0x6f, 0x74, 0x61, 0x6c,
0x53, 0x69, 0x7a, 0x65, 0x22, 0x21, 0x0a, 0x0b, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x45, 0x76,
0x65, 0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x04, 0x6b, 0x69, 0x6e, 0x64, 0x32, 0xdf, 0x02, 0x0a, 0x07, 0x4d, 0x75, 0x73, 0x69,
0x63, 0x46, 0x73, 0x12, 0x42, 0x0a, 0x09, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65,
0x12, 0x19, 0x2e, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x66, 0x73, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x6e,
0x63, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x6d, 0x75,
0x73, 0x69, 0x63, 0x66, 0x73, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x44, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x4d, 0x65,
0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x1b, 0x2e, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x66, 0x73,
0x2e, 0x47, 0x65, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x66, 0x73, 0x2e, 0x4d, 0x61,
0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x30, 0x01, 0x12, 0x44, 0x0a,
0x0b, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x2e, 0x6d,
0x75, 0x73, 0x69, 0x63, 0x66, 0x73, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65,
0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x6d, 0x75, 0x73, 0x69,
0x63, 0x66, 0x73, 0x2e, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x45, 0x6e, 0x74, 0x72,
0x79, 0x30, 0x01, 0x12, 0x38, 0x0a, 0x07, 0x47, 0x65, 0x74, 0x46, 0x69, 0x6c, 0x65, 0x12, 0x17,
0x2e, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x66, 0x73, 0x2e, 0x47, 0x65, 0x74, 0x46, 0x69, 0x6c, 0x65,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x12, 0x2e, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x66,
0x73, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x30, 0x01, 0x12, 0x4a, 0x0a,
0x0f, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73,
0x12, 0x1f, 0x2e, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x66, 0x73, 0x2e, 0x53, 0x75, 0x62, 0x73, 0x63,
0x72, 0x69, 0x62, 0x65, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x14, 0x2e, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x66, 0x73, 0x2e, 0x43, 0x68, 0x61, 0x6e,
0x67, 0x65, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x30, 0x01, 0x4a, 0x8f, 0x1e, 0x0a, 0x06, 0x12, 0x04,
0x00, 0x00, 0x66, 0x01, 0x0a, 0x08, 0x0a, 0x01, 0x0c, 0x12, 0x03, 0x00, 0x00, 0x12, 0x0a, 0x08,
0x0a, 0x01, 0x02, 0x12, 0x03, 0x02, 0x00, 0x10, 0x0a, 0x62, 0x0a, 0x02, 0x06, 0x00, 0x12, 0x04,
0x06, 0x00, 0x20, 0x01, 0x1a, 0x56, 0x20, 0x4d, 0x75, 0x73, 0x69, 0x63, 0x46, 0x73, 0x20, 0x73,
0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x20, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x73, 0x20, 0x61,
0x20, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2d, 0x73, 0x69, 0x64, 0x65, 0x20, 0x6d, 0x75, 0x73,
0x69, 0x63, 0x20, 0x6c, 0x69, 0x62, 0x72, 0x61, 0x72, 0x79, 0x20, 0x74, 0x6f, 0x20, 0x61, 0x20,
0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x20, 0x6d, 0x75, 0x73, 0x69, 0x63, 0x66, 0x73, 0x0a, 0x20,
0x46, 0x55, 0x53, 0x45, 0x20, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x2e, 0x0a, 0x0a, 0x0a, 0x0a, 0x03,
0x06, 0x00, 0x01, 0x12, 0x03, 0x06, 0x08, 0x0f, 0x0a, 0xac, 0x02, 0x0a, 0x04, 0x06, 0x00, 0x02,
0x00, 0x12, 0x03, 0x0b, 0x02, 0x3e, 0x1a, 0x9e, 0x02, 0x20, 0x48, 0x61, 0x73, 0x68, 0x2d, 0x62,
0x61, 0x73, 0x65, 0x64, 0x20, 0x72, 0x65, 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x69, 0x61, 0x74,
0x69, 0x6f, 0x6e, 0x2e, 0x20, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x20, 0x73, 0x65, 0x6e, 0x64,
0x73, 0x20, 0x74, 0x68, 0x65, 0x20, 0x28, 0x69, 0x6e, 0x6f, 0x64, 0x65, 0x2c, 0x20, 0x68, 0x61,
0x73, 0x68, 0x29, 0x20, 0x70, 0x61, 0x69, 0x72, 0x73, 0x20, 0x69, 0x74, 0x0a, 0x20, 0x63, 0x75,
0x72, 0x72, 0x65, 0x6e, 0x74, 0x6c, 0x79, 0x20, 0x68, 0x61, 0x73, 0x3b, 0x20, 0x73, 0x65, 0x72,
0x76, 0x65, 0x72, 0x20, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x64, 0x73, 0x20, 0x77, 0x69, 0x74,
0x68, 0x20, 0x74, 0x68, 0x65, 0x20, 0x73, 0x75, 0x62, 0x73, 0x65, 0x74, 0x20, 0x74, 0x68, 0x61,
0x74, 0x20, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x20, 0x28, 0x6e, 0x65, 0x77, 0x20, 0x6f,
0x72, 0x0a, 0x20, 0x68, 0x61, 0x73, 0x68, 0x2d, 0x64, 0x69, 0x66, 0x66, 0x65, 0x72, 0x65, 0x6e,
0x74, 0x29, 0x20, 0x70, 0x6c, 0x75, 0x73, 0x20, 0x74, 0x68, 0x65, 0x20, 0x69, 0x6e, 0x6f, 0x64,
0x65, 0x73, 0x20, 0x74, 0x68, 0x65, 0x20, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x20, 0x68, 0x61,
0x73, 0x20, 0x62, 0x75, 0x74, 0x20, 0x74, 0x68, 0x65, 0x20, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72,
0x20, 0x6e, 0x6f, 0x20, 0x6c, 0x6f, 0x6e, 0x67, 0x65, 0x72, 0x0a, 0x20, 0x68, 0x61, 0x73, 0x2e,
0x20, 0x43, 0x68, 0x65, 0x61, 0x70, 0x20, 0x6f, 0x6e, 0x20, 0x74, 0x68, 0x65, 0x20, 0x77, 0x69,
0x72, 0x65, 0x20, 0xe2, 0x80, 0x94, 0x20, 0x6e, 0x6f, 0x20, 0x66, 0x69, 0x6c, 0x65, 0x20, 0x6d,
0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x20, 0x63, 0x72, 0x6f, 0x73, 0x73, 0x65, 0x73, 0x20,
0x75, 0x6e, 0x74, 0x69, 0x6c, 0x20, 0x74, 0x68, 0x65, 0x20, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74,
0x20, 0x61, 0x73, 0x6b, 0x73, 0x2e, 0x0a, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x00, 0x01,
0x12, 0x03, 0x0b, 0x06, 0x0f, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x00, 0x02, 0x12, 0x03,
0x0b, 0x10, 0x20, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x00, 0x03, 0x12, 0x03, 0x0b, 0x2b,
0x3c, 0x0a, 0x94, 0x01, 0x0a, 0x04, 0x06, 0x00, 0x02, 0x01, 0x12, 0x03, 0x0f, 0x02, 0x45, 0x1a,
0x86, 0x01, 0x20, 0x46, 0x65, 0x74, 0x63, 0x68, 0x20, 0x66, 0x75, 0x6c, 0x6c, 0x20, 0x6d, 0x65,
0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x20, 0x66, 0x6f, 0x72, 0x20, 0x61, 0x20, 0x73, 0x70, 0x65,
0x63, 0x69, 0x66, 0x69, 0x63, 0x20, 0x73, 0x65, 0x74, 0x20, 0x6f, 0x66, 0x20, 0x69, 0x6e, 0x6f,
0x64, 0x65, 0x73, 0x2e, 0x20, 0x43, 0x61, 0x6c, 0x6c, 0x65, 0x64, 0x20, 0x61, 0x66, 0x74, 0x65,
0x72, 0x20, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65, 0x0a, 0x20, 0x72, 0x65, 0x74,
0x75, 0x72, 0x6e, 0x73, 0x20, 0x61, 0x20, 0x6e, 0x6f, 0x6e, 0x2d, 0x65, 0x6d, 0x70, 0x74, 0x79,
0x20, 0x60, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x60, 0x20, 0x6c, 0x69, 0x73, 0x74, 0x2c,
0x20, 0x66, 0x6f, 0x72, 0x20, 0x6a, 0x75, 0x73, 0x74, 0x20, 0x74, 0x68, 0x6f, 0x73, 0x65, 0x20,
0x69, 0x6e, 0x6f, 0x64, 0x65, 0x73, 0x2e, 0x0a, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x01,
0x01, 0x12, 0x03, 0x0f, 0x06, 0x11, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x01, 0x02, 0x12,
0x03, 0x0f, 0x12, 0x24, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x01, 0x06, 0x12, 0x03, 0x0f,
0x2f, 0x35, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x01, 0x03, 0x12, 0x03, 0x0f, 0x36, 0x43,
0x0a, 0xa8, 0x01, 0x0a, 0x04, 0x06, 0x00, 0x02, 0x02, 0x12, 0x03, 0x14, 0x02, 0x45, 0x1a, 0x9a,
0x01, 0x20, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x20, 0x65, 0x76, 0x65, 0x72, 0x79, 0x20, 0x66,
0x69, 0x6c, 0x65, 0x20, 0x65, 0x6e, 0x74, 0x72, 0x79, 0x20, 0x69, 0x6e, 0x20, 0x74, 0x68, 0x65,
0x20, 0x6c, 0x69, 0x62, 0x72, 0x61, 0x72, 0x79, 0x2e, 0x20, 0x43, 0x6f, 0x6e, 0x76, 0x65, 0x6e,
0x69, 0x65, 0x6e, 0x63, 0x65, 0x20, 0x52, 0x50, 0x43, 0x20, 0x66, 0x6f, 0x72, 0x20, 0x66, 0x69,
0x72, 0x73, 0x74, 0x2d, 0x72, 0x75, 0x6e, 0x0a, 0x20, 0x62, 0x6f, 0x6f, 0x74, 0x73, 0x74, 0x72,
0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x20, 0x6f, 0x72, 0x20, 0x66, 0x75, 0x6c, 0x6c, 0x2d, 0x72,
0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x2e, 0x20, 0x54, 0x68, 0x65, 0x20, 0x64, 0x69, 0x66, 0x66,
0x2d, 0x62, 0x61, 0x73, 0x65, 0x64, 0x20, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65,
0x20, 0x70, 0x61, 0x74, 0x68, 0x20, 0x69, 0x73, 0x20, 0x74, 0x68, 0x65, 0x0a, 0x20, 0x6e, 0x6f,
0x72, 0x6d, 0x61, 0x6c, 0x20, 0x63, 0x61, 0x73, 0x65, 0x2e, 0x0a, 0x0a, 0x0c, 0x0a, 0x05, 0x06,
0x00, 0x02, 0x02, 0x01, 0x12, 0x03, 0x14, 0x06, 0x11, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02,
0x02, 0x02, 0x12, 0x03, 0x14, 0x12, 0x24, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x02, 0x06,
0x12, 0x03, 0x14, 0x2f, 0x35, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x02, 0x03, 0x12, 0x03,
0x14, 0x36, 0x43, 0x0a, 0xf8, 0x01, 0x0a, 0x04, 0x06, 0x00, 0x02, 0x03, 0x12, 0x03, 0x1a, 0x02,
0x39, 0x1a, 0xea, 0x01, 0x20, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x20, 0x74, 0x68, 0x65, 0x20,
0x62, 0x79, 0x74, 0x65, 0x73, 0x20, 0x6f, 0x66, 0x20, 0x6f, 0x6e, 0x65, 0x20, 0x66, 0x69, 0x6c,
0x65, 0x2e, 0x20, 0x49, 0x66, 0x20, 0x60, 0x73, 0x74, 0x61, 0x72, 0x74, 0x60, 0x20, 0x61, 0x6e,
0x64, 0x20, 0x60, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x60, 0x20, 0x61, 0x72, 0x65, 0x20, 0x62,
0x6f, 0x74, 0x68, 0x20, 0x7a, 0x65, 0x72, 0x6f, 0x20, 0x74, 0x68, 0x65, 0x0a, 0x20, 0x73, 0x65,
0x72, 0x76, 0x65, 0x72, 0x20, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x73, 0x20, 0x74, 0x68, 0x65,
0x20, 0x65, 0x6e, 0x74, 0x69, 0x72, 0x65, 0x20, 0x66, 0x69, 0x6c, 0x65, 0x3b, 0x20, 0x6f, 0x74,
0x68, 0x65, 0x72, 0x77, 0x69, 0x73, 0x65, 0x20, 0x6a, 0x75, 0x73, 0x74, 0x20, 0x74, 0x68, 0x65,
0x20, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x65, 0x64, 0x20, 0x72, 0x61, 0x6e, 0x67, 0x65,
0x2e, 0x20, 0x54, 0x68, 0x65, 0x0a, 0x20, 0x66, 0x69, 0x72, 0x73, 0x74, 0x20, 0x63, 0x68, 0x75,
0x6e, 0x6b, 0x20, 0x63, 0x61, 0x72, 0x72, 0x69, 0x65, 0x73, 0x20, 0x60, 0x74, 0x6f, 0x74, 0x61,
0x6c, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x60, 0x20, 0x61, 0x6e, 0x64, 0x20, 0x60, 0x6f, 0x66, 0x66,
0x73, 0x65, 0x74, 0x60, 0x3b, 0x20, 0x73, 0x75, 0x62, 0x73, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x74,
0x20, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x73, 0x20, 0x63, 0x6f, 0x6e, 0x74, 0x69, 0x6e, 0x75, 0x65,
0x0a, 0x20, 0x66, 0x72, 0x6f, 0x6d, 0x20, 0x74, 0x68, 0x65, 0x72, 0x65, 0x2e, 0x0a, 0x0a, 0x0c,
0x0a, 0x05, 0x06, 0x00, 0x02, 0x03, 0x01, 0x12, 0x03, 0x1a, 0x06, 0x0d, 0x0a, 0x0c, 0x0a, 0x05,
0x06, 0x00, 0x02, 0x03, 0x02, 0x12, 0x03, 0x1a, 0x0e, 0x1c, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00,
0x02, 0x03, 0x06, 0x12, 0x03, 0x1a, 0x27, 0x2d, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x03,
0x03, 0x12, 0x03, 0x1a, 0x2e, 0x37, 0x0a, 0xc2, 0x01, 0x0a, 0x04, 0x06, 0x00, 0x02, 0x04, 0x12,
0x03, 0x1f, 0x02, 0x4b, 0x1a, 0xb4, 0x01, 0x20, 0x4c, 0x6f, 0x6e, 0x67, 0x2d, 0x6c, 0x69, 0x76,
0x65, 0x64, 0x20, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x20, 0x6f, 0x66, 0x20, 0x66, 0x69, 0x6c,
0x65, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x20, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x20, 0x65,
0x76, 0x65, 0x6e, 0x74, 0x73, 0x2e, 0x20, 0x54, 0x68, 0x65, 0x20, 0x63, 0x6c, 0x69, 0x65, 0x6e,
0x74, 0x20, 0x75, 0x73, 0x65, 0x73, 0x20, 0x74, 0x68, 0x65, 0x73, 0x65, 0x20, 0x61, 0x73, 0x0a,
0x20, 0x77, 0x61, 0x6b, 0x65, 0x2d, 0x75, 0x70, 0x73, 0x20, 0x74, 0x6f, 0x20, 0x72, 0x65, 0x66,
0x65, 0x74, 0x63, 0x68, 0x20, 0x74, 0x68, 0x65, 0x20, 0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73,
0x74, 0x3b, 0x20, 0x63, 0x6f, 0x72, 0x72, 0x65, 0x63, 0x74, 0x6e, 0x65, 0x73, 0x73, 0x20, 0x61,
0x6c, 0x77, 0x61, 0x79, 0x73, 0x20, 0x72, 0x65, 0x73, 0x74, 0x73, 0x20, 0x6f, 0x6e, 0x20, 0x74,
0x68, 0x65, 0x20, 0x68, 0x61, 0x73, 0x68, 0x0a, 0x20, 0x64, 0x69, 0x66, 0x66, 0x2c, 0x20, 0x6e,
0x65, 0x76, 0x65, 0x72, 0x20, 0x6f, 0x6e, 0x20, 0x74, 0x68, 0x65, 0x20, 0x65, 0x76, 0x65, 0x6e,
0x74, 0x20, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x2e, 0x0a, 0x0a, 0x0c, 0x0a, 0x05, 0x06,
0x00, 0x02, 0x04, 0x01, 0x12, 0x03, 0x1f, 0x06, 0x15, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02,
0x04, 0x02, 0x12, 0x03, 0x1f, 0x16, 0x2c, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x04, 0x06,
0x12, 0x03, 0x1f, 0x37, 0x3d, 0x0a, 0x0c, 0x0a, 0x05, 0x06, 0x00, 0x02, 0x04, 0x03, 0x12, 0x03,
0x1f, 0x3e, 0x49, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x00, 0x12, 0x04, 0x22, 0x00, 0x24, 0x01, 0x0a,
0x0a, 0x0a, 0x03, 0x04, 0x00, 0x01, 0x12, 0x03, 0x22, 0x08, 0x18, 0x0a, 0x0c, 0x0a, 0x05, 0x04,
0x00, 0x02, 0x00, 0x04, 0x12, 0x03, 0x23, 0x02, 0x0a, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x00, 0x02,
0x00, 0x12, 0x03, 0x23, 0x02, 0x21, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x00, 0x02, 0x00, 0x06, 0x12,
0x03, 0x23, 0x0b, 0x14, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x00, 0x02, 0x00, 0x01, 0x12, 0x03, 0x23,
0x15, 0x1c, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x00, 0x02, 0x00, 0x03, 0x12, 0x03, 0x23, 0x1f, 0x20,
0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x01, 0x12, 0x04, 0x26, 0x00, 0x29, 0x01, 0x0a, 0x0a, 0x0a, 0x03,
0x04, 0x01, 0x01, 0x12, 0x03, 0x26, 0x08, 0x19, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x01, 0x02, 0x00,
0x04, 0x12, 0x03, 0x27, 0x02, 0x0a, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x01, 0x02, 0x00, 0x12, 0x03,
0x27, 0x02, 0x21, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x01, 0x02, 0x00, 0x06, 0x12, 0x03, 0x27, 0x0b,
0x14, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x01, 0x02, 0x00, 0x01, 0x12, 0x03, 0x27, 0x15, 0x1c, 0x0a,
0x0c, 0x0a, 0x05, 0x04, 0x01, 0x02, 0x00, 0x03, 0x12, 0x03, 0x27, 0x1f, 0x20, 0x0a, 0x0c, 0x0a,
0x05, 0x04, 0x01, 0x02, 0x01, 0x04, 0x12, 0x03, 0x28, 0x02, 0x0a, 0x0a, 0x0b, 0x0a, 0x04, 0x04,
0x01, 0x02, 0x01, 0x12, 0x03, 0x28, 0x02, 0x1e, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x01, 0x02, 0x01,
0x05, 0x12, 0x03, 0x28, 0x0b, 0x11, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x01, 0x02, 0x01, 0x01, 0x12,
0x03, 0x28, 0x12, 0x19, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x01, 0x02, 0x01, 0x03, 0x12, 0x03, 0x28,
0x1c, 0x1d, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x02, 0x12, 0x04, 0x2b, 0x00, 0x2e, 0x01, 0x0a, 0x0a,
0x0a, 0x03, 0x04, 0x02, 0x01, 0x12, 0x03, 0x2b, 0x08, 0x11, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02,
0x02, 0x00, 0x05, 0x12, 0x03, 0x2c, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x02, 0x02, 0x00,
0x12, 0x03, 0x2c, 0x02, 0x13, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x00, 0x01, 0x12, 0x03,
0x2c, 0x09, 0x0e, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x00, 0x03, 0x12, 0x03, 0x2c, 0x11,
0x12, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02, 0x02, 0x01, 0x05, 0x12, 0x03, 0x2d, 0x02, 0x08, 0x0a,
0x0b, 0x0a, 0x04, 0x04, 0x02, 0x02, 0x01, 0x12, 0x03, 0x2d, 0x02, 0x12, 0x0a, 0x0c, 0x0a, 0x05,
0x04, 0x02, 0x02, 0x01, 0x01, 0x12, 0x03, 0x2d, 0x09, 0x0d, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x02,
0x02, 0x01, 0x03, 0x12, 0x03, 0x2d, 0x10, 0x11, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x03, 0x12, 0x04,
0x30, 0x00, 0x32, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x03, 0x01, 0x12, 0x03, 0x30, 0x08, 0x1a,
0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x03, 0x02, 0x00, 0x04, 0x12, 0x03, 0x31, 0x02, 0x0a, 0x0a, 0x0b,
0x0a, 0x04, 0x04, 0x03, 0x02, 0x00, 0x12, 0x03, 0x31, 0x02, 0x1d, 0x0a, 0x0c, 0x0a, 0x05, 0x04,
0x03, 0x02, 0x00, 0x05, 0x12, 0x03, 0x31, 0x0b, 0x11, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x03, 0x02,
0x00, 0x01, 0x12, 0x03, 0x31, 0x12, 0x18, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x03, 0x02, 0x00, 0x03,
0x12, 0x03, 0x31, 0x1b, 0x1c, 0x0a, 0x09, 0x0a, 0x02, 0x04, 0x04, 0x12, 0x03, 0x34, 0x00, 0x1d,
0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x04, 0x01, 0x12, 0x03, 0x34, 0x08, 0x1a, 0x0a, 0x0a, 0x0a, 0x02,
0x04, 0x05, 0x12, 0x04, 0x36, 0x00, 0x3b, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x05, 0x01, 0x12,
0x03, 0x36, 0x08, 0x16, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x00, 0x05, 0x12, 0x03, 0x37,
0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x05, 0x02, 0x00, 0x12, 0x03, 0x37, 0x02, 0x10, 0x0a,
0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x00, 0x01, 0x12, 0x03, 0x37, 0x09, 0x0b, 0x0a, 0x0c, 0x0a,
0x05, 0x04, 0x05, 0x02, 0x00, 0x03, 0x12, 0x03, 0x37, 0x0e, 0x0f, 0x0a, 0x0c, 0x0a, 0x05, 0x04,
0x05, 0x02, 0x01, 0x05, 0x12, 0x03, 0x39, 0x02, 0x08, 0x0a, 0x45, 0x0a, 0x04, 0x04, 0x05, 0x02,
0x01, 0x12, 0x03, 0x39, 0x02, 0x13, 0x1a, 0x38, 0x20, 0x57, 0x68, 0x65, 0x6e, 0x20, 0x62, 0x6f,
0x74, 0x68, 0x20, 0x61, 0x72, 0x65, 0x20, 0x7a, 0x65, 0x72, 0x6f, 0x20, 0x74, 0x68, 0x65, 0x20,
0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x20, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x73, 0x20, 0x74,
0x68, 0x65, 0x20, 0x65, 0x6e, 0x74, 0x69, 0x72, 0x65, 0x20, 0x66, 0x69, 0x6c, 0x65, 0x2e, 0x0a,
0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x01, 0x01, 0x12, 0x03, 0x39, 0x09, 0x0e, 0x0a, 0x0c,
0x0a, 0x05, 0x04, 0x05, 0x02, 0x01, 0x03, 0x12, 0x03, 0x39, 0x11, 0x12, 0x0a, 0x0c, 0x0a, 0x05,
0x04, 0x05, 0x02, 0x02, 0x05, 0x12, 0x03, 0x3a, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x05,
0x02, 0x02, 0x12, 0x03, 0x3a, 0x02, 0x14, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x02, 0x01,
0x12, 0x03, 0x3a, 0x09, 0x0f, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x05, 0x02, 0x02, 0x03, 0x12, 0x03,
0x3a, 0x12, 0x13, 0x0a, 0x09, 0x0a, 0x02, 0x04, 0x06, 0x12, 0x03, 0x3d, 0x00, 0x21, 0x0a, 0x0a,
0x0a, 0x03, 0x04, 0x06, 0x01, 0x12, 0x03, 0x3d, 0x08, 0x1e, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x07,
0x12, 0x04, 0x3f, 0x00, 0x47, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x07, 0x01, 0x12, 0x03, 0x3f,
0x08, 0x15, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x07, 0x02, 0x00, 0x05, 0x12, 0x03, 0x40, 0x02, 0x08,
0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x07, 0x02, 0x00, 0x12, 0x03, 0x40, 0x02, 0x10, 0x0a, 0x0c, 0x0a,
0x05, 0x04, 0x07, 0x02, 0x00, 0x01, 0x12, 0x03, 0x40, 0x09, 0x0b, 0x0a, 0x0c, 0x0a, 0x05, 0x04,
0x07, 0x02, 0x00, 0x03, 0x12, 0x03, 0x40, 0x0e, 0x0f, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x07, 0x02,
0x01, 0x05, 0x12, 0x03, 0x41, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x07, 0x02, 0x01, 0x12,
0x03, 0x41, 0x02, 0x16, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x07, 0x02, 0x01, 0x01, 0x12, 0x03, 0x41,
0x09, 0x11, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x07, 0x02, 0x01, 0x03, 0x12, 0x03, 0x41, 0x14, 0x15,
0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x07, 0x02, 0x02, 0x05, 0x12, 0x03, 0x42, 0x02, 0x08, 0x0a, 0x0b,
0x0a, 0x04, 0x04, 0x07, 0x02, 0x02, 0x12, 0x03, 0x42, 0x02, 0x12, 0x0a, 0x0c, 0x0a, 0x05, 0x04,
0x07, 0x02, 0x02, 0x01, 0x12, 0x03, 0x42, 0x09, 0x0d, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x07, 0x02,
0x02, 0x03, 0x12, 0x03, 0x42, 0x10, 0x11, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x07, 0x02, 0x03, 0x05,
0x12, 0x03, 0x43, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x07, 0x02, 0x03, 0x12, 0x03, 0x43,
0x02, 0x13, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x07, 0x02, 0x03, 0x01, 0x12, 0x03, 0x43, 0x09, 0x0e,
0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x07, 0x02, 0x03, 0x03, 0x12, 0x03, 0x43, 0x11, 0x12, 0x0a, 0x0c,
0x0a, 0x05, 0x04, 0x07, 0x02, 0x04, 0x05, 0x12, 0x03, 0x44, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04,
0x04, 0x07, 0x02, 0x04, 0x12, 0x03, 0x44, 0x02, 0x13, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x07, 0x02,
0x04, 0x01, 0x12, 0x03, 0x44, 0x09, 0x0e, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x07, 0x02, 0x04, 0x03,
0x12, 0x03, 0x44, 0x11, 0x12, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x07, 0x02, 0x05, 0x05, 0x12, 0x03,
0x45, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x07, 0x02, 0x05, 0x12, 0x03, 0x45, 0x02, 0x14,
0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x07, 0x02, 0x05, 0x01, 0x12, 0x03, 0x45, 0x09, 0x0f, 0x0a, 0x0c,
0x0a, 0x05, 0x04, 0x07, 0x02, 0x05, 0x03, 0x12, 0x03, 0x45, 0x12, 0x13, 0x0a, 0x0c, 0x0a, 0x05,
0x04, 0x07, 0x02, 0x06, 0x06, 0x12, 0x03, 0x46, 0x02, 0x0f, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x07,
0x02, 0x06, 0x12, 0x03, 0x46, 0x02, 0x23, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x07, 0x02, 0x06, 0x01,
0x12, 0x03, 0x46, 0x10, 0x1e, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x07, 0x02, 0x06, 0x03, 0x12, 0x03,
0x46, 0x21, 0x22, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x08, 0x12, 0x04, 0x49, 0x00, 0x56, 0x01, 0x0a,
0x0a, 0x0a, 0x03, 0x04, 0x08, 0x01, 0x12, 0x03, 0x49, 0x08, 0x15, 0x0a, 0x0c, 0x0a, 0x05, 0x04,
0x08, 0x02, 0x00, 0x04, 0x12, 0x03, 0x4a, 0x02, 0x0a, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x08, 0x02,
0x00, 0x12, 0x03, 0x4a, 0x02, 0x1d, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x00, 0x05, 0x12,
0x03, 0x4a, 0x0b, 0x11, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x00, 0x01, 0x12, 0x03, 0x4a,
0x12, 0x18, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x00, 0x03, 0x12, 0x03, 0x4a, 0x1b, 0x1c,
0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x01, 0x04, 0x12, 0x03, 0x4b, 0x02, 0x0a, 0x0a, 0x0b,
0x0a, 0x04, 0x04, 0x08, 0x02, 0x01, 0x12, 0x03, 0x4b, 0x02, 0x23, 0x0a, 0x0c, 0x0a, 0x05, 0x04,
0x08, 0x02, 0x01, 0x05, 0x12, 0x03, 0x4b, 0x0b, 0x11, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02,
0x01, 0x01, 0x12, 0x03, 0x4b, 0x12, 0x1e, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x01, 0x03,
0x12, 0x03, 0x4b, 0x21, 0x22, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x02, 0x05, 0x12, 0x03,
0x4c, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x08, 0x02, 0x02, 0x12, 0x03, 0x4c, 0x02, 0x13,
0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x02, 0x01, 0x12, 0x03, 0x4c, 0x09, 0x0e, 0x0a, 0x0c,
0x0a, 0x05, 0x04, 0x08, 0x02, 0x02, 0x03, 0x12, 0x03, 0x4c, 0x11, 0x12, 0x0a, 0x0c, 0x0a, 0x05,
0x04, 0x08, 0x02, 0x03, 0x05, 0x12, 0x03, 0x4d, 0x02, 0x07, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x08,
0x02, 0x03, 0x12, 0x03, 0x4d, 0x02, 0x19, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x03, 0x01,
0x12, 0x03, 0x4d, 0x08, 0x14, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x03, 0x03, 0x12, 0x03,
0x4d, 0x17, 0x18, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x04, 0x05, 0x12, 0x03, 0x4e, 0x02,
0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x08, 0x02, 0x04, 0x12, 0x03, 0x4e, 0x02, 0x19, 0x0a, 0x0c,
0x0a, 0x05, 0x04, 0x08, 0x02, 0x04, 0x01, 0x12, 0x03, 0x4e, 0x09, 0x14, 0x0a, 0x0c, 0x0a, 0x05,
0x04, 0x08, 0x02, 0x04, 0x03, 0x12, 0x03, 0x4e, 0x17, 0x18, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08,
0x02, 0x05, 0x04, 0x12, 0x03, 0x4f, 0x02, 0x0a, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x08, 0x02, 0x05,
0x12, 0x03, 0x4f, 0x02, 0x21, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x05, 0x05, 0x12, 0x03,
0x4f, 0x0b, 0x11, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x05, 0x01, 0x12, 0x03, 0x4f, 0x12,
0x1c, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x05, 0x03, 0x12, 0x03, 0x4f, 0x1f, 0x20, 0x0a,
0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x06, 0x05, 0x12, 0x03, 0x50, 0x02, 0x07, 0x0a, 0x0b, 0x0a,
0x04, 0x04, 0x08, 0x02, 0x06, 0x12, 0x03, 0x50, 0x02, 0x13, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08,
0x02, 0x06, 0x01, 0x12, 0x03, 0x50, 0x08, 0x0e, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x06,
0x03, 0x12, 0x03, 0x50, 0x11, 0x12, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x07, 0x04, 0x12,
0x03, 0x51, 0x02, 0x0a, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x08, 0x02, 0x07, 0x12, 0x03, 0x51, 0x02,
0x2b, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x07, 0x05, 0x12, 0x03, 0x51, 0x0b, 0x10, 0x0a,
0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x07, 0x01, 0x12, 0x03, 0x51, 0x11, 0x26, 0x0a, 0x0c, 0x0a,
0x05, 0x04, 0x08, 0x02, 0x07, 0x03, 0x12, 0x03, 0x51, 0x29, 0x2a, 0x0a, 0x0c, 0x0a, 0x05, 0x04,
0x08, 0x02, 0x08, 0x04, 0x12, 0x03, 0x52, 0x02, 0x0a, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x08, 0x02,
0x08, 0x12, 0x03, 0x52, 0x02, 0x34, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x08, 0x06, 0x12,
0x03, 0x52, 0x0b, 0x1b, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x08, 0x01, 0x12, 0x03, 0x52,
0x1c, 0x2f, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x08, 0x03, 0x12, 0x03, 0x52, 0x32, 0x33,
0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x09, 0x05, 0x12, 0x03, 0x53, 0x02, 0x08, 0x0a, 0x0b,
0x0a, 0x04, 0x04, 0x08, 0x02, 0x09, 0x12, 0x03, 0x53, 0x02, 0x1f, 0x0a, 0x0c, 0x0a, 0x05, 0x04,
0x08, 0x02, 0x09, 0x01, 0x12, 0x03, 0x53, 0x09, 0x19, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02,
0x09, 0x03, 0x12, 0x03, 0x53, 0x1c, 0x1e, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x0a, 0x05,
0x12, 0x03, 0x54, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x08, 0x02, 0x0a, 0x12, 0x03, 0x54,
0x02, 0x24, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x0a, 0x01, 0x12, 0x03, 0x54, 0x09, 0x1e,
0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x0a, 0x03, 0x12, 0x03, 0x54, 0x21, 0x23, 0x0a, 0x0c,
0x0a, 0x05, 0x04, 0x08, 0x02, 0x0b, 0x05, 0x12, 0x03, 0x55, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04,
0x04, 0x08, 0x02, 0x0b, 0x12, 0x03, 0x55, 0x02, 0x24, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02,
0x0b, 0x01, 0x12, 0x03, 0x55, 0x09, 0x1e, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x08, 0x02, 0x0b, 0x03,
0x12, 0x03, 0x55, 0x21, 0x23, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x09, 0x12, 0x04, 0x58, 0x00, 0x5b,
0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x09, 0x01, 0x12, 0x03, 0x58, 0x08, 0x18, 0x0a, 0x0c, 0x0a,
0x05, 0x04, 0x09, 0x02, 0x00, 0x05, 0x12, 0x03, 0x59, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04,
0x09, 0x02, 0x00, 0x12, 0x03, 0x59, 0x02, 0x14, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x09, 0x02, 0x00,
0x01, 0x12, 0x03, 0x59, 0x09, 0x0f, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x09, 0x02, 0x00, 0x03, 0x12,
0x03, 0x59, 0x12, 0x13, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x09, 0x02, 0x01, 0x05, 0x12, 0x03, 0x5a,
0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x09, 0x02, 0x01, 0x12, 0x03, 0x5a, 0x02, 0x14, 0x0a,
0x0c, 0x0a, 0x05, 0x04, 0x09, 0x02, 0x01, 0x01, 0x12, 0x03, 0x5a, 0x09, 0x0f, 0x0a, 0x0c, 0x0a,
0x05, 0x04, 0x09, 0x02, 0x01, 0x03, 0x12, 0x03, 0x5a, 0x12, 0x13, 0x0a, 0x0a, 0x0a, 0x02, 0x04,
0x0a, 0x12, 0x04, 0x5d, 0x00, 0x61, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x0a, 0x01, 0x12, 0x03,
0x5d, 0x08, 0x11, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0a, 0x02, 0x00, 0x05, 0x12, 0x03, 0x5e, 0x02,
0x07, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x0a, 0x02, 0x00, 0x12, 0x03, 0x5e, 0x02, 0x11, 0x0a, 0x0c,
0x0a, 0x05, 0x04, 0x0a, 0x02, 0x00, 0x01, 0x12, 0x03, 0x5e, 0x08, 0x0c, 0x0a, 0x0c, 0x0a, 0x05,
0x04, 0x0a, 0x02, 0x00, 0x03, 0x12, 0x03, 0x5e, 0x0f, 0x10, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0a,
0x02, 0x01, 0x05, 0x12, 0x03, 0x5f, 0x02, 0x08, 0x0a, 0x0b, 0x0a, 0x04, 0x04, 0x0a, 0x02, 0x01,
0x12, 0x03, 0x5f, 0x02, 0x14, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0a, 0x02, 0x01, 0x01, 0x12, 0x03,
0x5f, 0x09, 0x0f, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0a, 0x02, 0x01, 0x03, 0x12, 0x03, 0x5f, 0x12,
0x13, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0a, 0x02, 0x02, 0x05, 0x12, 0x03, 0x60, 0x02, 0x08, 0x0a,
0x0b, 0x0a, 0x04, 0x04, 0x0a, 0x02, 0x02, 0x12, 0x03, 0x60, 0x02, 0x18, 0x0a, 0x0c, 0x0a, 0x05,
0x04, 0x0a, 0x02, 0x02, 0x01, 0x12, 0x03, 0x60, 0x09, 0x13, 0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0a,
0x02, 0x02, 0x03, 0x12, 0x03, 0x60, 0x16, 0x17, 0x0a, 0x0a, 0x0a, 0x02, 0x04, 0x0b, 0x12, 0x04,
0x63, 0x00, 0x66, 0x01, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x0b, 0x01, 0x12, 0x03, 0x63, 0x08, 0x13,
0x0a, 0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x00, 0x05, 0x12, 0x03, 0x65, 0x02, 0x08, 0x0a, 0x2f,
0x0a, 0x04, 0x04, 0x0b, 0x02, 0x00, 0x12, 0x03, 0x65, 0x02, 0x12, 0x1a, 0x22, 0x20, 0x22, 0x63,
0x72, 0x65, 0x61, 0x74, 0x65, 0x22, 0x2c, 0x20, 0x22, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x79, 0x22,
0x2c, 0x20, 0x6f, 0x72, 0x20, 0x22, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x22, 0x2e, 0x0a, 0x0a,
0x0c, 0x0a, 0x05, 0x04, 0x0b, 0x02, 0x00, 0x01, 0x12, 0x03, 0x65, 0x09, 0x0d, 0x0a, 0x0c, 0x0a,
0x05, 0x04, 0x0b, 0x02, 0x00, 0x03, 0x12, 0x03, 0x65, 0x10, 0x11, 0x62, 0x06, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x33,
];
include!("musicfs.tonic.rs");
// @@protoc_insertion_point(module)
@@ -0,0 +1,544 @@
// @generated
/// Generated client implementations.
pub mod music_fs_client {
#![allow(
unused_variables,
dead_code,
missing_docs,
clippy::wildcard_imports,
clippy::let_unit_value
)]
use tonic::codegen::http::Uri;
use tonic::codegen::*;
#[derive(Debug, Clone)]
pub struct MusicFsClient<T> {
inner: tonic::client::Grpc<T>,
}
impl MusicFsClient<tonic::transport::Channel> {
/// Attempt to create a new client by connecting to a given endpoint.
pub async fn connect<D>(dst: D) -> Result<Self, tonic::transport::Error>
where
D: TryInto<tonic::transport::Endpoint>,
D::Error: Into<StdError>,
{
let conn = tonic::transport::Endpoint::new(dst)?.connect().await?;
Ok(Self::new(conn))
}
}
impl<T> MusicFsClient<T>
where
T: tonic::client::GrpcService<tonic::body::Body>,
T::Error: Into<StdError>,
T::ResponseBody: Body<Data = Bytes> + std::marker::Send + 'static,
<T::ResponseBody as Body>::Error: Into<StdError> + std::marker::Send,
{
pub fn new(inner: T) -> Self {
let inner = tonic::client::Grpc::new(inner);
Self { inner }
}
pub fn with_origin(inner: T, origin: Uri) -> Self {
let inner = tonic::client::Grpc::with_origin(inner, origin);
Self { inner }
}
pub fn with_interceptor<F>(
inner: T,
interceptor: F,
) -> MusicFsClient<InterceptedService<T, F>>
where
F: tonic::service::Interceptor,
T::ResponseBody: Default,
T: tonic::codegen::Service<
http::Request<tonic::body::Body>,
Response = http::Response<
<T as tonic::client::GrpcService<tonic::body::Body>>::ResponseBody,
>,
>,
<T as tonic::codegen::Service<http::Request<tonic::body::Body>>>::Error:
Into<StdError> + std::marker::Send + std::marker::Sync,
{
MusicFsClient::new(InterceptedService::new(inner, interceptor))
}
/// Compress requests with the given encoding.
///
/// This requires the server to support it otherwise it might respond with an
/// error.
#[must_use]
pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.inner = self.inner.send_compressed(encoding);
self
}
/// Enable decompressing responses.
#[must_use]
pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.inner = self.inner.accept_compressed(encoding);
self
}
/// Limits the maximum size of a decoded message.
///
/// Default: `4MB`
#[must_use]
pub fn max_decoding_message_size(mut self, limit: usize) -> Self {
self.inner = self.inner.max_decoding_message_size(limit);
self
}
/// Limits the maximum size of an encoded message.
///
/// Default: `usize::MAX`
#[must_use]
pub fn max_encoding_message_size(mut self, limit: usize) -> Self {
self.inner = self.inner.max_encoding_message_size(limit);
self
}
pub async fn reconcile(
&mut self,
request: impl tonic::IntoRequest<super::ReconcileRequest>,
) -> std::result::Result<tonic::Response<super::ReconcileResponse>, tonic::Status> {
self.inner.ready().await.map_err(|e| {
tonic::Status::unknown(format!("Service was not ready: {}", e.into()))
})?;
let codec = tonic_prost::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static("/musicfs.MusicFs/Reconcile");
let mut req = request.into_request();
req.extensions_mut()
.insert(GrpcMethod::new("musicfs.MusicFs", "Reconcile"));
self.inner.unary(req, path, codec).await
}
pub async fn get_metadata(
&mut self,
request: impl tonic::IntoRequest<super::GetMetadataRequest>,
) -> std::result::Result<
tonic::Response<tonic::codec::Streaming<super::ManifestEntry>>,
tonic::Status,
> {
self.inner.ready().await.map_err(|e| {
tonic::Status::unknown(format!("Service was not ready: {}", e.into()))
})?;
let codec = tonic_prost::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static("/musicfs.MusicFs/GetMetadata");
let mut req = request.into_request();
req.extensions_mut()
.insert(GrpcMethod::new("musicfs.MusicFs", "GetMetadata"));
self.inner.server_streaming(req, path, codec).await
}
pub async fn get_manifest(
&mut self,
request: impl tonic::IntoRequest<super::GetManifestRequest>,
) -> std::result::Result<
tonic::Response<tonic::codec::Streaming<super::ManifestEntry>>,
tonic::Status,
> {
self.inner.ready().await.map_err(|e| {
tonic::Status::unknown(format!("Service was not ready: {}", e.into()))
})?;
let codec = tonic_prost::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static("/musicfs.MusicFs/GetManifest");
let mut req = request.into_request();
req.extensions_mut()
.insert(GrpcMethod::new("musicfs.MusicFs", "GetManifest"));
self.inner.server_streaming(req, path, codec).await
}
pub async fn get_file(
&mut self,
request: impl tonic::IntoRequest<super::GetFileRequest>,
) -> std::result::Result<
tonic::Response<tonic::codec::Streaming<super::FileChunk>>,
tonic::Status,
> {
self.inner.ready().await.map_err(|e| {
tonic::Status::unknown(format!("Service was not ready: {}", e.into()))
})?;
let codec = tonic_prost::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static("/musicfs.MusicFs/GetFile");
let mut req = request.into_request();
req.extensions_mut()
.insert(GrpcMethod::new("musicfs.MusicFs", "GetFile"));
self.inner.server_streaming(req, path, codec).await
}
pub async fn subscribe_events(
&mut self,
request: impl tonic::IntoRequest<super::SubscribeEventsRequest>,
) -> std::result::Result<
tonic::Response<tonic::codec::Streaming<super::ChangeEvent>>,
tonic::Status,
> {
self.inner.ready().await.map_err(|e| {
tonic::Status::unknown(format!("Service was not ready: {}", e.into()))
})?;
let codec = tonic_prost::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static("/musicfs.MusicFs/SubscribeEvents");
let mut req = request.into_request();
req.extensions_mut()
.insert(GrpcMethod::new("musicfs.MusicFs", "SubscribeEvents"));
self.inner.server_streaming(req, path, codec).await
}
}
}
/// Generated server implementations.
pub mod music_fs_server {
#![allow(
unused_variables,
dead_code,
missing_docs,
clippy::wildcard_imports,
clippy::let_unit_value
)]
use tonic::codegen::*;
/// Generated trait containing gRPC methods that should be implemented for use with MusicFsServer.
#[async_trait]
pub trait MusicFs: std::marker::Send + std::marker::Sync + 'static {
async fn reconcile(
&self,
request: tonic::Request<super::ReconcileRequest>,
) -> std::result::Result<tonic::Response<super::ReconcileResponse>, tonic::Status>;
/// Server streaming response type for the GetMetadata method.
type GetMetadataStream: tonic::codegen::tokio_stream::Stream<
Item = std::result::Result<super::ManifestEntry, tonic::Status>,
> + std::marker::Send
+ 'static;
async fn get_metadata(
&self,
request: tonic::Request<super::GetMetadataRequest>,
) -> std::result::Result<tonic::Response<Self::GetMetadataStream>, tonic::Status>;
/// Server streaming response type for the GetManifest method.
type GetManifestStream: tonic::codegen::tokio_stream::Stream<
Item = std::result::Result<super::ManifestEntry, tonic::Status>,
> + std::marker::Send
+ 'static;
async fn get_manifest(
&self,
request: tonic::Request<super::GetManifestRequest>,
) -> std::result::Result<tonic::Response<Self::GetManifestStream>, tonic::Status>;
/// Server streaming response type for the GetFile method.
type GetFileStream: tonic::codegen::tokio_stream::Stream<
Item = std::result::Result<super::FileChunk, tonic::Status>,
> + std::marker::Send
+ 'static;
async fn get_file(
&self,
request: tonic::Request<super::GetFileRequest>,
) -> std::result::Result<tonic::Response<Self::GetFileStream>, tonic::Status>;
/// Server streaming response type for the SubscribeEvents method.
type SubscribeEventsStream: tonic::codegen::tokio_stream::Stream<
Item = std::result::Result<super::ChangeEvent, tonic::Status>,
> + std::marker::Send
+ 'static;
async fn subscribe_events(
&self,
request: tonic::Request<super::SubscribeEventsRequest>,
) -> std::result::Result<tonic::Response<Self::SubscribeEventsStream>, tonic::Status>;
}
#[derive(Debug)]
pub struct MusicFsServer<T> {
inner: Arc<T>,
accept_compression_encodings: EnabledCompressionEncodings,
send_compression_encodings: EnabledCompressionEncodings,
max_decoding_message_size: Option<usize>,
max_encoding_message_size: Option<usize>,
}
impl<T> MusicFsServer<T> {
pub fn new(inner: T) -> Self {
Self::from_arc(Arc::new(inner))
}
pub fn from_arc(inner: Arc<T>) -> Self {
Self {
inner,
accept_compression_encodings: Default::default(),
send_compression_encodings: Default::default(),
max_decoding_message_size: None,
max_encoding_message_size: None,
}
}
pub fn with_interceptor<F>(inner: T, interceptor: F) -> InterceptedService<Self, F>
where
F: tonic::service::Interceptor,
{
InterceptedService::new(Self::new(inner), interceptor)
}
/// Enable decompressing requests with the given encoding.
#[must_use]
pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.accept_compression_encodings.enable(encoding);
self
}
/// Compress responses with the given encoding, if the client supports it.
#[must_use]
pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.send_compression_encodings.enable(encoding);
self
}
/// Limits the maximum size of a decoded message.
///
/// Default: `4MB`
#[must_use]
pub fn max_decoding_message_size(mut self, limit: usize) -> Self {
self.max_decoding_message_size = Some(limit);
self
}
/// Limits the maximum size of an encoded message.
///
/// Default: `usize::MAX`
#[must_use]
pub fn max_encoding_message_size(mut self, limit: usize) -> Self {
self.max_encoding_message_size = Some(limit);
self
}
}
impl<T, B> tonic::codegen::Service<http::Request<B>> for MusicFsServer<T>
where
T: MusicFs,
B: Body + std::marker::Send + 'static,
B::Error: Into<StdError> + std::marker::Send + 'static,
{
type Response = http::Response<tonic::body::Body>;
type Error = std::convert::Infallible;
type Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready(
&mut self,
_cx: &mut Context<'_>,
) -> Poll<std::result::Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, req: http::Request<B>) -> Self::Future {
match req.uri().path() {
"/musicfs.MusicFs/Reconcile" => {
#[allow(non_camel_case_types)]
struct ReconcileSvc<T: MusicFs>(pub Arc<T>);
impl<T: MusicFs> tonic::server::UnaryService<super::ReconcileRequest> for ReconcileSvc<T> {
type Response = super::ReconcileResponse;
type Future = BoxFuture<tonic::Response<Self::Response>, tonic::Status>;
fn call(
&mut self,
request: tonic::Request<super::ReconcileRequest>,
) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut =
async move { <T as MusicFs>::reconcile(&inner, request).await };
Box::pin(fut)
}
}
let accept_compression_encodings = self.accept_compression_encodings;
let send_compression_encodings = self.send_compression_encodings;
let max_decoding_message_size = self.max_decoding_message_size;
let max_encoding_message_size = self.max_encoding_message_size;
let inner = self.inner.clone();
let fut = async move {
let method = ReconcileSvc(inner);
let codec = tonic_prost::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config(
accept_compression_encodings,
send_compression_encodings,
)
.apply_max_message_size_config(
max_decoding_message_size,
max_encoding_message_size,
);
let res = grpc.unary(method, req).await;
Ok(res)
};
Box::pin(fut)
}
"/musicfs.MusicFs/GetMetadata" => {
#[allow(non_camel_case_types)]
struct GetMetadataSvc<T: MusicFs>(pub Arc<T>);
impl<T: MusicFs>
tonic::server::ServerStreamingService<super::GetMetadataRequest>
for GetMetadataSvc<T>
{
type Response = super::ManifestEntry;
type ResponseStream = T::GetMetadataStream;
type Future =
BoxFuture<tonic::Response<Self::ResponseStream>, tonic::Status>;
fn call(
&mut self,
request: tonic::Request<super::GetMetadataRequest>,
) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut =
async move { <T as MusicFs>::get_metadata(&inner, request).await };
Box::pin(fut)
}
}
let accept_compression_encodings = self.accept_compression_encodings;
let send_compression_encodings = self.send_compression_encodings;
let max_decoding_message_size = self.max_decoding_message_size;
let max_encoding_message_size = self.max_encoding_message_size;
let inner = self.inner.clone();
let fut = async move {
let method = GetMetadataSvc(inner);
let codec = tonic_prost::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config(
accept_compression_encodings,
send_compression_encodings,
)
.apply_max_message_size_config(
max_decoding_message_size,
max_encoding_message_size,
);
let res = grpc.server_streaming(method, req).await;
Ok(res)
};
Box::pin(fut)
}
"/musicfs.MusicFs/GetManifest" => {
#[allow(non_camel_case_types)]
struct GetManifestSvc<T: MusicFs>(pub Arc<T>);
impl<T: MusicFs>
tonic::server::ServerStreamingService<super::GetManifestRequest>
for GetManifestSvc<T>
{
type Response = super::ManifestEntry;
type ResponseStream = T::GetManifestStream;
type Future =
BoxFuture<tonic::Response<Self::ResponseStream>, tonic::Status>;
fn call(
&mut self,
request: tonic::Request<super::GetManifestRequest>,
) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut =
async move { <T as MusicFs>::get_manifest(&inner, request).await };
Box::pin(fut)
}
}
let accept_compression_encodings = self.accept_compression_encodings;
let send_compression_encodings = self.send_compression_encodings;
let max_decoding_message_size = self.max_decoding_message_size;
let max_encoding_message_size = self.max_encoding_message_size;
let inner = self.inner.clone();
let fut = async move {
let method = GetManifestSvc(inner);
let codec = tonic_prost::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config(
accept_compression_encodings,
send_compression_encodings,
)
.apply_max_message_size_config(
max_decoding_message_size,
max_encoding_message_size,
);
let res = grpc.server_streaming(method, req).await;
Ok(res)
};
Box::pin(fut)
}
"/musicfs.MusicFs/GetFile" => {
#[allow(non_camel_case_types)]
struct GetFileSvc<T: MusicFs>(pub Arc<T>);
impl<T: MusicFs> tonic::server::ServerStreamingService<super::GetFileRequest> for GetFileSvc<T> {
type Response = super::FileChunk;
type ResponseStream = T::GetFileStream;
type Future =
BoxFuture<tonic::Response<Self::ResponseStream>, tonic::Status>;
fn call(
&mut self,
request: tonic::Request<super::GetFileRequest>,
) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut =
async move { <T as MusicFs>::get_file(&inner, request).await };
Box::pin(fut)
}
}
let accept_compression_encodings = self.accept_compression_encodings;
let send_compression_encodings = self.send_compression_encodings;
let max_decoding_message_size = self.max_decoding_message_size;
let max_encoding_message_size = self.max_encoding_message_size;
let inner = self.inner.clone();
let fut = async move {
let method = GetFileSvc(inner);
let codec = tonic_prost::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config(
accept_compression_encodings,
send_compression_encodings,
)
.apply_max_message_size_config(
max_decoding_message_size,
max_encoding_message_size,
);
let res = grpc.server_streaming(method, req).await;
Ok(res)
};
Box::pin(fut)
}
"/musicfs.MusicFs/SubscribeEvents" => {
#[allow(non_camel_case_types)]
struct SubscribeEventsSvc<T: MusicFs>(pub Arc<T>);
impl<T: MusicFs>
tonic::server::ServerStreamingService<super::SubscribeEventsRequest>
for SubscribeEventsSvc<T>
{
type Response = super::ChangeEvent;
type ResponseStream = T::SubscribeEventsStream;
type Future =
BoxFuture<tonic::Response<Self::ResponseStream>, tonic::Status>;
fn call(
&mut self,
request: tonic::Request<super::SubscribeEventsRequest>,
) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut = async move {
<T as MusicFs>::subscribe_events(&inner, request).await
};
Box::pin(fut)
}
}
let accept_compression_encodings = self.accept_compression_encodings;
let send_compression_encodings = self.send_compression_encodings;
let max_decoding_message_size = self.max_decoding_message_size;
let max_encoding_message_size = self.max_encoding_message_size;
let inner = self.inner.clone();
let fut = async move {
let method = SubscribeEventsSvc(inner);
let codec = tonic_prost::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config(
accept_compression_encodings,
send_compression_encodings,
)
.apply_max_message_size_config(
max_decoding_message_size,
max_encoding_message_size,
);
let res = grpc.server_streaming(method, req).await;
Ok(res)
};
Box::pin(fut)
}
_ => Box::pin(async move {
let mut response = http::Response::new(tonic::body::Body::default());
let headers = response.headers_mut();
headers.insert(
tonic::Status::GRPC_STATUS,
(tonic::Code::Unimplemented as i32).into(),
);
headers.insert(
http::header::CONTENT_TYPE,
tonic::metadata::GRPC_CONTENT_TYPE,
);
Ok(response)
}),
}
}
}
impl<T> Clone for MusicFsServer<T> {
fn clone(&self) -> Self {
let inner = self.inner.clone();
Self {
inner,
accept_compression_encodings: self.accept_compression_encodings,
send_compression_encodings: self.send_compression_encodings,
max_decoding_message_size: self.max_decoding_message_size,
max_encoding_message_size: self.max_encoding_message_size,
}
}
}
/// Generated gRPC service name
pub const SERVICE_NAME: &str = "musicfs.MusicFs";
impl<T> tonic::server::NamedService for MusicFsServer<T> {
const NAME: &'static str = SERVICE_NAME;
}
}
+11
View File
@@ -0,0 +1,11 @@
pub mod musicfs {
include!("generated/musicfs/musicfs.rs");
}
pub use musicfs::{
ChangeEvent, FileChunk, GetFileRequest, GetManifestRequest, GetMetadataRequest, InodeHash,
ManifestEntry, MusicMetadata, PictureDataRange, ReconcileRequest, ReconcileResponse,
SubscribeEventsRequest,
music_fs_client::MusicFsClient,
music_fs_server::{MusicFs, MusicFsServer},
};
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "musicfs-server"
version.workspace = true
edition.workspace = true
default-run = "musicfs-server"
[[bin]]
name = "musicfs-server"
path = "src/bin/musicfs-server.rs"
[dependencies]
musicfs-core.workspace = true
musicfs-proto.workspace = true
tonic.workspace = true
tonic-health.workspace = true
tonic-reflection.workspace = true
tokio.workspace = true
anyhow.workspace = true
async-trait.workspace = true
clap.workspace = true
notify.workspace = true
tokio-stream.workspace = true
tracing.workspace = true
[dev-dependencies]
tempfile.workspace = true
@@ -0,0 +1,102 @@
use std::{net::SocketAddr, path::PathBuf};
use anyhow::{Context, Result};
use clap::Parser;
use musicfs_core::logging::{LogConfig, init};
use musicfs_server::server::{
state::ServerState,
transport::{self, TransportArgs},
watcher::ServerWatcher,
};
use tokio::signal::unix::{SignalKind, signal};
use tracing::{error, info};
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
/// Directory containing the music library to serve.
#[arg(short, long, required = true)]
source: PathBuf,
/// Address:port the transport should listen on (e.g. 0.0.0.0:50051).
#[arg(short, long, default_value = "0.0.0.0:50051")]
listen: SocketAddr,
/// Name of the transport implementation to use. Today only "grpc".
#[arg(short, long, default_value = "grpc")]
transport: String,
/// Directory for daily-rotated log files.
#[arg(long, default_value = "./logs")]
log_dir: PathBuf,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
// Bind the guard for the whole process so the non-blocking file writer
// flushes on exit. Initialized before anything else so even early
// failures land in the log.
let _guard = init(LogConfig {
log_dir: args.log_dir.clone(),
file_prefix: "musicfs-server".to_string(),
max_files: 7,
});
let source = args.source.clone();
info!(
source = %source.display(),
transport = %args.transport,
listen = %args.listen,
log_dir = %args.log_dir.display(),
"musicfs-server starting"
);
if !source.is_dir() {
error!(source = %source.display(), "source is not a readable directory");
return Err(anyhow::anyhow!(
"source is not a readable directory: {}",
source.display()
));
}
let state = ServerState::new();
state
.replace_all(&source)
.with_context(|| format!("initial scan of {}", source.display()))?;
info!(entries = state.manifest().len(), "initial scan complete");
let (_watcher, events_rx) = ServerWatcher::spawn(source.clone(), state.clone());
let transport = transport::build(
&args.transport,
TransportArgs {
listen: args.listen,
},
)
.with_context(|| format!("building transport {:?}", args.transport))?;
info!(
transport = %args.transport,
listen = %args.listen,
"transport ready"
);
let server_task = tokio::spawn(async move {
if let Err(e) = transport.run(state, events_rx).await {
error!(error = %e, "transport ended with error");
}
});
let mut sigint = signal(SignalKind::interrupt()).expect("register SIGINT");
let mut sigterm = signal(SignalKind::terminate()).expect("register SIGTERM");
tokio::select! {
_ = sigint.recv() => info!("received SIGINT, shutting down"),
_ = sigterm.recv() => info!("received SIGTERM, shutting down"),
_ = server_task => info!("transport task exited"),
}
return Ok(());
}
+1
View File
@@ -0,0 +1 @@
pub mod server;
@@ -0,0 +1,93 @@
use musicfs_core::compute_item_hash;
use musicfs_core::music::metadata::MusicMetadata;
/// One row of the in-memory manifest: every field the client needs to
/// reconstruct an `Item` whose hash matches the server's hash.
///
/// Field semantics:
/// - `id` is the server filesystem inode. The client uses it as the FUSE
/// inode, which keeps `compute_hash` inputs identical on both sides.
/// - `rel_path` is the path relative to the server's `--source` root, using
/// `/` as separator. The client stores it as `Item.original_path` and uses
/// it as the byte-source locator.
/// - `mtime` / `ctime` / `crtime` are seconds since the Unix epoch. These are
/// the exact three time fields `compute_hash` consumes.
/// - `size` is the real on-disk file size in bytes (the client's virtual size
/// is derived from `music_metadata.virtual_size(size)` when present).
/// - `music_metadata` is the fully-encoded metadata produced server-side via
/// `parse_music_metadata_for_path`. The client never parses audio.
///
/// Wire format conversion lives in `transport/grpc.rs` (`From<ManifestEntry>`
/// for the generated proto type).
#[derive(Debug, Clone)]
pub struct ManifestEntry {
pub id: u64,
pub rel_path: String,
pub size: u64,
pub mtime: u64,
pub ctime: u64,
pub crtime: u64,
pub music_metadata: Option<MusicMetadata>,
}
impl ManifestEntry {
pub fn hash(&self) -> u64 {
return compute_item_hash(
self.id,
self.rel_path.as_bytes(),
self.ctime,
self.mtime,
self.crtime,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_entry_clones_fields_into_copy() {
let entry = ManifestEntry {
id: 42,
rel_path: "Artist/Album/track.flac".to_string(),
size: 12345,
mtime: 1_700_000_000,
ctime: 1_699_999_000,
crtime: 1_699_990_000,
music_metadata: None,
};
let cloned = entry.clone();
assert_eq!(cloned.id, entry.id);
assert_eq!(cloned.rel_path, entry.rel_path);
assert_eq!(cloned.size, entry.size);
assert_eq!(cloned.mtime, entry.mtime);
assert_eq!(cloned.ctime, entry.ctime);
assert_eq!(cloned.crtime, entry.crtime);
assert!(cloned.music_metadata.is_none());
}
#[test]
fn manifest_entry_carries_music_metadata_when_present() {
let mm = MusicMetadata {
artist: vec!["Test Artist".to_string()],
album: "Test Album".to_string(),
track_title: "Test Title".to_string(),
track_number: 3,
..MusicMetadata::default()
};
let entry = ManifestEntry {
id: 1,
rel_path: "a/b.flac".to_string(),
size: 100,
mtime: 0,
ctime: 0,
crtime: 0,
music_metadata: Some(mm.clone()),
};
assert_eq!(entry.music_metadata.as_ref().unwrap().album, "Test Album");
assert_eq!(entry.music_metadata.as_ref().unwrap().track_number, 3);
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod manifest;
pub mod range;
pub mod state;
pub mod transport;
pub mod watcher;
+175
View File
@@ -0,0 +1,175 @@
/// Parsed `Range: bytes=...` request header.
///
/// Supported forms (RFC 7233):
/// - `bytes=a-b` → `StartEnd(a, b)` (inclusive)
/// - `bytes=a-` → `Start(a)`
/// - `bytes=-N` → `Suffix(N)` (last N bytes)
///
/// Returns `None` if the header is missing, uses a unit other than `bytes`,
/// or fails to parse. Multiple ranges (`bytes=a-b,c-d`) are not supported —
/// the first is used and the rest ignored.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ByteRange {
StartEnd(u64, u64),
Start(u64),
Suffix(u64),
}
pub fn parse_range_header(header: &str) -> Option<ByteRange> {
let header = header.trim();
let rest = header.strip_prefix("bytes=")?;
let first = rest.split(',').next()?.trim();
let (left, right) = first.split_once('-')?;
let left = left.trim();
let right = right.trim();
return match (left.is_empty(), right.is_empty()) {
(false, false) => Some(ByteRange::StartEnd(left.parse().ok()?, right.parse().ok()?)),
(false, true) => Some(ByteRange::Start(left.parse().ok()?)),
(true, false) => Some(ByteRange::Suffix(right.parse().ok()?)),
(true, true) => None,
};
}
/// Resolve a parsed range against a real file size, yielding an absolute
/// `(start, length)` pair suitable for `seek + read`.
///
/// Returns `None` if the resolved range is unsatisfiable (e.g. start past
/// end of file). The returned `start` is clamped to `[0, size]` and `length`
/// is clamped to not exceed `size - start`.
pub fn resolve_range(range: ByteRange, size: u64) -> Option<(u64, u64)> {
let (start, end_inclusive) = match range {
ByteRange::StartEnd(a, b) => {
if a >= size || a > b {
return None;
}
(a, b.min(size - 1))
}
ByteRange::Start(a) => {
if a >= size {
return None;
}
(a, size - 1)
}
ByteRange::Suffix(n) => {
if n == 0 || size == 0 {
return None;
}
// A suffix larger than the file clamps to the whole file.
let start = size.saturating_sub(n);
(start, size - 1)
}
};
return Some((start, end_inclusive - start + 1));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_start_end() {
assert_eq!(
parse_range_header("bytes=0-499"),
Some(ByteRange::StartEnd(0, 499))
);
assert_eq!(
parse_range_header("bytes=500-999"),
Some(ByteRange::StartEnd(500, 999))
);
}
#[test]
fn parse_start_only() {
assert_eq!(
parse_range_header("bytes=9500-"),
Some(ByteRange::Start(9500))
);
}
#[test]
fn parse_suffix() {
assert_eq!(
parse_range_header("bytes=-500"),
Some(ByteRange::Suffix(500))
);
}
#[test]
fn parse_ignores_additional_ranges_after_first() {
assert_eq!(
parse_range_header("bytes=0-499,1000-1499"),
Some(ByteRange::StartEnd(0, 499))
);
}
#[test]
fn parse_returns_none_for_non_bytes_unit() {
assert_eq!(parse_range_header("items=0-4"), None);
}
#[test]
fn parse_returns_none_for_malformed() {
assert_eq!(parse_range_header("not a range"), None);
assert_eq!(parse_range_header("bytes="), None);
assert_eq!(parse_range_header("bytes=-"), None);
}
#[test]
fn resolve_start_end_in_bounds() {
assert_eq!(
resolve_range(ByteRange::StartEnd(0, 499), 1000),
Some((0, 500))
);
assert_eq!(
resolve_range(ByteRange::StartEnd(100, 199), 1000),
Some((100, 100))
);
}
#[test]
fn resolve_start_end_clamps_end_to_size() {
assert_eq!(
resolve_range(ByteRange::StartEnd(900, 2000), 1000),
Some((900, 100))
);
}
#[test]
fn resolve_start_end_unsatisfiable_when_start_past_size() {
assert_eq!(resolve_range(ByteRange::StartEnd(1500, 2000), 1000), None);
}
#[test]
fn resolve_start_only() {
assert_eq!(resolve_range(ByteRange::Start(900), 1000), Some((900, 100)));
assert_eq!(resolve_range(ByteRange::Start(0), 1000), Some((0, 1000)));
}
#[test]
fn resolve_start_unsatisfiable_when_past_size() {
assert_eq!(resolve_range(ByteRange::Start(1500), 1000), None);
}
#[test]
fn resolve_suffix() {
assert_eq!(
resolve_range(ByteRange::Suffix(500), 1000),
Some((500, 500))
);
assert_eq!(resolve_range(ByteRange::Suffix(1), 1000), Some((999, 1)));
}
#[test]
fn resolve_suffix_larger_than_size_clamps_to_full_file() {
assert_eq!(
resolve_range(ByteRange::Suffix(2000), 1000),
Some((0, 1000))
);
}
#[test]
fn resolve_suffix_zero_unsatisfiable() {
assert_eq!(resolve_range(ByteRange::Suffix(0), 1000), None);
}
}
+260
View File
@@ -0,0 +1,260 @@
use std::{
collections::HashMap,
fs, io,
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::{SystemTime, UNIX_EPOCH},
};
use std::os::unix::fs::MetadataExt;
use crate::server::manifest::ManifestEntry;
use musicfs_core::FileAttrs;
use musicfs_core::music::parse::parse_music_metadata_for_path;
use tracing::info;
/// Server-side entry for one file. Built once on startup from a directory
/// scan and refreshed by the watcher on inotify events.
#[derive(Debug, Clone, PartialEq)]
pub struct FileEntry {
pub abs_path: PathBuf,
pub rel_path: String,
pub attrs: FileAttrs,
pub music_metadata: Option<musicfs_core::music::metadata::MusicMetadata>,
}
#[derive(Clone)]
pub struct ServerState {
inner: Arc<Mutex<HashMap<u64, FileEntry>>>,
}
impl ServerState {
pub fn new() -> Self {
return ServerState {
inner: Arc::new(Mutex::new(HashMap::new())),
};
}
/// Replace the entire map with a fresh scan of `source`. Used on startup
/// and on watcher-driven reconciliations.
pub fn replace_all(&self, source: &Path) -> io::Result<bool> {
let entries = scan_directory(source)?;
let new_map: HashMap<u64, FileEntry> = entries.into_iter().collect();
let mut map = self.inner.lock().unwrap();
if *map == new_map {
return Ok(false);
}
let count = new_map.len();
*map = new_map;
info!(count, "server state: scan complete (changed)");
return Ok(true);
}
/// Snapshot the current state into a manifest. Order is by inode ascending
/// so two scans of an unchanged library serialize identically.
pub fn manifest(&self) -> Vec<ManifestEntry> {
let map = self.inner.lock().unwrap();
let mut entries: Vec<ManifestEntry> = map
.iter()
.map(|(id, file)| ManifestEntry {
id: *id,
rel_path: file.rel_path.clone(),
size: file.attrs.size,
mtime: secs(file.attrs.mtime),
ctime: secs(file.attrs.ctime),
crtime: secs(file.attrs.crtime),
music_metadata: file.music_metadata.clone(),
})
.collect();
entries.sort_by_key(|e| e.id);
return entries;
}
pub fn lookup(&self, id: u64) -> Option<FileEntry> {
return self.inner.lock().unwrap().get(&id).cloned();
}
}
fn secs(time: SystemTime) -> u64 {
return time
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
}
fn scan_directory(source: &Path) -> io::Result<Vec<(u64, FileEntry)>> {
let mut out = Vec::new();
walk(source, source, &mut out)?;
return Ok(out);
}
fn walk(source: &Path, dir: &Path, out: &mut Vec<(u64, FileEntry)>) -> io::Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
walk(source, &path, out)?;
continue;
}
if !file_type.is_file() {
continue;
}
let metadata = entry.metadata()?;
let rel_path = path
.strip_prefix(source)
.map(|p| p.to_path_buf())
.unwrap_or_else(|_| path.clone())
.to_string_lossy()
.replace('\\', "/");
let inode = metadata.ino();
let music_metadata = parse_music_metadata_for_path(&path);
let file_entry = FileEntry {
abs_path: path,
rel_path,
attrs: FileAttrs::from(&metadata),
music_metadata,
};
out.push((inode, file_entry));
}
return Ok(());
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn replace_all_loads_files_into_state() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path();
let mut f = fs::File::create(source.join("a.txt")).unwrap();
f.write_all(b"hello").unwrap();
drop(f);
let mut f = fs::File::create(source.join("b.txt")).unwrap();
f.write_all(b"world!").unwrap();
drop(f);
let state = ServerState::new();
state.replace_all(source).unwrap();
let manifest = state.manifest();
assert_eq!(manifest.len(), 2);
assert!(manifest.iter().any(|e| e.rel_path == "a.txt"));
assert!(manifest.iter().any(|e| e.rel_path == "b.txt"));
}
#[test]
fn manifest_entries_sorted_by_id_ascending() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path();
// Create files in any order; inode order is determined by the FS.
for name in ["z.txt", "a.txt", "m.txt"] {
fs::write(source.join(name), b"x").unwrap();
}
let state = ServerState::new();
state.replace_all(source).unwrap();
let ids: Vec<u64> = state.manifest().iter().map(|e| e.id).collect();
let mut sorted = ids.clone();
sorted.sort();
assert_eq!(ids, sorted);
}
#[test]
fn replace_all_clears_existing_entries() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path();
fs::write(source.join("a.txt"), b"x").unwrap();
let state = ServerState::new();
state.replace_all(source).unwrap();
assert_eq!(state.manifest().len(), 1);
fs::remove_file(source.join("a.txt")).unwrap();
state.replace_all(source).unwrap();
assert_eq!(state.manifest().len(), 0);
}
#[test]
fn replace_all_returns_true_on_first_scan() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path();
fs::write(source.join("a.txt"), b"x").unwrap();
let state = ServerState::new();
assert!(state.replace_all(source).unwrap());
}
#[test]
fn replace_all_returns_false_when_unchanged() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path();
fs::write(source.join("a.txt"), b"hello").unwrap();
fs::write(source.join("b.txt"), b"world").unwrap();
let state = ServerState::new();
state.replace_all(source).unwrap();
assert!(!state.replace_all(source).unwrap());
}
#[test]
fn replace_all_returns_true_after_file_added() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path();
fs::write(source.join("a.txt"), b"x").unwrap();
let state = ServerState::new();
state.replace_all(source).unwrap();
fs::write(source.join("b.txt"), b"y").unwrap();
assert!(state.replace_all(source).unwrap());
}
#[test]
fn replace_all_returns_true_after_file_removed() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path();
fs::write(source.join("a.txt"), b"x").unwrap();
fs::write(source.join("b.txt"), b"y").unwrap();
let state = ServerState::new();
state.replace_all(source).unwrap();
fs::remove_file(source.join("a.txt")).unwrap();
assert!(state.replace_all(source).unwrap());
}
#[test]
fn replace_all_returns_true_after_file_renamed() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path();
fs::write(source.join("old.txt"), b"x").unwrap();
let state = ServerState::new();
state.replace_all(source).unwrap();
assert!(!state.replace_all(source).unwrap());
fs::rename(source.join("old.txt"), source.join("new.txt")).unwrap();
assert!(state.replace_all(source).unwrap());
}
#[test]
fn replace_all_returns_true_after_content_modified() {
let tmp = tempfile::tempdir().unwrap();
let source = tmp.path();
fs::write(source.join("a.txt"), b"original").unwrap();
let state = ServerState::new();
state.replace_all(source).unwrap();
assert!(!state.replace_all(source).unwrap());
fs::write(source.join("a.txt"), b"modified content").unwrap();
assert!(state.replace_all(source).unwrap());
}
}
@@ -0,0 +1,328 @@
use std::{
fs,
io::{Read, Seek, SeekFrom},
path::PathBuf,
pin::Pin,
};
use anyhow::Result;
use async_trait::async_trait;
use tokio::sync::broadcast::Receiver;
use tonic::{Request, Response, Status, transport::Server};
use crate::server::manifest::ManifestEntry as DomainManifestEntry;
use crate::server::state::ServerState;
use crate::server::transport::{MusicTransport, TransportArgs};
use crate::server::watcher::{ChangeEvent, ChangeKind};
use musicfs_core::music::metadata::MusicMetadata;
use musicfs_proto as proto_types;
use musicfs_proto::MusicFs as MusicFsTrait;
use musicfs_proto::{
ChangeEvent as ProtoChangeEvent, FileChunk, GetFileRequest, GetManifestRequest,
GetMetadataRequest, InodeHash, ManifestEntry, MusicFsServer,
MusicMetadata as ProtoMusicMetadata, PictureDataRange, ReconcileRequest, ReconcileResponse,
SubscribeEventsRequest,
};
use tracing::{debug, info, warn};
pub struct GrpcTransport {
args: TransportArgs,
}
impl GrpcTransport {
pub fn new(args: TransportArgs) -> Self {
return GrpcTransport { args };
}
}
#[async_trait]
impl MusicTransport for GrpcTransport {
async fn run(self: Box<Self>, state: ServerState, events: Receiver<ChangeEvent>) -> Result<()> {
let service = MusicFsService {
state,
events: tokio::sync::Mutex::new(events),
};
let listen = self.args.listen;
info!(%listen, "musicfs-server gRPC listening");
// Standard gRPC Health Checking Protocol (grpc.health.v1.Health).
let (reporter, health_service) = tonic_health::server::health_reporter();
reporter
.set_serving::<MusicFsServer<MusicFsService>>()
.await;
// v1alpha covers older clients; v1 is the current standard.
let reflection_v1 = tonic_reflection::server::Builder::configure()
.register_encoded_file_descriptor_set(musicfs_proto::musicfs::FILE_DESCRIPTOR_SET)
.register_encoded_file_descriptor_set(tonic_health::pb::FILE_DESCRIPTOR_SET)
.build_v1()?;
let reflection_v1alpha = tonic_reflection::server::Builder::configure()
.register_encoded_file_descriptor_set(musicfs_proto::musicfs::FILE_DESCRIPTOR_SET)
.register_encoded_file_descriptor_set(tonic_health::pb::FILE_DESCRIPTOR_SET)
.build_v1alpha()?;
Server::builder()
.add_service(health_service)
.add_service(reflection_v1)
.add_service(reflection_v1alpha)
.add_service(MusicFsServer::new(service))
.serve(listen)
.await?;
return Ok(());
}
}
struct MusicFsService {
state: ServerState,
events: tokio::sync::Mutex<Receiver<ChangeEvent>>,
}
type BoxStream<T> = Pin<Box<dyn tokio_stream::Stream<Item = Result<T, Status>> + Send>>;
#[tonic::async_trait]
impl MusicFsTrait for MusicFsService {
type GetManifestStream = BoxStream<ManifestEntry>;
type GetMetadataStream = BoxStream<ManifestEntry>;
type GetFileStream = BoxStream<FileChunk>;
type SubscribeEventsStream = BoxStream<ProtoChangeEvent>;
async fn reconcile(
&self,
request: Request<ReconcileRequest>,
) -> Result<Response<ReconcileResponse>, Status> {
let client_entries: std::collections::HashMap<u64, u64> = request
.into_inner()
.entries
.into_iter()
.map(|ih| (ih.inode, ih.hash))
.collect();
debug!(client_entries = client_entries.len(), "reconcile");
let server_manifest = self.state.manifest();
let mut changed: Vec<InodeHash> = Vec::new();
let mut deleted: Vec<u64> = Vec::new();
for entry in &server_manifest {
let entry_hash = entry.hash();
match client_entries.get(&entry.id) {
Some(client_hash) if *client_hash == entry_hash => {}
_ => {
changed.push(InodeHash {
inode: entry.id,
hash: entry_hash,
});
}
}
}
for client_inode in client_entries.keys() {
if !server_manifest.iter().any(|e| &e.id == client_inode) {
deleted.push(*client_inode);
}
}
debug!(
changed = changed.len(),
deleted = deleted.len(),
"reconcile result"
);
return Ok(Response::new(ReconcileResponse { changed, deleted }));
}
async fn get_metadata(
&self,
request: Request<GetMetadataRequest>,
) -> Result<Response<Self::GetMetadataStream>, Status> {
let wanted: std::collections::HashSet<u64> =
request.into_inner().inodes.into_iter().collect();
debug!(wanted = wanted.len(), "get_metadata");
let entries: Vec<DomainManifestEntry> = self
.state
.manifest()
.into_iter()
.filter(|e| wanted.contains(&e.id))
.collect();
let stream = tokio_stream::iter(
entries
.into_iter()
.map(|e| Ok::<ManifestEntry, Status>(e.into())),
);
return Ok(Response::new(Box::pin(stream)));
}
async fn get_manifest(
&self,
_request: Request<GetManifestRequest>,
) -> Result<Response<Self::GetManifestStream>, Status> {
debug!("get_manifest");
let entries: Vec<DomainManifestEntry> = self.state.manifest();
let stream = tokio_stream::iter(
entries
.into_iter()
.map(|e| Ok::<ManifestEntry, Status>(e.into())),
);
return Ok(Response::new(Box::pin(stream)));
}
async fn get_file(
&self,
request: Request<GetFileRequest>,
) -> Result<Response<Self::GetFileStream>, Status> {
let req = request.into_inner();
let id = req.id;
debug!(%id, "get_file");
let entry = match self.state.lookup(id) {
Some(e) => e,
None => {
debug!(%id, "get_file: not found");
return Err(Status::not_found(format!("no file with id {id}")));
}
};
let abs_path: PathBuf = entry.abs_path.clone();
let total_size = entry.attrs.size;
let start = if req.start == 0 && req.length == 0 {
0u64
} else {
req.start
};
let length = if req.start == 0 && req.length == 0 {
total_size
} else if req.length == 0 {
total_size.saturating_sub(start)
} else {
req.length
};
let chunk =
tokio::task::spawn_blocking(move || read_range_blocking(&abs_path, start, length))
.await
.map_err(|e| {
warn!(%id, error = %e, "get_file: join blocking read failed");
Status::internal(format!("join blocking read: {e}"))
})?
.map_err(|e| {
warn!(%id, error = %e, "get_file: read range failed");
Status::internal(format!("read file range: {e}"))
})?;
// Split into <=1MB messages: gRPC's default max is 4MB and music files
// routinely exceed it.
const CHUNK_SIZE: usize = 1024 * 1024;
let chunk_stream = tokio_stream::iter(
chunk
.chunks(CHUNK_SIZE)
.enumerate()
.map(|(i, c)| {
Ok::<FileChunk, Status>(FileChunk {
data: c.to_vec(),
offset: start + (i * CHUNK_SIZE) as u64,
total_size,
})
})
.collect::<Vec<_>>(),
);
return Ok(Response::new(Box::pin(chunk_stream)));
}
async fn subscribe_events(
&self,
_request: Request<SubscribeEventsRequest>,
) -> Result<Response<Self::SubscribeEventsStream>, Status> {
debug!("subscribe_events");
// Take an owned receiver (the guard is dropped at the end of this
// statement) so it can move into the 'static spawned task, and so each
// subscriber gets its own receiver rather than contending on one.
let mut rx = self.events.lock().await.resubscribe();
let (tx, rx_stream) =
tokio::sync::mpsc::unbounded_channel::<Result<ProtoChangeEvent, Status>>();
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(event) => {
let proto = ProtoChangeEvent {
kind: kind_to_string(event.kind),
};
if tx.send(Ok(proto)).is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
warn!(lagged = n, "subscribe_events: lagged; continuing");
continue;
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
debug!("subscribe_events: broadcast closed; ending stream");
break;
}
}
}
});
let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx_stream);
return Ok(Response::new(Box::pin(stream)));
}
}
fn read_range_blocking(
path: &std::path::Path,
start: u64,
length: u64,
) -> std::io::Result<Vec<u8>> {
let mut file = fs::File::open(path)?;
file.seek(SeekFrom::Start(start))?;
let mut buf = vec![0u8; length as usize];
let mut filled = 0usize;
while filled < buf.len() {
let n = file.read(&mut buf[filled..])?;
if n == 0 {
buf.truncate(filled);
break;
}
filled += n;
}
return Ok(buf);
}
fn kind_to_string(kind: ChangeKind) -> String {
return match kind {
ChangeKind::Create => "create".to_string(),
ChangeKind::Modify => "modify".to_string(),
ChangeKind::Remove => "remove".to_string(),
};
}
impl From<DomainManifestEntry> for ManifestEntry {
fn from(entry: DomainManifestEntry) -> Self {
return ManifestEntry {
id: entry.id,
rel_path: entry.rel_path,
size: entry.size,
mtime: entry.mtime,
ctime: entry.ctime,
crtime: entry.crtime,
music_metadata: entry.music_metadata.map(music_metadata_to_proto),
};
}
}
fn music_metadata_to_proto(mm: MusicMetadata) -> ProtoMusicMetadata {
ProtoMusicMetadata {
artist: mm.artist,
album_artist: mm.album_artist,
album: mm.album,
track_number: mm.track_number,
track_title: mm.track_title,
other_tags: mm.other_tags,
header: mm.header,
picture_block_headers: mm.picture_block_headers,
picture_data_ranges: mm
.picture_data_ranges
.into_iter()
.map(|(offset, length)| PictureDataRange { offset, length })
.collect(),
real_audio_start: mm.real_audio_start,
vorbis_comment_offset: mm.vorbis_comment_offset,
vorbis_comment_length: mm.vorbis_comment_length,
}
}
#[allow(unused_imports)]
use proto_types as _proto_types_anchor;
@@ -0,0 +1,39 @@
pub mod grpc;
use std::net::SocketAddr;
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use tokio::sync::broadcast::Receiver;
use crate::server::state::ServerState;
use crate::server::watcher::ChangeEvent;
/// Transport-agnostic musicfs server. A single implementation is wired in
/// today (`grpc`); the factory in [`build`] is the seam where additional
/// transports (HTTP/3, raw QUIC, in-process for tests, ...) get plugged in
/// without touching the binary.
///
/// `run` consumes `self` — a transport serves exactly once. It receives the
/// shared [`ServerState`] and a fresh broadcast receiver for change events.
#[async_trait]
pub trait MusicTransport: Send + Sync + 'static {
async fn run(self: Box<Self>, state: ServerState, events: Receiver<ChangeEvent>) -> Result<()>;
}
/// Arguments every transport understands. Transports may extend this with
/// their own configuration via their constructors.
#[derive(Debug, Clone, Copy)]
pub struct TransportArgs {
pub listen: SocketAddr,
}
/// Construct the named transport. The list of accepted names is intentionally
/// discoverable (a single match arm) so adding a transport means adding an
/// arm here plus a module under `transport/`.
pub fn build(name: &str, args: TransportArgs) -> Result<Box<dyn MusicTransport>> {
return match name {
"grpc" => Ok(Box::new(grpc::GrpcTransport::new(args))),
other => Err(anyhow!("unknown transport: {other}")),
};
}
+119
View File
@@ -0,0 +1,119 @@
use std::{
path::PathBuf,
sync::{Arc, Mutex},
thread,
time::Duration,
};
use notify::{EventKind, RecursiveMode, Watcher};
use tokio::sync::broadcast;
use crate::server::state::ServerState;
use tracing::{error, info};
const POLL_INTERVAL: Duration = Duration::from_secs(15);
/// A change observed by the watcher. Pushed onto the broadcast channel for
/// `/events` subscribers. The client treats these as wake-ups: correctness
/// always rests on the subsequent `/manifest` hash diff.
#[derive(Debug, Clone)]
pub struct ChangeEvent {
pub kind: ChangeKind,
}
#[derive(Debug, Clone, Copy)]
pub enum ChangeKind {
Create,
Modify,
Remove,
}
/// Server-side file watcher. Owns a background std thread driving `notify`
/// (inotify on Linux). On any filesystem event under `source` it:
/// 1. Rebuilds the shared [`ServerState`] from a fresh scan.
/// 2. Broadcasts a [`ChangeEvent`] so connected `/events` clients wake up.
///
/// The state rebuild is full-scan rather than incremental. This matches the
/// existing `LocalOriginFileWatcher` pattern, keeps the watcher simple, and
/// is cheap on a LAN-scale library. Hash-diff reconciliation on the client
/// absorbs any over-reporting.
pub struct ServerWatcher {
_events_tx: broadcast::Sender<ChangeEvent>,
_worker: Arc<Mutex<Option<thread::JoinHandle<()>>>>,
}
impl ServerWatcher {
/// Spawn the watcher. Returns the broadcast receiver that `/events`
/// handlers subscribe to.
pub fn spawn(source: PathBuf, state: ServerState) -> (Self, broadcast::Receiver<ChangeEvent>) {
let (events_tx, events_rx) = broadcast::channel(64);
let worker_tx = events_tx.clone();
let handle = thread::spawn(move || {
run_watcher_loop(source, state, worker_tx);
});
let watcher = ServerWatcher {
_events_tx: events_tx,
_worker: Arc::new(Mutex::new(Some(handle))),
};
return (watcher, events_rx);
}
}
fn run_watcher_loop(
source: PathBuf,
state: ServerState,
events_tx: broadcast::Sender<ChangeEvent>,
) {
let (tx, rx) = std::sync::mpsc::channel();
let mut watcher = match notify::recommended_watcher(tx) {
Ok(w) => w,
Err(e) => {
error!(error = %e, "server watcher: failed to create inotify watcher");
return;
}
};
if let Err(e) = watcher.watch(&source, RecursiveMode::Recursive) {
error!(source = %source.display(), error = %e, "server watcher: failed to watch source");
return;
}
info!(source = %source.display(), "server watcher: scanning for changes");
loop {
match rx.recv_timeout(POLL_INTERVAL) {
Ok(res) => match res {
Ok(event) => match event.kind {
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
let kind = match event.kind {
EventKind::Create(_) => ChangeKind::Create,
EventKind::Modify(_) => ChangeKind::Modify,
EventKind::Remove(_) => ChangeKind::Remove,
_ => continue,
};
match state.replace_all(&source) {
Ok(true) => {
let _ = events_tx.send(ChangeEvent { kind });
}
Ok(false) => {}
Err(e) => {
error!(error = %e, "server watcher: state refresh failed");
}
}
}
_ => {}
},
Err(e) => error!(error = %e, "server watcher: inotify error"),
},
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => match state.replace_all(&source) {
Ok(true) => {
let _ = events_tx.send(ChangeEvent {
kind: ChangeKind::Modify,
});
}
Ok(false) => {}
Err(e) => error!(error = %e, "server watcher: poll rescan failed"),
},
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
}
}