Refactor to split client from server
This commit is contained in:
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod cache;
|
||||
pub mod entities;
|
||||
pub mod sync;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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://");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(¤t_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(¤t_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(());
|
||||
}
|
||||
}
|
||||
@@ -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(¤t_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(¤t_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");
|
||||
}
|
||||
@@ -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
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod encoder;
|
||||
pub mod flac;
|
||||
pub mod metadata;
|
||||
pub mod mp3;
|
||||
pub mod parse;
|
||||
pub mod parser;
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
};
|
||||
@@ -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(());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod manifest;
|
||||
pub mod range;
|
||||
pub mod state;
|
||||
pub mod transport;
|
||||
pub mod watcher;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}")),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user