Files
MusicFS/docs/v2/development-plan.md
T
Alexander e08988f7f3 Add development plan and Oracle-validated weekly plans (Weeks 1-3)
development-plan.md (master plan):
- 11-week implementation broken into 4 phases
- 11 Rust crates with dependency graph
- Per-week deliverables, tests, exit criteria
- Deferred requirements (FR-21, FR-22) with rationale

plans/week-01-foundation.md:
- Workspace setup, core types, FUSE skeleton, local origin
- Origin trait with watch() method (arch 4.3.4)
- EventBus with FileAccessed event (FR-18.1)
- All EROFS handlers for read-only enforcement (FR-4.1-4.5)

plans/week-02-metadata.md:
- symphonia metadata extraction (FR-6.1-6.5)
- SQLite schema matching architecture 4.3.6 exactly
- Column names: track/disc (not track_number/disc_number)
- Hash columns as TEXT (hex-encoded, not BLOB)
- Added idx_files_real index (FR-7.3)

plans/week-03-virtual-tree.md:
- Path resolver with $var syntax (arch 4.3.1)
- Template vars: $artist, $album, $title, $track, $year, $disc, $genre, $format, $format_upper
- RefreshPolicy struct for FR-9.3 (TTL-based refresh)
- force_refresh() method for FR-9.4 (signal/API refresh)

All plans Oracle-validated against architecture.md and requirements.md
2026-05-12 17:52:33 +02:00

1339 lines
40 KiB
Markdown

