Refactor before adding NetworkOrigin
This commit is contained in:
+12
-33
@@ -1,10 +1,6 @@
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
use fuser::INodeNo;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use crate::item::{FileType, Item};
|
||||
use crate::virtual_dirs::parent_inode_from_path;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "items")]
|
||||
@@ -40,36 +36,22 @@ impl From<&Item> for ActiveModel {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(Model, fs::Metadata)> for Item {
|
||||
fn from((entity, metadata): (Model, fs::Metadata)) -> Self {
|
||||
let local_path = PathBuf::from(&entity.local_path);
|
||||
let parent_inode = parent_inode_from_path(&local_path);
|
||||
let file_type = match entity.file_type.as_str() {
|
||||
"directory" => FileType::Directory,
|
||||
_ => FileType::File,
|
||||
};
|
||||
Item::new(
|
||||
INodeNo(entity.inode as u64),
|
||||
parent_inode,
|
||||
entity.name,
|
||||
PathBuf::from(entity.original_path),
|
||||
local_path,
|
||||
file_type,
|
||||
metadata,
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[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 tmp = tempfile::tempdir().unwrap();
|
||||
let metadata = std::fs::metadata(tmp.path()).unwrap();
|
||||
let item = Item::new(
|
||||
INodeNo(42),
|
||||
INodeNo::ROOT,
|
||||
@@ -77,7 +59,7 @@ mod tests {
|
||||
PathBuf::from("/original/path"),
|
||||
PathBuf::from("local/path"),
|
||||
FileType::Directory,
|
||||
metadata,
|
||||
attrs_for_tempdir(),
|
||||
None,
|
||||
);
|
||||
let am = ActiveModel::from(&item);
|
||||
@@ -115,9 +97,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn file_type_string_mapping() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let metadata = std::fs::metadata(tmp.path()).unwrap();
|
||||
|
||||
let item_dir = Item::new(
|
||||
INodeNo(1),
|
||||
INodeNo::ROOT,
|
||||
@@ -125,7 +104,7 @@ mod tests {
|
||||
PathBuf::from("/original/dir"),
|
||||
PathBuf::from("local/dir"),
|
||||
FileType::Directory,
|
||||
metadata.clone(),
|
||||
attrs_for_tempdir(),
|
||||
None,
|
||||
);
|
||||
let am_dir = ActiveModel::from(&item_dir);
|
||||
@@ -143,7 +122,7 @@ mod tests {
|
||||
PathBuf::from("/original/file"),
|
||||
PathBuf::from("local/file"),
|
||||
FileType::File,
|
||||
metadata,
|
||||
attrs_for_tempdir(),
|
||||
None,
|
||||
);
|
||||
let am_file = ActiveModel::from(&item_file);
|
||||
|
||||
+33
-33
@@ -1,12 +1,16 @@
|
||||
use std::{fs, hash::Hasher, path::PathBuf, time::SystemTime};
|
||||
use std::{
|
||||
hash::Hasher,
|
||||
path::PathBuf,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
use fuser::INodeNo;
|
||||
use twox_hash::XxHash64;
|
||||
|
||||
use crate::music::metadata::MusicMetadata;
|
||||
use crate::origins::attrs::FileAttrs;
|
||||
|
||||
#[derive(PartialEq, Eq, Copy, Clone, Debug)]
|
||||
pub enum FileType {
|
||||
@@ -14,7 +18,7 @@ pub enum FileType {
|
||||
File,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Item {
|
||||
pub inode: INodeNo,
|
||||
pub parent_inode: INodeNo,
|
||||
@@ -22,11 +26,18 @@ pub struct Item {
|
||||
pub original_path: PathBuf,
|
||||
pub local_path: PathBuf,
|
||||
pub file_type: FileType,
|
||||
pub metadata: fs::Metadata,
|
||||
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(
|
||||
@@ -36,7 +47,7 @@ impl Item {
|
||||
original_path: PathBuf,
|
||||
local_path: PathBuf,
|
||||
file_type: FileType,
|
||||
metadata: fs::Metadata,
|
||||
attrs: FileAttrs,
|
||||
music_metadata: Option<MusicMetadata>,
|
||||
) -> Item {
|
||||
let mut item = Item {
|
||||
@@ -46,7 +57,7 @@ impl Item {
|
||||
original_path,
|
||||
local_path,
|
||||
file_type,
|
||||
metadata,
|
||||
attrs,
|
||||
music_metadata,
|
||||
hash: 0,
|
||||
};
|
||||
@@ -59,25 +70,11 @@ impl Item {
|
||||
let seed = 1234;
|
||||
let mut hasher = XxHash64::with_seed(seed);
|
||||
|
||||
let metadata = &self.metadata;
|
||||
let mtime: u64 = metadata
|
||||
.modified()
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH)
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let crtime: u64 = metadata
|
||||
.created()
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH)
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
hasher.write_u64(self.inode.0);
|
||||
hasher.write(self.original_path.as_os_str().as_bytes());
|
||||
hasher.write_u64(metadata.ctime() as u64);
|
||||
hasher.write_u64(mtime);
|
||||
hasher.write_u64(crtime);
|
||||
hasher.write_u64(secs_since_epoch(self.attrs.ctime));
|
||||
hasher.write_u64(secs_since_epoch(self.attrs.mtime));
|
||||
hasher.write_u64(secs_since_epoch(self.attrs.crtime));
|
||||
|
||||
return hasher.finish();
|
||||
}
|
||||
@@ -86,11 +83,11 @@ impl Item {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::origins::attrs::FileAttrs;
|
||||
|
||||
#[test]
|
||||
fn compute_hash_deterministic() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let metadata = std::fs::metadata(tmp.path()).unwrap();
|
||||
let attrs = file_attrs_for_tempdir();
|
||||
|
||||
let item1 = Item::new(
|
||||
INodeNo(42),
|
||||
@@ -99,11 +96,10 @@ mod tests {
|
||||
PathBuf::from("/some/path"),
|
||||
PathBuf::from("test"),
|
||||
FileType::File,
|
||||
metadata,
|
||||
attrs.clone(),
|
||||
None,
|
||||
);
|
||||
|
||||
let metadata2 = std::fs::metadata(tmp.path()).unwrap();
|
||||
let item2 = Item::new(
|
||||
INodeNo(42),
|
||||
INodeNo::ROOT,
|
||||
@@ -111,7 +107,7 @@ mod tests {
|
||||
PathBuf::from("/some/path"),
|
||||
PathBuf::from("test"),
|
||||
FileType::File,
|
||||
metadata2,
|
||||
attrs,
|
||||
None,
|
||||
);
|
||||
|
||||
@@ -120,8 +116,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn compute_hash_changes_on_path_change() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let metadata = std::fs::metadata(tmp.path()).unwrap();
|
||||
let attrs = file_attrs_for_tempdir();
|
||||
|
||||
let item1 = Item::new(
|
||||
INodeNo(42),
|
||||
@@ -130,11 +125,10 @@ mod tests {
|
||||
PathBuf::from("/some/path"),
|
||||
PathBuf::from("test"),
|
||||
FileType::File,
|
||||
metadata,
|
||||
attrs.clone(),
|
||||
None,
|
||||
);
|
||||
|
||||
let metadata2 = std::fs::metadata(tmp.path()).unwrap();
|
||||
let item2 = Item::new(
|
||||
INodeNo(42),
|
||||
INodeNo::ROOT,
|
||||
@@ -142,10 +136,16 @@ mod tests {
|
||||
PathBuf::from("/different/path"),
|
||||
PathBuf::from("test"),
|
||||
FileType::File,
|
||||
metadata2,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+34
-5
@@ -1,5 +1,13 @@
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
use clap::Parser;
|
||||
use musicfs::origins::local::watcher::FileWatcher;
|
||||
use musicfs::db::entities::{Entity, Model};
|
||||
use musicfs::db::sync::sync_items_to_db;
|
||||
use musicfs::music::db::restore_music_metadata_from_db;
|
||||
use musicfs::origins::local::LocalOrigin;
|
||||
use musicfs::origins::{FuseFs, Origin};
|
||||
use musicfs::virtual_dirs::restore_virtual_paths;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -22,11 +30,32 @@ async fn main() {
|
||||
|
||||
let db = sea_orm::Database::connect(&args.database).await.unwrap();
|
||||
|
||||
let fs = musicfs::origins::local::LocalOrigin::new(args.source.clone(), mountpoint.clone(), db)
|
||||
.await
|
||||
.unwrap();
|
||||
let origin = LocalOrigin::new(args.source.clone(), mountpoint.clone());
|
||||
|
||||
fs.watch();
|
||||
let mut snapshot = origin.snapshot().unwrap();
|
||||
|
||||
let db_items: HashMap<i64, Model> = Entity::find()
|
||||
.all(&db)
|
||||
.await
|
||||
.unwrap()
|
||||
.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));
|
||||
|
||||
let files = std::sync::Arc::new(std::sync::Mutex::new(snapshot));
|
||||
let bytes = origin.byte_source();
|
||||
let watcher = origin.watcher();
|
||||
watcher.watch(files.clone());
|
||||
|
||||
let fs = FuseFs {
|
||||
files,
|
||||
bytes,
|
||||
client: db,
|
||||
};
|
||||
|
||||
let cfg = fuser::Config::default();
|
||||
let session =
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,16 @@ pub fn read_bytes_at(path: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>
|
||||
return Ok(buf);
|
||||
}
|
||||
|
||||
/// Reassemble a virtualized FLAC byte range by stitching together the cached
|
||||
/// header, picture block headers, and on-demand audio/picture bytes fetched
|
||||
/// through `reader`.
|
||||
///
|
||||
/// `reader(offset, len)` is transport-agnostic: for the local origin it wraps
|
||||
/// `read_bytes_at(&path, ...)`; a network origin will route the same call
|
||||
/// through its byte cache + HTTP range request. The FLAC assembly logic never
|
||||
/// touches a `Path` directly.
|
||||
pub fn assemble_flac_read(
|
||||
original_path: &Path,
|
||||
reader: &dyn Fn(u64, usize) -> io::Result<Vec<u8>>,
|
||||
header: &[u8],
|
||||
pic_hdrs: &[Vec<u8>],
|
||||
pic_ranges: &[(u64, u64)],
|
||||
@@ -60,7 +68,7 @@ pub fn assemble_flac_read(
|
||||
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 = read_bytes_at(original_path, real_off, (data_end - data_start) as usize)?;
|
||||
let bytes = reader(real_off, (data_end - data_start) as usize)?;
|
||||
buf.extend_from_slice(&bytes);
|
||||
}
|
||||
|
||||
@@ -70,7 +78,7 @@ pub fn assemble_flac_read(
|
||||
let audio_start = offset.max(virt_pos);
|
||||
if audio_start < end {
|
||||
let real_off = real_audio_start + (audio_start - virt_pos);
|
||||
let bytes = read_bytes_at(original_path, real_off, (end - audio_start) as usize)?;
|
||||
let bytes = reader(real_off, (end - audio_start) as usize)?;
|
||||
buf.extend_from_slice(&bytes);
|
||||
}
|
||||
|
||||
@@ -81,6 +89,11 @@ pub fn assemble_flac_read(
|
||||
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() {
|
||||
@@ -107,7 +120,8 @@ mod tests {
|
||||
#[test]
|
||||
fn assemble_flac_read_header_only() {
|
||||
let header = b"HEADERDATA";
|
||||
let result = assemble_flac_read(Path::new(""), header, &[], &[], 0, 2, 4).unwrap();
|
||||
let noop = |_, _| Ok(vec![]);
|
||||
let result = assemble_flac_read(&noop, header, &[], &[], 0, 2, 4).unwrap();
|
||||
assert_eq!(result, b"ADER");
|
||||
}
|
||||
|
||||
@@ -117,10 +131,11 @@ mod tests {
|
||||
let content: Vec<u8> = (0..20).collect();
|
||||
f.write_all(&content).unwrap();
|
||||
f.flush().unwrap();
|
||||
let path = f.path();
|
||||
let path = f.path().to_path_buf();
|
||||
|
||||
let reader = file_reader(path);
|
||||
let header = b"HDR";
|
||||
let result = assemble_flac_read(path, header, &[], &[], 10, 5, 4).unwrap();
|
||||
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]);
|
||||
@@ -132,10 +147,11 @@ mod tests {
|
||||
let content: Vec<u8> = (0..110).collect();
|
||||
f.write_all(&content).unwrap();
|
||||
f.flush().unwrap();
|
||||
let path = f.path();
|
||||
let path = f.path().to_path_buf();
|
||||
|
||||
let reader = file_reader(path);
|
||||
let header = b"ABCD";
|
||||
let result = assemble_flac_read(path, header, &[], &[], 100, 2, 6).unwrap();
|
||||
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
|
||||
|
||||
+22
-528
@@ -6,26 +6,16 @@ use std::{
|
||||
collections::BTreeMap,
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use fuser::{Errno, FileAttr, Filesystem, Generation, INodeNo, Request};
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use crate::db::entities::{ActiveModel, Entity, Model};
|
||||
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, save_music_metadata};
|
||||
use crate::virtual_dirs::{parent_inode_from_path, restore_virtual_paths};
|
||||
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
use fuser::INodeNo;
|
||||
|
||||
use crate::item::Item;
|
||||
use crate::origins::{ByteSource, FileWatcher, Origin};
|
||||
pub struct LocalOrigin {
|
||||
pub(crate) source: PathBuf,
|
||||
pub(crate) destination: PathBuf,
|
||||
pub(crate) files: Arc<Mutex<BTreeMap<INodeNo, Item>>>,
|
||||
client: sea_orm::DatabaseConnection,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LocalOrigin {
|
||||
@@ -34,540 +24,44 @@ impl std::fmt::Debug for LocalOrigin {
|
||||
.debug_struct("LocalOrigin")
|
||||
.field("source", &self.source)
|
||||
.field("destination", &self.destination)
|
||||
.field("files", &self.files)
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalOrigin {
|
||||
pub async fn new(
|
||||
source: String,
|
||||
destination: String,
|
||||
client: sea_orm::DatabaseConnection,
|
||||
) -> Result<LocalOrigin, io::Error> {
|
||||
println!("Initializing the LocalOrigin");
|
||||
|
||||
let mut snapshot = snapshot::build_snapshot(Path::new(&source), Path::new(&destination))?;
|
||||
|
||||
let db_items: std::collections::HashMap<i64, Model> = Entity::find()
|
||||
.all(&client)
|
||||
.await
|
||||
.unwrap()
|
||||
.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, Path::new(&source));
|
||||
|
||||
let local_origin = LocalOrigin {
|
||||
pub fn new(source: String, destination: String) -> LocalOrigin {
|
||||
return LocalOrigin {
|
||||
source: source.into(),
|
||||
destination: destination.into(),
|
||||
files: Arc::new(Mutex::new(snapshot)),
|
||||
client,
|
||||
};
|
||||
return Ok(local_origin);
|
||||
}
|
||||
|
||||
fn file_to_attr(item: &Item) -> FileAttr {
|
||||
let metadata = &item.metadata;
|
||||
let ctime = std::time::UNIX_EPOCH + Duration::from_secs(metadata.ctime() as u64);
|
||||
let atime = metadata
|
||||
.accessed()
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
||||
let mtime = metadata
|
||||
.modified()
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
||||
let crtime = metadata
|
||||
.created()
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
||||
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(metadata.size()),
|
||||
_ => metadata.size(),
|
||||
};
|
||||
|
||||
return FileAttr {
|
||||
ino: item.inode,
|
||||
size,
|
||||
blocks: metadata.blocks(),
|
||||
atime,
|
||||
mtime,
|
||||
ctime,
|
||||
crtime,
|
||||
kind,
|
||||
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,
|
||||
flags: 0,
|
||||
};
|
||||
pub fn source(&self) -> &Path {
|
||||
return &self.source;
|
||||
}
|
||||
}
|
||||
|
||||
impl Filesystem for LocalOrigin {
|
||||
fn open(
|
||||
&self,
|
||||
_req: &Request,
|
||||
ino: INodeNo,
|
||||
_flags: fuser::OpenFlags,
|
||||
reply: fuser::ReplyOpen,
|
||||
) {
|
||||
if self.files.lock().unwrap().contains_key(&ino) {
|
||||
reply.opened(fuser::FileHandle(ino.0), fuser::FopenFlags::empty());
|
||||
} else {
|
||||
reply.error(Errno::ENOENT);
|
||||
}
|
||||
impl Origin for LocalOrigin {
|
||||
fn snapshot(&self) -> io::Result<BTreeMap<INodeNo, Item>> {
|
||||
return snapshot::build_snapshot(&self.source, &self.destination);
|
||||
}
|
||||
|
||||
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,
|
||||
) {
|
||||
match self.files.lock().unwrap().get(&ino) {
|
||||
Some(file) => reply.attr(&Duration::new(1, 0), &LocalOrigin::file_to_attr(file)),
|
||||
None => reply.error(Errno::ENOENT),
|
||||
}
|
||||
fn byte_source(&self) -> Arc<dyn ByteSource> {
|
||||
return Arc::new(LocalByteSource);
|
||||
}
|
||||
|
||||
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,
|
||||
) {
|
||||
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 => {
|
||||
reply.written(written);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match &mut item.music_metadata {
|
||||
// FLAC: intercept Vorbis comment block writes
|
||||
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]);
|
||||
Some(mm.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
// MP3: intercept ID3v2 header writes (must start at offset 0)
|
||||
Some(mm)
|
||||
if !mm.header.is_empty()
|
||||
&& write_start == 0
|
||||
&& data.len() >= 3
|
||||
&& &data[0..3] == b"ID3" =>
|
||||
{
|
||||
mm.update_from_id3_data(data);
|
||||
Some(mm.clone())
|
||||
}
|
||||
// MP3: intercept ID3v1 fallback writes (128-byte "TAG" block)
|
||||
Some(mm) if !mm.header.is_empty() && data.len() == 128 && &data[0..3] == b"TAG" => {
|
||||
mm.update_from_id3v1_data(data);
|
||||
Some(mm.clone())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(music_metadata) = updated_music_metadata {
|
||||
let client = self.client.clone();
|
||||
let ino_i64 = ino.0 as i64;
|
||||
run_db_blocking(async move {
|
||||
save_music_metadata(ino_i64, &music_metadata, &client)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
reply.written(written);
|
||||
}
|
||||
|
||||
fn getattr(
|
||||
&self,
|
||||
_req: &fuser::Request,
|
||||
ino: INodeNo,
|
||||
_fh: Option<fuser::FileHandle>,
|
||||
reply: fuser::ReplyAttr,
|
||||
) {
|
||||
println!("getattr(ino={})", ino);
|
||||
match self.files.lock().unwrap().get(&ino) {
|
||||
Some(file) => {
|
||||
let ttl = Duration::new(1, 0);
|
||||
let attr = LocalOrigin::file_to_attr(file);
|
||||
reply.attr(&ttl, &attr);
|
||||
}
|
||||
None => reply.error(Errno::ENOENT),
|
||||
}
|
||||
}
|
||||
|
||||
fn readdir(
|
||||
&self,
|
||||
_req: &fuser::Request,
|
||||
ino: INodeNo,
|
||||
fh: fuser::FileHandle,
|
||||
offset: u64,
|
||||
mut reply: fuser::ReplyDirectory,
|
||||
) {
|
||||
println!("readdir(ino={}, fh={}, offset={})", ino, fh, offset);
|
||||
|
||||
let files = self.files.lock().unwrap();
|
||||
|
||||
let parent_inode = match files.get(&ino) {
|
||||
Some(dir) => dir.parent_inode,
|
||||
None => {
|
||||
reply.error(Errno::ENOENT);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Offsets are 1-based cursors: "." = 1, ".." = 2, real entries = 3+.
|
||||
// The kernel passes back the offset of the last entry it received;
|
||||
// we return entries with offset > that value.
|
||||
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,
|
||||
) {
|
||||
let name_str = match name.to_str() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
reply.error(Errno::EINVAL);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let newname_str = match newname.to_str() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
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 => {
|
||||
reply.error(Errno::ENOENT);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let new_local_path = if newparent == INodeNo::ROOT {
|
||||
PathBuf::from(newname_str)
|
||||
} else {
|
||||
match files.get(&newparent) {
|
||||
Some(dir) => dir.local_path.join(newname_str),
|
||||
None => {
|
||||
reply.error(Errno::ENOENT);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
run_db_blocking(async move {
|
||||
use sea_orm::ActiveValue::Set;
|
||||
ActiveModel {
|
||||
inode: Set(inode_i64),
|
||||
name: Set(new_name_owned),
|
||||
local_path: Set(new_local_path_str),
|
||||
..Default::default()
|
||||
}
|
||||
.update(&client)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
reply.ok();
|
||||
}
|
||||
|
||||
fn read(
|
||||
&self,
|
||||
_req: &Request,
|
||||
ino: INodeNo,
|
||||
_fh: fuser::FileHandle,
|
||||
offset: u64,
|
||||
size: u32,
|
||||
_flags: fuser::OpenFlags,
|
||||
_lock_owner: Option<fuser::LockOwner>,
|
||||
reply: fuser::ReplyData,
|
||||
) {
|
||||
let files = self.files.lock().unwrap();
|
||||
let item = match files.get(&ino) {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
reply.error(Errno::ENOENT);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let original_path = item.original_path.clone();
|
||||
let flac = item
|
||||
.music_metadata
|
||||
.as_ref()
|
||||
.filter(|mm| !mm.header.is_empty())
|
||||
.map(|mm| {
|
||||
(
|
||||
mm.header.clone(),
|
||||
mm.picture_block_headers.clone(),
|
||||
mm.picture_data_ranges.clone(),
|
||||
mm.real_audio_start,
|
||||
)
|
||||
});
|
||||
drop(files);
|
||||
|
||||
let Some((header, pic_hdrs, pic_ranges, real_audio_start)) = flac else {
|
||||
match file_io::read_bytes_at(&original_path, offset, size as usize) {
|
||||
Ok(bytes) => reply.data(&bytes),
|
||||
Err(_) => reply.error(Errno::EIO),
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
match file_io::assemble_flac_read(
|
||||
&original_path,
|
||||
&header,
|
||||
&pic_hdrs,
|
||||
&pic_ranges,
|
||||
real_audio_start,
|
||||
offset,
|
||||
size,
|
||||
) {
|
||||
Ok(bytes) => reply.data(&bytes),
|
||||
Err(_) => reply.error(Errno::EIO),
|
||||
}
|
||||
}
|
||||
|
||||
fn lookup(
|
||||
&self,
|
||||
_req: &Request,
|
||||
parent: INodeNo,
|
||||
name: &std::ffi::OsStr,
|
||||
reply: fuser::ReplyEntry,
|
||||
) {
|
||||
println!("lookup(parent={}, name={})", parent, name.display());
|
||||
|
||||
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 = LocalOrigin::file_to_attr(item.1);
|
||||
|
||||
reply.entry(&ttl, &attr, Generation(0));
|
||||
}
|
||||
None => reply.error(Errno::ENOENT),
|
||||
}
|
||||
fn watcher(&self) -> Box<dyn FileWatcher> {
|
||||
return Box::new(watcher::LocalOriginFileWatcher::new(
|
||||
self.source.clone(),
|
||||
self.destination.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::item::Item;
|
||||
use crate::music::metadata::MusicMetadata;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::PathBuf;
|
||||
pub struct LocalByteSource;
|
||||
|
||||
#[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 item = Item::new(
|
||||
INodeNo(42),
|
||||
INodeNo::ROOT,
|
||||
"test".to_string(),
|
||||
tmp.path().to_path_buf(),
|
||||
PathBuf::from("test"),
|
||||
FileType::File,
|
||||
metadata.clone(),
|
||||
None,
|
||||
);
|
||||
|
||||
let attr = LocalOrigin::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 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,
|
||||
metadata.clone(),
|
||||
Some(mm.clone()),
|
||||
);
|
||||
|
||||
let attr = LocalOrigin::file_to_attr(&item);
|
||||
let expected_virtual_size = mm.virtual_size(metadata.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,
|
||||
metadata,
|
||||
None,
|
||||
);
|
||||
|
||||
let attr = LocalOrigin::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,
|
||||
metadata,
|
||||
None,
|
||||
);
|
||||
|
||||
let attr = LocalOrigin::file_to_attr(&item);
|
||||
assert_eq!(attr.kind, fuser::FileType::RegularFile);
|
||||
impl ByteSource for LocalByteSource {
|
||||
fn read_at(&self, locator: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>> {
|
||||
return file_io::read_bytes_at(locator, offset, len);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use std::os::unix::fs::MetadataExt;
|
||||
use crate::item::{FileType, Item};
|
||||
use crate::music::encoder::MusicMetadataEncoderFactory;
|
||||
use crate::music::parser::MusicMetadataParserFactory;
|
||||
use crate::origins::attrs::FileAttrs;
|
||||
use crate::virtual_dirs::ensure_virtual_dirs;
|
||||
|
||||
pub fn fill_fileset(map: &Arc<Mutex<BTreeMap<INodeNo, Item>>>, source: &Path, destination: &Path) {
|
||||
@@ -46,7 +47,7 @@ pub fn build_snapshot(
|
||||
source.to_path_buf(),
|
||||
destination.to_path_buf(),
|
||||
FileType::Directory,
|
||||
fs::metadata(source)?,
|
||||
FileAttrs::from(&fs::metadata(source)?),
|
||||
None,
|
||||
);
|
||||
map.insert(INodeNo::ROOT, local_root);
|
||||
@@ -104,7 +105,7 @@ pub fn read_into_map(
|
||||
item_path,
|
||||
local_path,
|
||||
FileType::File,
|
||||
metadata,
|
||||
FileAttrs::from(&metadata),
|
||||
music_metadata,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
use std::{sync::mpsc, thread};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex, mpsc},
|
||||
thread,
|
||||
};
|
||||
|
||||
use fuser::INodeNo;
|
||||
use notify::{Event, EventKind, RecursiveMode, Watcher};
|
||||
|
||||
pub trait FileWatcher {
|
||||
fn watch(&self);
|
||||
use crate::item::Item;
|
||||
use crate::origins::FileWatcher;
|
||||
|
||||
pub struct LocalOriginFileWatcher {
|
||||
source: PathBuf,
|
||||
destination: PathBuf,
|
||||
}
|
||||
|
||||
use super::LocalOrigin;
|
||||
impl LocalOriginFileWatcher {
|
||||
pub fn new(source: PathBuf, destination: PathBuf) -> Self {
|
||||
return LocalOriginFileWatcher {
|
||||
source,
|
||||
destination,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl FileWatcher for LocalOrigin {
|
||||
fn watch(&self) {
|
||||
let files = self.files.clone();
|
||||
impl FileWatcher for LocalOriginFileWatcher {
|
||||
fn watch(&self, files: Arc<Mutex<BTreeMap<INodeNo, Item>>>) {
|
||||
let source = self.source.clone();
|
||||
let destination = self.destination.clone();
|
||||
|
||||
|
||||
@@ -1 +1,534 @@
|
||||
pub mod attrs;
|
||||
pub mod local;
|
||||
|
||||
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 crate::db::sync::run_db_blocking;
|
||||
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)`.
|
||||
pub trait ByteSource: Send + Sync {
|
||||
fn read_at(&self, locator: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>>;
|
||||
}
|
||||
|
||||
pub trait FileWatcher: Send + Sync {
|
||||
fn watch(&self, files: Arc<Mutex<BTreeMap<INodeNo, Item>>>);
|
||||
}
|
||||
|
||||
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(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,
|
||||
) {
|
||||
if self.files.lock().unwrap().contains_key(&ino) {
|
||||
reply.opened(fuser::FileHandle(ino.0), fuser::FopenFlags::empty());
|
||||
} else {
|
||||
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,
|
||||
) {
|
||||
match self.files.lock().unwrap().get(&ino) {
|
||||
Some(file) => reply.attr(&Duration::new(1, 0), &file_to_attr(file)),
|
||||
None => 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,
|
||||
) {
|
||||
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 => {
|
||||
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]);
|
||||
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);
|
||||
Some(mm.clone())
|
||||
}
|
||||
Some(mm) if !mm.header.is_empty() && data.len() == 128 && &data[0..3] == b"TAG" => {
|
||||
mm.update_from_id3v1_data(data);
|
||||
Some(mm.clone())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(music_metadata) = updated_music_metadata {
|
||||
let client = self.client.clone();
|
||||
let ino_i64 = ino.0 as i64;
|
||||
run_db_blocking(async move {
|
||||
save_music_metadata(ino_i64, &music_metadata, &client)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
reply.written(written);
|
||||
}
|
||||
|
||||
fn getattr(
|
||||
&self,
|
||||
_req: &fuser::Request,
|
||||
ino: INodeNo,
|
||||
_fh: Option<fuser::FileHandle>,
|
||||
reply: fuser::ReplyAttr,
|
||||
) {
|
||||
println!("getattr(ino={})", ino);
|
||||
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 => reply.error(Errno::ENOENT),
|
||||
}
|
||||
}
|
||||
|
||||
fn readdir(
|
||||
&self,
|
||||
_req: &fuser::Request,
|
||||
ino: INodeNo,
|
||||
fh: fuser::FileHandle,
|
||||
offset: u64,
|
||||
mut reply: fuser::ReplyDirectory,
|
||||
) {
|
||||
println!("readdir(ino={}, fh={}, offset={})", ino, fh, offset);
|
||||
|
||||
let files = self.files.lock().unwrap();
|
||||
|
||||
let parent_inode = match files.get(&ino) {
|
||||
Some(dir) => dir.parent_inode,
|
||||
None => {
|
||||
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,
|
||||
) {
|
||||
let name_str = match name.to_str() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
reply.error(Errno::EINVAL);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let newname_str = match newname.to_str() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
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 => {
|
||||
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 => {
|
||||
reply.error(Errno::ENOENT);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
run_db_blocking(async move {
|
||||
use sea_orm::ActiveValue::Set;
|
||||
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
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
reply.ok();
|
||||
}
|
||||
|
||||
fn read(
|
||||
&self,
|
||||
_req: &Request,
|
||||
ino: INodeNo,
|
||||
_fh: fuser::FileHandle,
|
||||
offset: u64,
|
||||
size: u32,
|
||||
_flags: fuser::OpenFlags,
|
||||
_lock_owner: Option<fuser::LockOwner>,
|
||||
reply: fuser::ReplyData,
|
||||
) {
|
||||
let (locator, flac) = {
|
||||
let files = self.files.lock().unwrap();
|
||||
let item = match files.get(&ino) {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
reply.error(Errno::ENOENT);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let locator = item.original_path.clone();
|
||||
let flac = item
|
||||
.music_metadata
|
||||
.as_ref()
|
||||
.filter(|mm| !mm.header.is_empty())
|
||||
.map(|mm| {
|
||||
(
|
||||
mm.header.clone(),
|
||||
mm.picture_block_headers.clone(),
|
||||
mm.picture_data_ranges.clone(),
|
||||
mm.real_audio_start,
|
||||
)
|
||||
});
|
||||
(locator, flac)
|
||||
};
|
||||
|
||||
let Some((header, pic_hdrs, pic_ranges, real_audio_start)) = flac else {
|
||||
match self.bytes.read_at(&locator, offset, size as usize) {
|
||||
Ok(bytes) => reply.data(&bytes),
|
||||
Err(_) => reply.error(Errno::EIO),
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
let bytes = &self.bytes;
|
||||
let reader = |off: u64, len: usize| bytes.read_at(&locator, off, len);
|
||||
match file_io::assemble_flac_read(
|
||||
&reader,
|
||||
&header,
|
||||
&pic_hdrs,
|
||||
&pic_ranges,
|
||||
real_audio_start,
|
||||
offset,
|
||||
size,
|
||||
) {
|
||||
Ok(bytes) => reply.data(&bytes),
|
||||
Err(_) => reply.error(Errno::EIO),
|
||||
}
|
||||
}
|
||||
|
||||
fn lookup(
|
||||
&self,
|
||||
_req: &Request,
|
||||
parent: INodeNo,
|
||||
name: &std::ffi::OsStr,
|
||||
reply: fuser::ReplyEntry,
|
||||
) {
|
||||
println!("lookup(parent={}, name={})", parent, name.display());
|
||||
|
||||
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 => 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);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -10,6 +10,7 @@ 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);
|
||||
@@ -71,7 +72,7 @@ pub fn ensure_virtual_dirs(
|
||||
source.to_path_buf(),
|
||||
PathBuf::from(¤t_path),
|
||||
FileType::Directory,
|
||||
fs::metadata(source).unwrap(),
|
||||
FileAttrs::from(&fs::metadata(source).unwrap()),
|
||||
None,
|
||||
);
|
||||
map.insert(virt_ino, virt_item);
|
||||
@@ -227,7 +228,7 @@ mod tests {
|
||||
source.to_path_buf(),
|
||||
PathBuf::from("real_file"),
|
||||
FileType::File,
|
||||
fs::metadata(source).unwrap(),
|
||||
FileAttrs::from(&fs::metadata(source).unwrap()),
|
||||
None,
|
||||
);
|
||||
snapshot.insert(real_ino, real_item);
|
||||
|
||||
+15
-7
@@ -1,6 +1,11 @@
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use musicfs::origins::local::file_io::assemble_flac_read;
|
||||
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() {
|
||||
@@ -8,15 +13,16 @@ fn assemble_flac_read_picture_header_region() {
|
||||
let data: Vec<u8> = (0..100).collect();
|
||||
f.write_all(&data).unwrap();
|
||||
f.flush().unwrap();
|
||||
let path = f.path();
|
||||
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(
|
||||
path,
|
||||
&reader,
|
||||
header,
|
||||
&[pic_hdr.clone()],
|
||||
&pic_ranges,
|
||||
@@ -35,15 +41,16 @@ fn assemble_flac_read_picture_data_region() {
|
||||
let data: Vec<u8> = (0..100).collect();
|
||||
f.write_all(&data).unwrap();
|
||||
f.flush().unwrap();
|
||||
let path = f.path();
|
||||
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(
|
||||
path,
|
||||
&reader,
|
||||
header,
|
||||
&[pic_hdr.clone()],
|
||||
&pic_ranges,
|
||||
@@ -62,12 +69,13 @@ fn assemble_flac_read_full_virtual_layout() {
|
||||
let data: Vec<u8> = (0..200).map(|i| i as u8).collect();
|
||||
f.write_all(&data).unwrap();
|
||||
f.flush().unwrap();
|
||||
let path = f.path();
|
||||
let path = f.path().to_path_buf();
|
||||
|
||||
let header = vec![0xAA; 8];
|
||||
let real_audio_start = 100u64;
|
||||
|
||||
let result = assemble_flac_read(path, &header, &[], &[], real_audio_start, 0, 18).unwrap();
|
||||
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>>());
|
||||
|
||||
@@ -59,25 +59,21 @@ fn build_snapshot_nested_dirs() {
|
||||
let dest = tempfile::tempdir().unwrap();
|
||||
|
||||
fs::create_dir(source.path().join("subdir")).unwrap();
|
||||
let mut f = fs::File::create(source.path().join("subdir").join("file.txt")).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 subdir_entry = snapshot
|
||||
.values()
|
||||
.find(|item| item.name == "subdir" && item.file_type == FileType::Directory)
|
||||
.expect("subdir not found");
|
||||
|
||||
let file_entry = snapshot
|
||||
.values()
|
||||
.find(|item| item.name == "file.txt" && item.file_type == FileType::File)
|
||||
.expect("file.txt not found");
|
||||
|
||||
assert_eq!(subdir_entry.parent_inode, INodeNo::ROOT);
|
||||
assert_eq!(file_entry.parent_inode, INodeNo::ROOT);
|
||||
assert_eq!(file_entry.original_path, nested_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user