# MusicFS Development Plan
**Version**: 1.0
**Date**: 2026-05-12
**Status**: Draft
**Prerequisites**: [requirements.md](requirements.md), [architecture.md](architecture.md)
---
## 1. Overview
This plan breaks down the 11-week implementation into specific deliverables with Rust modules, test requirements, and acceptance criteria mapped to requirements.
### 1.1 Project Structure
```
musicfs/
├── Cargo.toml # Workspace root
├── proto/
│ └── musicfs.proto # gRPC definitions
├── crates/
│ ├── musicfs-core/ # Core types, traits, errors
│ ├── musicfs-fuse/ # FUSE filesystem implementation
│ ├── musicfs-cache/ # Three-tier caching (L1/L2/L3)
│ ├── musicfs-cas/ # Content-addressable storage
│ ├── musicfs-sync/ # Delta sync, CDC chunking
│ ├── musicfs-origins/ # Origin plugins (local, s3, sftp)
│ ├── musicfs-metadata/ # Audio metadata extraction
│ ├── musicfs-search/ # Full-text search (tantivy)
│ ├── musicfs-plugins/ # Plugin host (native + WASM)
│ ├── musicfs-grpc/ # gRPC control API
│ └── musicfs-cli/ # CLI binary
├── tests/
│ ├── integration/ # Cross-crate integration tests
│ └── e2e/ # End-to-end FUSE tests
└── benches/ # Criterion benchmarks
```
### 1.2 Dependency Graph
```
musicfs-cli
┌──────────┼──────────┐
│ │ │
▼ ▼ ▼
musicfs-grpc musicfs-fuse musicfs-search
│ │ │
└────┬─────┴───────────────┘
musicfs-core
/ | \
/ | \
▼ ▼ ▼
musicfs-cache musicfs-origins musicfs-metadata
│ │
▼ │
musicfs-cas ◄───────┘
musicfs-sync
```
---
## 2. Phase 1: MVP (Weeks 1-4)
**Goal**: Basic functional filesystem with single local origin.
**Requirements Covered**: FR-1, FR-2, FR-3, FR-4, FR-5, FR-6, FR-7, FR-8, FR-9, FR-18, NFR-1.1-1.7
---
### Week 1: Foundation
#### Deliverables
| Task | Crate | Files | Requirements |
|------|-------|-------|--------------|
| Workspace setup | root | `Cargo.toml`, `.cargo/config.toml` | - |
| Core types | musicfs-core | `lib.rs`, `error.rs`, `types.rs` | - |
| Event Bus | musicfs-core | `events.rs` | FR-18.1-18.4 |
| FUSE skeleton | musicfs-fuse | `lib.rs`, `filesystem.rs` | FR-1.1, FR-1.2 |
| Local origin | musicfs-origins | `lib.rs`, `local.rs`, `traits.rs` | FR-12.1 |
| Nix flake | root | `flake.nix` | - |
#### Core Types (`musicfs-core/src/types.rs`)
```rust
// Virtual path in metadata-organized tree
pub struct VirtualPath(PathBuf);
// Real path on origin
pub struct RealPath {
pub origin_id: OriginId,
pub path: PathBuf,
}
// File metadata
pub struct FileMeta {
pub id: FileId,
pub virtual_path: VirtualPath,
pub real_path: RealPath,
pub size: u64,
pub mtime: SystemTime,
pub content_hash: ContentHash,
pub audio: Option<AudioMeta>,
}
pub struct AudioMeta {
pub artist: Option<String>,
pub album: Option<String>,
pub title: Option<String>,
pub track_number: Option<u32>,
pub duration_ms: Option<u64>,
pub format: AudioFormat,
}
// Content-addressable hash (per architecture 8.3)
pub struct ContentHash([u8; 8]); // xxHash64 for file-level dedup
pub struct ChunkHash([u8; 8]); // xxHash64 for chunk-level dedup
```
#### Event Bus (`musicfs-core/src/events.rs`)
```rust
use tokio::sync::broadcast;
/// Central event bus for system-wide notifications (per architecture 4.2)
pub struct EventBus {
sender: broadcast::Sender<Event>,
}
impl EventBus {
pub fn new(capacity: usize) -> Self {
let (sender, _) = broadcast::channel(capacity);
Self { sender }
}
pub fn publish(&self, event: Event) {
let _ = self.sender.send(event); // Ignore if no receivers
}
pub fn subscribe(&self) -> broadcast::Receiver<Event> {
self.sender.subscribe()
}
}
#[derive(Clone, Debug)]
pub enum Event {
FileAdded { path: VirtualPath, origin_id: OriginId },
FileRemoved { path: VirtualPath },
FileModified { path: VirtualPath },
OriginConnected { origin_id: OriginId },
OriginDisconnected { origin_id: OriginId },
SyncStarted { origin_id: OriginId },
SyncCompleted { origin_id: OriginId, files_changed: u64 },
CacheEviction { bytes_freed: u64 },
}
```
#### Origin Trait (`musicfs-origins/src/traits.rs`)
```rust
#[async_trait]
pub trait Origin: Send + Sync {
fn id(&self) -> &OriginId;
fn origin_type(&self) -> OriginType;
/// List entries in directory
async fn readdir(&self, path: &Path) -> Result<Vec<DirEntry>>;
/// Get file metadata
async fn stat(&self, path: &Path) -> Result<FileStat>;
/// Read file content
async fn read(&self, path: &Path, offset: u64, size: u32) -> Result<Bytes>;
/// Check if path exists
async fn exists(&self, path: &Path) -> Result<bool>;
/// Health check
async fn health(&self) -> HealthStatus;
}
```
#### FUSE Skeleton (`musicfs-fuse/src/filesystem.rs`)
```rust
pub struct MusicFs {
origins: Arc<OriginRegistry>,
cache: Arc<CacheManager>,
tree: Arc<RwLock<VirtualTree>>,
}
impl fuser::Filesystem for MusicFs {
fn lookup(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: ReplyEntry);
fn getattr(&mut self, _req: &Request, ino: u64, reply: ReplyAttr);
fn readdir(&mut self, _req: &Request, ino: u64, fh: u64, offset: i64, reply: ReplyDirectory);
fn open(&mut self, _req: &Request, ino: u64, flags: i32, reply: ReplyOpen);
fn read(&mut self, _req: &Request, ino: u64, fh: u64, offset: i64, size: u32, ...);
fn release(&mut self, _req: &Request, ino: u64, fh: u64, ...);
}
```
#### Tests
| Test | Type | Validates |
|------|------|-----------|
| `test_workspace_builds` | Unit | All crates compile |
| `test_local_origin_readdir` | Unit | Local origin lists files |
| `test_fuse_mount_unmount` | Integration | Mount/unmount works (FR-1.1, FR-1.2) |
#### Exit Criteria
- [ ] `cargo build` succeeds for all crates
- [ ] `cargo test` passes
- [ ] FUSE mount creates mount point
- [ ] FUSE unmount removes mount point
---
### Week 2: Metadata Extraction
#### Deliverables
| Task | Crate | Files | Requirements |
|------|-------|-------|--------------|
| Audio parsing | musicfs-metadata | `lib.rs`, `parser.rs`, `formats/` | FR-6.1-6.5 |
| Format detection | musicfs-metadata | `formats/flac.rs`, `formats/mp3.rs`, etc. | FR-24.1 |
| SQLite schema | musicfs-cache | `schema.sql`, `db.rs` | FR-7.1-7.4 |
| Metadata cache | musicfs-cache | `metadata.rs` | FR-7.1 |
#### Metadata Parser (`musicfs-metadata/src/parser.rs`)
```rust
pub struct MetadataParser {
symphonia: SymphoniaParser,
}
impl MetadataParser {
/// Extract metadata from audio file
pub fn parse(&self, reader: impl Read + Seek) -> Result<AudioMeta> {
// Uses symphonia for format-agnostic parsing
}
/// Extract just the header (first N KB for metadata overlay)
pub fn parse_header(&self, reader: impl Read + Seek) -> Result<(AudioMeta, Bytes)> {
// Returns metadata + raw header bytes for caching
}
}
```
#### SQLite Schema (`musicfs-cache/src/schema.sql`)
```sql
-- As defined in architecture.md section 4.3.6
-- NOTE: Chunk index stored in sled (chunks.sled/), NOT SQLite
CREATE TABLE files (
id INTEGER PRIMARY KEY,
origin_id TEXT NOT NULL, -- Origin identifier
real_path TEXT NOT NULL, -- Path on origin
virtual_path TEXT NOT NULL, -- Metadata-derived path
-- Audio metadata
title TEXT,
artist TEXT,
album TEXT,
track_number INTEGER,
duration_ms INTEGER,
bitrate INTEGER,
sample_rate INTEGER,
format TEXT,
-- Sync state
origin_mtime INTEGER,
origin_size INTEGER,
content_hash TEXT,
chunk_manifest BLOB, -- msgpack: [(chunk_hash, offset, size)]
last_sync INTEGER,
UNIQUE(origin_id, real_path)
);
CREATE TABLE artwork (
id INTEGER PRIMARY KEY,
file_id INTEGER REFERENCES files(id),
art_type TEXT, -- 'front', 'back'
chunk_hash TEXT, -- reference to CAS
width INTEGER,
height INTEGER,
UNIQUE(file_id, art_type)
);
CREATE TABLE collections (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE,
query_json TEXT, -- smart collection query
created_at INTEGER
);
-- Indexes for performance (NFR-1.1, NFR-1.2)
CREATE INDEX idx_virtual ON files(virtual_path);
CREATE INDEX idx_artist_album ON files(artist, album);
CREATE INDEX idx_content_hash ON files(content_hash);
```
#### Sled Chunk Index (`musicfs-cas/chunks.sled/`)
```rust
// Chunk hash → storage location (NOT in SQLite per architecture 4.3.2)
// Key: ChunkHash (8 bytes, xxHash64)
// Value: ChunkLocation { path: PathBuf, offset: u64, size: u32 }
```
#### Tests
| Test | Type | Validates |
|------|------|-----------|
| `test_parse_flac` | Unit | FLAC metadata extraction (FR-6.1-6.5) |
| `test_parse_mp3_id3v2` | Unit | MP3 ID3v2 tags |
| `test_parse_mp3_id3v1` | Unit | MP3 ID3v1 fallback |
| `test_parse_opus` | Unit | Opus/Vorbis comments |
| `test_metadata_cache_insert` | Unit | SQLite insert/query |
| `test_metadata_cache_persistence` | Integration | Data survives restart (FR-7.4) |
#### Exit Criteria
- [ ] Parse FLAC, MP3, Opus, M4A metadata
- [ ] Extract: artist, album, title, track, duration, format
- [ ] SQLite schema created and migrated
- [ ] Metadata persists across restarts
---
### Week 3: Virtual Tree & Basic Ops
#### Deliverables
| Task | Crate | Files | Requirements |
|------|-------|-------|--------------|
| Virtual path resolver | musicfs-core | `resolver.rs` | FR-5.1, FR-5.2 |
| Tree cache | musicfs-cache | `tree.rs` | FR-9.1-9.4 |
| readdir impl | musicfs-fuse | `ops/readdir.rs` | FR-2.1-2.3 |
| stat impl | musicfs-fuse | `ops/stat.rs` | FR-2.1 |
| open/read impl | musicfs-fuse | `ops/read.rs` | FR-3.1-3.5 |
#### Virtual Path Resolver (`musicfs-core/src/resolver.rs`)
```rust
pub struct PathResolver {
templates: Vec<PathTemplate>,
}
impl PathResolver {
/// Map real path + metadata → virtual path
pub fn resolve(&self, real: &RealPath, meta: &AudioMeta) -> VirtualPath {
// Template: "/{artist}/{album}/{track:02} - {title}.{ext}"
}
/// Reverse lookup: virtual path → file ID
pub fn lookup(&self, virtual_path: &VirtualPath, tree: &VirtualTree) -> Option<FileId> {
// O(1) hash lookup
}
}
pub struct PathTemplate {
pub pattern: String,
pub fallback_artist: String, // "Unknown Artist"
pub fallback_album: String, // "Unknown Album"
}
```
#### Tree Cache (`musicfs-cache/src/tree.rs`)
```rust
pub struct VirtualTree {
root: DirNode,
inode_map: HashMap<u64, NodeRef>,
path_map: HashMap<VirtualPath, u64>,
next_inode: AtomicU64,
}
pub enum VirtualNode {
Dir(DirNode),
File(FileNode),
}
pub struct DirNode {
pub inode: u64,
pub name: OsString,
pub children: BTreeMap<OsString, NodeRef>,
pub mtime: SystemTime,
}
pub struct FileNode {
pub inode: u64,
pub name: OsString,
pub file_id: FileId,
pub size: u64,
pub mtime: SystemTime,
}
```
#### Tests
| Test | Type | Validates |
|------|------|-----------|
| `test_resolve_complete_metadata` | Unit | Full path resolution (FR-5.1) |
| `test_resolve_missing_album` | Unit | Fallback handling (FR-5.2) |
| `test_tree_readdir` | Unit | Directory listing (FR-2.1) |
| `test_tree_stat` | Unit | File attributes (FR-2.1) |
| `test_fuse_readdir` | Integration | FUSE readdir (FR-2.2, NFR-1.2) |
| `test_fuse_stat` | Integration | FUSE stat (NFR-1.1) |
| `test_fuse_read` | Integration | FUSE read (FR-3.1) |
| `test_read_only_enforcement` | Integration | Write ops fail (FR-4.1-4.4) |
#### Benchmark
```rust
// benches/tree_ops.rs
fn bench_stat_cached(c: &mut Criterion) {
// Target: <1ms p99 (NFR-1.1)
}
fn bench_readdir_1000_entries(c: &mut Criterion) {
// Target: <10ms p99 (NFR-1.2)
}
fn bench_mount_time(c: &mut Criterion) {
// Target: <100ms, Max: <500ms (NFR-1.7)
// Mount must be O(1), not O(files)
}
```
#### Exit Criteria
- [ ] Virtual tree built from metadata
- [ ] `ls /mnt/music` shows Artist directories
- [ ] `ls /mnt/music/Artist/Album` shows tracks
- [ ] `stat` returns correct size, mtime
- [ ] `cat` reads file content
- [ ] Write operations return EROFS (FR-4.1)
- [ ] Mount completes in <500ms (NFR-1.7)
---
### Week 4: CAS & Chunk Caching
#### Deliverables
| Task | Crate | Files | Requirements |
|------|-------|-------|--------------|
| CAS implementation | musicfs-cas | `lib.rs`, `store.rs` | FR-20.1-20.4 |
| Chunk storage | musicfs-cas | `chunks.rs` | FR-8.1-8.4 |
| Cache eviction | musicfs-cache | `eviction.rs` | FR-8.2 |
| Integration tests | tests/integration | `basic_mount.rs` | FR-1, FR-2, FR-3 |
#### CAS Store (`musicfs-cas/src/store.rs`)
```rust
pub struct CasStore {
chunks_dir: PathBuf,
index: sled::Db, // hash → chunk location
}
impl CasStore {
/// Store chunk, returns hash
pub async fn put(&self, data: &[u8]) -> Result<ChunkHash> {
let hash = xxhash64(data);
let path = self.chunk_path(&hash);
if !path.exists() {
tokio::fs::write(&path, data).await?;
}
Ok(hash)
}
/// Retrieve chunk by hash
pub async fn get(&self, hash: &ChunkHash) -> Result<Bytes> {
let path = self.chunk_path(hash);
Ok(tokio::fs::read(&path).await?.into())
}
/// Check existence (for dedup)
pub fn exists(&self, hash: &ChunkHash) -> bool {
self.chunk_path(hash).exists()
}
}
```
#### Cache Eviction (`musicfs-cache/src/eviction.rs`)
```rust
pub struct LruEviction {
max_size: u64,
current_size: AtomicU64,
access_log: RwLock<BTreeMap<Instant, ChunkHash>>,
}
impl LruEviction {
/// Evict chunks until under limit
pub async fn evict_to_target(&self, store: &CasStore, target: u64) -> Result<u64> {
// LRU eviction based on access time
// Returns bytes freed
}
}
```
#### Tests
| Test | Type | Validates |
|------|------|-----------|
| `test_cas_put_get` | Unit | Basic store/retrieve (FR-20.1) |
| `test_cas_dedup` | Unit | Same content → same hash (FR-20.2) |
| `test_cas_integrity` | Unit | Verify chunk hash (FR-20.4) |
| `test_cache_eviction` | Unit | LRU eviction works (FR-8.2) |
| `test_cache_persistence` | Integration | Survives restart (FR-8.4) |
| `test_mount_play_audio` | E2E | mpv can play file through FUSE |
#### Exit Criteria
- [ ] Chunks stored in CAS with deduplication
- [ ] Cache size limit enforced via eviction
- [ ] Audio playback works through mounted filesystem
- [ ] Cache persists across daemon restarts
- [ ] All Phase 1 requirements pass acceptance tests
---
## 3. Phase 2: Delta Sync & Multi-Origin (Weeks 5-7)
**Goal**: Efficient synchronization and origin federation.
**Requirements Covered**: FR-10, FR-11, FR-12, FR-13, NFR-4, NFR-5
---
### Week 5: CDC & Delta Detection
#### Deliverables
| Task | Crate | Files | Requirements |
|------|-------|-------|--------------|
| FastCDC integration | musicfs-sync | `cdc.rs` | FR-11.2 |
| Manifest storage | musicfs-sync | `manifest.rs` | FR-11.3 |
| Delta detection | musicfs-sync | `delta.rs` | FR-10.1-10.4, FR-11.1 |
| Change watcher | musicfs-sync | `watcher.rs` | FR-10.3 |
#### CDC Chunking (`musicfs-sync/src/cdc.rs`)
```rust
pub struct CdcChunker {
min_size: usize, // 16 KB
avg_size: usize, // 64 KB
max_size: usize, // 256 KB
}
impl CdcChunker {
/// Chunk file content using FastCDC
pub fn chunk(&self, data: &[u8]) -> Vec<Chunk> {
let chunker = fastcdc::v2020::FastCDC::new(
data, self.min_size, self.avg_size, self.max_size
);
chunker.map(|c| Chunk {
offset: c.offset,
length: c.length,
hash: xxhash64(&data[c.offset..c.offset + c.length]),
}).collect()
}
}
pub struct ChunkManifest {
pub content_hash: ContentHash,
pub chunks: Vec<(ChunkHash, u64, u32)>, // (hash, offset, size)
pub total_size: u64,
}
```
#### Delta Detection (`musicfs-sync/src/delta.rs`)
```rust
pub struct DeltaDetector {
db: Arc<Database>,
}
impl DeltaDetector {
/// Compare origin state to cached state
pub async fn detect_changes(&self, origin: &dyn Origin) -> Result<ChangeSet> {
let origin_files = origin.list_recursive().await?;
let cached_files = self.db.list_files(origin.id()).await?;
ChangeSet {
added: self.find_added(&origin_files, &cached_files),
removed: self.find_removed(&origin_files, &cached_files),
modified: self.find_modified(&origin_files, &cached_files).await?,
}
}
/// Compute chunk delta for modified file
pub async fn compute_delta(
&self,
old_manifest: &ChunkManifest,
new_chunks: &[Chunk],
) -> ChunkDelta {
let old_hashes: HashSet<_> = old_manifest.chunks.iter().map(|c| c.0).collect();
ChunkDelta {
reuse: new_chunks.iter().filter(|c| old_hashes.contains(&c.hash)).cloned().collect(),
fetch: new_chunks.iter().filter(|c| !old_hashes.contains(&c.hash)).cloned().collect(),
}
}
}
```
#### Tests
| Test | Type | Validates |
|------|------|-----------|
| `test_cdc_stable_boundaries` | Unit | Insertions don't shift all chunks |
| `test_delta_detect_added` | Unit | New files detected (FR-10.1) |
| `test_delta_detect_removed` | Unit | Deleted files detected (FR-10.2) |
| `test_delta_detect_modified` | Unit | Changed files detected (FR-10.4) |
| `test_delta_chunk_reuse` | Unit | Unchanged chunks reused (FR-11.1) |
| `test_bandwidth_reduction` | Integration | >90% reduction on metadata edit (NFR-5.1) |
#### Exit Criteria
- [ ] CDC produces stable chunk boundaries
- [ ] Delta sync detects add/remove/modify
- [ ] Unchanged chunks reused (not re-fetched)
- [ ] Metadata-only edits achieve >90% bandwidth reduction
---
### Week 6: Origin Federation
#### Deliverables
| Task | Crate | Files | Requirements |
|------|-------|-------|--------------|
| Origin registry | musicfs-origins | `registry.rs` | FR-13.1-13.3 |
| Priority routing | musicfs-origins | `router.rs` | FR-13.4 |
| Health checks | musicfs-origins | `health.rs` | FR-13.6 |
| Failover logic | musicfs-origins | `failover.rs` | FR-13.5 |
#### Origin Registry (`musicfs-origins/src/registry.rs`)
```rust
pub struct OriginRegistry {
origins: RwLock<HashMap<OriginId, Arc<dyn Origin>>>,
router: Router,
health: HealthMonitor,
}
impl OriginRegistry {
/// Register new origin
pub async fn register(&self, config: OriginConfig) -> Result<OriginId>;
/// Get best origin for path
pub async fn route(&self, path: &RealPath) -> Result<Arc<dyn Origin>>;
/// Get all origins for path (for redundancy)
pub async fn route_all(&self, path: &RealPath) -> Vec<Arc<dyn Origin>>;
}
```
#### Router (`musicfs-origins/src/router.rs`)
```rust
pub struct Router {
priority_map: RwLock<HashMap<OriginId, Priority>>,
latency_stats: DashMap<OriginId, LatencyStats>,
}
impl Router {
/// Select best origin based on priority + health + latency
pub fn select(&self, candidates: &[OriginId], health: &HealthSnapshot) -> Option<OriginId> {
candidates
.iter()
.filter(|id| health.is_healthy(id))
.min_by_key(|id| {
let priority = self.priority_map.read().get(id).copied().unwrap_or(100);
let latency = self.latency_stats.get(id).map(|s| s.p50_ms).unwrap_or(1000);
(priority, latency)
})
.copied()
}
}
```
#### Tests
| Test | Type | Validates |
|------|------|-----------|
| `test_register_multiple_origins` | Unit | Multi-origin support (FR-13.1) |
| `test_route_by_priority` | Unit | Priority routing (FR-13.4) |
| `test_failover_on_unhealthy` | Unit | Automatic failover (FR-13.5) |
| `test_health_check_interval` | Integration | Periodic health checks (FR-13.6) |
| `test_origin_offline_graceful` | Integration | Serve cached when offline (NFR-7.1) |
#### Exit Criteria
- [ ] Multiple origins configurable
- [ ] Requests route to healthiest origin
- [ ] Failover when primary fails
- [ ] Offline origin doesn't crash daemon
---
### Week 7: Remote Origins
#### Deliverables
| Task | Crate | Files | Requirements |
|------|-------|-------|--------------|
| NFS origin | musicfs-origins | `nfs.rs` | FR-12.2 |
| SMB origin | musicfs-origins | `smb.rs` | FR-12.3 |
| S3 origin | musicfs-origins | `s3.rs` | FR-12.4 |
| SFTP origin | musicfs-origins | `sftp.rs` | FR-12.5 |
| Credential handling | musicfs-core | `credentials.rs` | Security |
#### NFS Origin (`musicfs-origins/src/nfs.rs`)
```rust
/// NFS origin treats mounted NFS as local filesystem
/// User mounts NFS externally; we just read from mount point
pub struct NfsOrigin {
mount_point: PathBuf,
id: OriginId,
}
#[async_trait]
impl Origin for NfsOrigin {
// Delegates to LocalOrigin implementation
// NFS-specific: handle stale file handles, network timeouts
async fn read(&self, path: &Path, offset: u64, size: u32) -> Result<Bytes> {
// Retry on ESTALE (stale NFS file handle)
for attempt in 0..3 {
match self.do_read(path, offset, size).await {
Ok(data) => return Ok(data),
Err(e) if e.raw_os_error() == Some(libc::ESTALE) => {
tokio::time::sleep(Duration::from_millis(100 * (1 << attempt))).await;
}
Err(e) => return Err(e.into()),
}
}
Err(Error::NfsStaleHandle)
}
}
```
#### S3 Origin (`musicfs-origins/src/s3.rs`)
```rust
pub struct S3Origin {
client: aws_sdk_s3::Client,
bucket: String,
prefix: String,
id: OriginId,
}
#[async_trait]
impl Origin for S3Origin {
async fn readdir(&self, path: &Path) -> Result<Vec<DirEntry>> {
let prefix = self.prefix.join(path);
let resp = self.client.list_objects_v2()
.bucket(&self.bucket)
.prefix(&prefix)
.delimiter("/")
.send().await?;
// Convert S3 response to DirEntry
}
async fn read(&self, path: &Path, offset: u64, size: u32) -> Result<Bytes> {
let range = format!("bytes={}-{}", offset, offset + size as u64 - 1);
let resp = self.client.get_object()
.bucket(&self.bucket)
.key(&self.prefix.join(path))
.range(range)
.send().await?;
Ok(resp.body.collect().await?.into_bytes())
}
}
```
#### Tests
| Test | Type | Validates |
|------|------|-----------|
| `test_nfs_origin_stale_handle` | Unit | NFS ESTALE retry (FR-12.2) |
| `test_smb_origin_mock` | Unit | SMB protocol (FR-12.3) |
| `test_s3_origin_mock` | Unit | S3 protocol (FR-12.4) |
| `test_sftp_origin_mock` | Unit | SFTP protocol (FR-12.5) |
| `test_s3_origin_real` | Integration | Real S3 (requires creds) |
| `test_mixed_origins` | Integration | Local + remote together |
#### Exit Criteria
- [ ] NFS origin functional (treats NFS mount as local)
- [ ] SMB origin functional
- [ ] S3 origin functional
- [ ] SFTP origin functional
- [ ] Mixed local + remote works
- [ ] All Phase 2 requirements pass acceptance tests
---
## 4. Phase 3: Search & Smart Features (Weeks 8-9)
**Goal**: Full-text search and intelligent caching.
**Requirements Covered**: FR-14, FR-15, FR-16, FR-19
---
### Week 8: Search Index
#### Deliverables
| Task | Crate | Files | Requirements |
|------|-------|-------|--------------|
| tantivy integration | musicfs-search | `index.rs` | FR-14.1-14.4 |
| Search virtual dir | musicfs-fuse | `ops/search.rs` | FR-14.3 |
| Query parser | musicfs-search | `query.rs` | FR-14.2 |
| Incremental indexing | musicfs-search | `indexer.rs` | FR-14.4 |
#### Search Index (`musicfs-search/src/index.rs`)
```rust
pub struct SearchIndex {
index: tantivy::Index,
reader: IndexReader,
writer: Mutex<IndexWriter>,
}
impl SearchIndex {
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
let searcher = self.reader.searcher();
let query_parser = QueryParser::for_index(
&self.index,
vec![
self.schema.get_field("artist")?,
self.schema.get_field("album")?,
self.schema.get_field("title")?,
],
);
let query = query_parser.parse_query(query)?;
let top_docs = searcher.search(&query, &TopDocs::with_limit(limit))?;
// Convert to SearchHit
}
pub fn index_file(&self, file: &FileMeta) -> Result<()> {
let mut writer = self.writer.lock();
writer.add_document(doc!(
self.schema.get_field("path")? => file.virtual_path.as_str(),
self.schema.get_field("artist")? => file.audio.as_ref().and_then(|a| a.artist.as_deref()).unwrap_or(""),
self.schema.get_field("album")? => file.audio.as_ref().and_then(|a| a.album.as_deref()).unwrap_or(""),
self.schema.get_field("title")? => file.audio.as_ref().and_then(|a| a.title.as_deref()).unwrap_or(""),
))?;
Ok(())
}
}
```
#### Search Virtual Directory (`musicfs-fuse/src/ops/search.rs`)
```rust
// /.search/metallica/ → symlinks to matching files
impl MusicFs {
fn handle_search_readdir(&self, query: &str, reply: ReplyDirectory) {
let results = self.search.search(query, 1000)?;
for (i, hit) in results.iter().enumerate() {
reply.add(
hit.inode,
(i + 1) as i64,
FileType::Symlink,
&hit.display_name(),
);
}
}
fn handle_search_readlink(&self, inode: u64, reply: ReplyData) {
// Return path to actual file
let target = self.search.resolve_inode(inode)?;
reply.data(target.as_bytes());
}
}
```
#### Tests
| Test | Type | Validates |
|------|------|-----------|
| `test_search_artist` | Unit | Artist search (FR-14.1) |
| `test_search_album` | Unit | Album search (FR-14.1) |
| `test_search_multi_field` | Unit | Cross-field search (FR-14.2) |
| `test_search_performance_1m` | Benchmark | <1s for 1M tracks (NFR-2.5) |
| `test_search_virtual_dir` | Integration | /.search/query works (FR-14.3) |
| `test_search_incremental` | Integration | New files indexed (FR-14.4) |
#### Exit Criteria
- [ ] Full-text search works
- [ ] `/.search/{query}/` returns symlinks
- [ ] Search <1s for 1M tracks
- [ ] New files automatically indexed
---
### Week 9: Smart Features
#### Deliverables
| Task | Crate | Files | Requirements |
|------|-------|-------|--------------|
| Smart collections | musicfs-search | `collections.rs` | FR-15.1-15.5 |
| Cover art cache | musicfs-cache | `artwork.rs` | FR-16.1-16.4 |
| Prefetch engine | musicfs-cache | `prefetch.rs` | FR-19.1-19.4 |
| Access patterns | musicfs-cache | `patterns.rs` | FR-19.3 |
#### Smart Collections (`musicfs-search/src/collections.rs`)
```rust
pub struct SmartCollection {
pub name: String,
pub query: CollectionQuery,
}
pub enum CollectionQuery {
DateRange { field: String, start: NaiveDate, end: NaiveDate },
Match { field: String, pattern: String },
Rating { min: u8 },
RecentlyAdded { days: u32 },
Compound { op: BoolOp, children: Vec<CollectionQuery> },
}
impl SmartCollection {
/// Materialize as virtual directory
pub fn resolve(&self, index: &SearchIndex) -> Vec<FileId> {
index.query(&self.query.to_tantivy_query())
}
}
```
#### Prefetch Engine (`musicfs-cache/src/prefetch.rs`)
```rust
pub struct PrefetchEngine {
pattern_db: PatternStore,
queue: PriorityQueue<PrefetchTask>,
}
impl PrefetchEngine {
/// Record file access
pub fn record_access(&self, file_id: FileId, context: AccessContext) {
self.pattern_db.record(file_id, context);
}
/// Predict next likely accesses
pub fn predict(&self, current: FileId) -> Vec<FileId> {
// Album-aware: if track N accessed, prefetch N+1, N+2
// Artist-aware: other albums by same artist
// Time-based: tracks often played together
}
/// Background prefetch worker
pub async fn run(&self, cache: Arc<CacheManager>) {
loop {
if let Some(task) = self.queue.pop() {
cache.prefetch(&task.file_id).await;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
}
```
#### Tests
| Test | Type | Validates |
|------|------|-----------|
| `test_collection_date_range` | Unit | Date-based collections (FR-15.2) |
| `test_collection_recently_added` | Unit | Recent additions (FR-15.2) |
| `test_artwork_extraction` | Unit | Cover art from audio (FR-16.1) |
| `test_artwork_thumbnail` | Unit | Thumbnail generation (FR-16.3) |
| `test_prefetch_album_sequence` | Unit | Sequential track prefetch (FR-19.1) |
| `test_prefetch_reduces_misses` | Integration | >50% miss reduction (FR-19.4) |
#### Exit Criteria
- [ ] Smart collections as virtual directories
- [ ] Album art extracted and thumbnailed
- [ ] Prefetch reduces cache misses >50%
- [ ] All Phase 3 requirements pass acceptance tests
---
## 5. Phase 4: Plugin System & Polish (Weeks 10-11)
**Goal**: Extensibility and production readiness.
**Requirements Covered**: FR-17, FR-18, FR-23, FR-24, NFR-6
---
### Week 10: Plugin System
#### Deliverables
| Task | Crate | Files | Requirements |
|------|-------|-------|--------------|
| Plugin traits | musicfs-plugins | `traits.rs` | FR-23.1-23.4 |
| Native host | musicfs-plugins | `native.rs` | FR-23.2 |
| WASM host | musicfs-plugins | `wasm.rs` | FR-23.3 |
| Example plugins | plugins/ | `local-origin/`, `flac-format/` | FR-23.5 |
#### Plugin Traits (`musicfs-plugins/src/traits.rs`)
```rust
/// Origin plugin interface
pub trait OriginPlugin: Send + Sync {
fn id(&self) -> &str;
fn origin_type(&self) -> &str;
fn create(&self, config: Value) -> Result<Box<dyn Origin>>;
}
/// Metadata source plugin
pub trait MetadataPlugin: Send + Sync {
fn id(&self) -> &str;
fn lookup(&self, query: &MetadataQuery) -> Result<Option<ExternalMetadata>>;
}
/// Format plugin (for custom audio formats)
pub trait FormatPlugin: Send + Sync {
fn id(&self) -> &str;
fn extensions(&self) -> &[&str];
fn parse(&self, reader: &mut dyn Read) -> Result<AudioMeta>;
}
```
#### WASM Host (`musicfs-plugins/src/wasm.rs`)
```rust
pub struct WasmPluginHost {
engine: wasmtime::Engine,
linker: wasmtime::Linker<PluginState>,
}
impl WasmPluginHost {
pub fn load(&self, wasm_bytes: &[u8]) -> Result<WasmPlugin> {
let module = wasmtime::Module::new(&self.engine, wasm_bytes)?;
let instance = self.linker.instantiate(&mut store, &module)?;
// Extract plugin interface
}
}
```
#### Tests
| Test | Type | Validates |
|------|------|-----------|
| `test_native_plugin_load` | Unit | Native plugin loading (FR-23.2) |
| `test_wasm_plugin_sandbox` | Unit | WASM isolation (FR-23.3) |
| `test_plugin_hot_reload` | Integration | Reload without restart (FR-23.4) |
| `test_example_origin_plugin` | Integration | Custom origin works |
#### Exit Criteria
- [ ] Native plugins loadable at runtime
- [ ] WASM plugins sandboxed
- [ ] Example plugins functional
- [ ] Plugins hot-reloadable
---
### Week 11: Control API & Production
#### Deliverables
| Task | Crate | Files | Requirements |
|------|-------|-------|--------------|
| gRPC server | musicfs-grpc | `server.rs` | FR-17.1-17.5 |
| Proto codegen | proto/ | `musicfs.proto`, `build.rs` | FR-17.2 |
| Event streaming | musicfs-grpc | `events.rs` | FR-18.1-18.4 |
| Metrics export | musicfs-core | `metrics.rs` | NFR-6.1-6.4 |
| CLI completion | musicfs-cli | `main.rs` | FR-17 |
| systemd unit | dist/ | `musicfs.service` | Production |
| Packaging | dist/ | `PKGBUILD`, `musicfs.spec` | Production |
#### Proto Definitions (`proto/musicfs.proto`)
**Source**: Copy verbatim from [architecture.md section 4.3.7](architecture.md#437-control-api)
```protobuf
syntax = "proto3";
package musicfs.v1;
service MusicFS {
// Daemon lifecycle
rpc GetStatus(Empty) returns (StatusResponse);
rpc Shutdown(ShutdownRequest) returns (Empty);
// Cache management
rpc GetCacheStats(Empty) returns (CacheStats);
rpc ClearCache(ClearCacheRequest) returns (ClearCacheResponse);
rpc Prefetch(PrefetchRequest) returns (stream PrefetchProgress);
// Origin management
rpc ListOrigins(Empty) returns (OriginsResponse);
rpc GetOriginHealth(OriginRequest) returns (OriginHealth);
rpc RescanOrigin(OriginRequest) returns (stream SyncProgress);
// Search
rpc Search(SearchRequest) returns (SearchResponse);
rpc SearchStream(SearchRequest) returns (stream SearchResult);
// Events (server-streaming)
rpc SubscribeEvents(EventFilter) returns (stream Event);
}
// Full message definitions in architecture.md section 4.3.7
// Including: StatusResponse, CacheStats, TierStats, OriginInfo,
// OriginHealth, SyncProgress, SearchRequest, SearchResponse,
// EventFilter, Event, and all enums
```
#### gRPC Server (`musicfs-grpc/src/server.rs`)
```rust
pub struct MusicFsService {
core: Arc<MusicFsCore>,
events: broadcast::Sender<Event>,
}
#[tonic::async_trait]
impl musicfs::v1::music_fs_server::MusicFs for MusicFsService {
async fn get_status(&self, _: Request<Empty>) -> Result<Response<StatusResponse>, Status> {
let status = self.core.status().await;
Ok(Response::new(status.into()))
}
type SubscribeEventsStream = ReceiverStream<Result<Event, Status>>;
async fn subscribe_events(
&self,
request: Request<EventFilter>,
) -> Result<Response<Self::SubscribeEventsStream>, Status> {
let filter = request.into_inner();
let rx = self.events.subscribe();
let stream = BroadcastStream::new(rx)
.filter(|e| filter.matches(e))
.map(|e| Ok(e.into()));
Ok(Response::new(ReceiverStream::new(stream)))
}
}
```
#### Metrics (`musicfs-core/src/metrics.rs`)
```rust
lazy_static! {
pub static ref FUSE_OPS: IntCounterVec = register_int_counter_vec!(
"musicfs_fuse_ops_total",
"Total FUSE operations",
&["op"]
).unwrap();
pub static ref FUSE_LATENCY: HistogramVec = register_histogram_vec!(
"musicfs_fuse_latency_seconds",
"FUSE operation latency",
&["op"],
vec![0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0]
).unwrap();
pub static ref CACHE_HITS: IntCounter = register_int_counter!(
"musicfs_cache_hits_total",
"Cache hits"
).unwrap();
}
```
#### Tests
| Test | Type | Validates |
|------|------|-----------|
| `test_grpc_status` | Unit | GetStatus RPC (FR-17.1) |
| `test_grpc_cache_clear` | Unit | ClearCache RPC (FR-17.3) |
| `test_grpc_events_stream` | Integration | Event streaming (FR-18.1) |
| `test_metrics_prometheus` | Unit | Prometheus format (NFR-6.1) |
| `test_cli_commands` | Integration | CLI works |
| `test_systemd_service` | E2E | Service lifecycle |
#### Exit Criteria
- [ ] gRPC API fully functional
- [ ] Event streaming works
- [ ] Prometheus metrics exported
- [ ] CLI feature-complete
- [ ] systemd service works
- [ ] All acceptance tests pass
---
## 6. Acceptance Test Matrix
| Test Suite | Requirements | Phase |
|------------|--------------|-------|
| `test_basic_mount` | FR-1.1-1.5, NFR-1.4-1.5, NFR-1.7 | 1 |
| `test_directory_ops` | FR-2.1-2.3, NFR-1.2 | 1 |
| `test_file_read` | FR-3.1-3.5, NFR-1.3 | 1 |
| `test_read_only` | FR-4.1-4.4 | 1 |
| `test_metadata_extraction` | FR-6.1-6.5 | 1 |
| `test_cache_persistence` | FR-7.1-7.4, FR-8.1-8.4 | 1 |
| `test_delta_sync` | FR-10.1-10.4, FR-11.1-11.4, NFR-5.1 | 2 |
| `test_multi_origin` | FR-13.1-13.6 | 2 |
| `test_remote_origins` | FR-12.1-12.5 | 2 |
| `test_search` | FR-14.1-14.4, NFR-2.5 | 3 |
| `test_smart_collections` | FR-15.1-15.5 | 3 |
| `test_album_art` | FR-16.1-16.4 | 3 |
| `test_prefetch` | FR-19.1-19.4 | 3 |
| `test_plugins` | FR-23.1-23.5 | 4 |
| `test_control_api` | FR-17.1-17.5 | 4 |
| `test_events` | FR-18.1-18.4 | 4 |
| `test_performance` | NFR-1.1-1.5, NFR-2.1-2.4 | All |
| `test_scalability` | NFR-3.1-3.4 | All |
---
## 7. Risk Mitigation
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| FUSE performance ceiling | Medium | High | Early benchmarking in Week 3; consider io_uring bypass |
| symphonia format gaps | Low | Medium | Document unsupported formats; allow format plugins |
| WASM plugin overhead | Medium | Low | Benchmark; native fallback for perf-critical plugins |
| S3 latency variability | High | Medium | Aggressive prefetch; health-based routing |
| SQLite contention | Low | Medium | WAL mode; read-only connections; consider sled fallback |
---
## 8. Deferred Requirements (Post-MVP)
The following P1 requirements are explicitly deferred from the 11-week plan:
### FR-21: External Metadata Sources [P1] → Phase 5
| ID | Requirement | Rationale for Deferral |
|----|-------------|------------------------|
| FR-21.1 | MusicBrainz integration | Requires rate limiting, caching, API key management |
| FR-21.2 | Discogs integration | OAuth flow complexity; lower priority than core function |
| FR-21.3 | Last.fm integration | Scrobbling is write operation; conflicts with read-only model |
| FR-21.4 | AcoustID fingerprinting | CPU-intensive; requires chromaprint dependency |
| FR-21.5 | Custom metadata plugins | Covered by FR-23 plugin system; metadata plugin type needed |
**Recommended Phase 5 scope (Weeks 12-13):**
- MusicBrainz lookup plugin (native)
- Metadata plugin trait in musicfs-plugins
- Example WASM metadata plugin
### FR-22: Import & Migration [P1] → Phase 5
| ID | Requirement | Rationale for Deferral |
|----|-------------|------------------------|
| FR-22.1 | Import from beets database | Requires beets schema knowledge; optional for fresh installs |
| FR-22.2 | Import from iTunes library | XML parsing; Apple ecosystem complexity |
| FR-22.3 | Export library metadata | Nice-to-have; users can query SQLite directly |
**Recommended Phase 5 scope (Week 14):**
- `musicfs import beets /path/to/beets.db` CLI command
- Export to JSON/CSV via CLI
### Why Defer?
1. **Core functionality first**: MVP must prove FUSE + caching + multi-origin works
2. **Plugin system prerequisite**: FR-21.5 depends on FR-23 being stable
3. **Limited impact**: Users can populate library from origins without import
4. **Scope control**: 11 weeks is aggressive; buffer for unknowns
---
## 9. Definition of Done
A feature is complete when:
1. [ ] Implementation matches architecture.md specification
2. [ ] Unit tests pass with >80% coverage
3. [ ] Integration tests pass
4. [ ] Performance meets NFR targets (benchmarked)
5. [ ] Documentation updated
6. [ ] No new `unsafe` without justification
7. [ ] Clippy clean (deny warnings)
8. [ ] Code reviewed
---
## 9. Getting Started (Week 1, Day 1)
```bash
# Create workspace
mkdir -p musicfs && cd musicfs
cargo init --name musicfs
# Create crate structure
mkdir -p crates/{musicfs-core,musicfs-fuse,musicfs-cache,musicfs-cas,musicfs-sync,musicfs-origins,musicfs-metadata,musicfs-search,musicfs-plugins,musicfs-grpc,musicfs-cli}
mkdir -p proto tests/{integration,e2e} benches
# Initialize each crate
for crate in crates/*; do
cargo init --lib "$crate"
done
cargo init crates/musicfs-cli
# Add to workspace Cargo.toml
cat > Cargo.toml << 'EOF'
[workspace]
resolver = "2"
members = ["crates/*"]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
rust-version = "1.75"
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
thiserror = "1"
tracing = "0.1"
serde = { version = "1", features = ["derive"] }
EOF
# Create flake.nix for reproducible builds
# ... (nix flake with rust-overlay)
```
Ready to begin implementation?