diff --git a/docs/v2/plans/week-02-metadata.md b/docs/v2/plans/week-02-metadata.md index 252f2b4..a5611ef 100644 --- a/docs/v2/plans/week-02-metadata.md +++ b/docs/v2/plans/week-02-metadata.md @@ -23,6 +23,21 @@ Implement audio metadata extraction using symphonia and create SQLite schema for --- +## Task 0: Extend AudioMeta in `musicfs-core` + +Add `lyrics` and `composer` fields to `AudioMeta` struct (FR-6.4): + +```rust +// In musicfs-core/src/types.rs, add to AudioMeta: +pub struct AudioMeta { + // ... existing fields ... + pub lyrics: Option, + pub composer: Option, +} +``` + +--- + ## Task 1: Metadata Parser (`musicfs-metadata`) ### 1.1 Create `Cargo.toml` @@ -168,6 +183,12 @@ impl MetadataParser { meta.year = value.chars().take(4).collect::() .parse().ok(); } + StandardTagKey::Lyrics => { + meta.lyrics = Some(value); + } + StandardTagKey::Composer => { + meta.composer = Some(value); + } _ => {} } } diff --git a/docs/v2/plans/week-10-plugin-system.md b/docs/v2/plans/week-10-plugin-system.md new file mode 100644 index 0000000..38cbdff --- /dev/null +++ b/docs/v2/plans/week-10-plugin-system.md @@ -0,0 +1,179 @@ +# Week 10: Plugin System + +**Phase**: 4 - Plugin System & Polish +**Goal**: Extensibility via native and WASM plugins +**Requirements**: FR-23.1-23.5, FR-24.1-24.3 + +--- + +## 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 | +| Plugin lifecycle | musicfs-plugins | `manager.rs` | FR-23.5 | +| Example plugins | plugins/ | `example-origin/`, `example-format/` | FR-23.5 | + +--- + +## Plugin Traits (`musicfs-plugins/src/traits.rs`) + +```rust +/// Base plugin interface +pub trait Plugin: Send + Sync { + fn name(&self) -> &str; + fn version(&self) -> Version; + fn init(&mut self, config: Value) -> Result<(), PluginError>; + fn shutdown(&mut self) -> Result<(), PluginError>; +} + +/// Origin plugin interface (per architecture 4.3.4) +pub trait OriginPlugin: Plugin { + fn origin_type(&self) -> &str; + fn create(&self, config: Value) -> Result, PluginError>; +} + +/// Metadata source plugin +pub trait MetadataPlugin: Plugin { + fn lookup(&self, query: &MetadataQuery) -> Result, PluginError>; +} + +/// Format plugin for custom audio formats (FR-24.1) +pub trait FormatPlugin: Plugin { + fn extensions(&self) -> &[&str]; + fn can_handle(&self, extension: &str) -> bool; + fn parse(&self, reader: &mut dyn Read) -> Result; +} +``` + +--- + +## Native Plugin Host (`musicfs-plugins/src/native.rs`) + +```rust +pub struct NativePluginHost { + plugins: HashMap, + search_paths: Vec, +} + +struct LoadedPlugin { + library: libloading::Library, + instance: Box, +} + +impl NativePluginHost { + pub fn new() -> Self; + + /// Load plugin from shared library (.so/.dylib) + pub fn load(&mut self, path: &Path) -> Result; + + /// Unload plugin (FR-23.5) + pub fn unload(&mut self, id: PluginId) -> Result<(), PluginError>; + + /// Hot reload plugin without restart (FR-23.4) + pub fn reload(&mut self, id: PluginId) -> Result<(), PluginError>; + + /// List loaded plugins + pub fn list(&self) -> Vec; +} +``` + +--- + +## WASM Plugin Host (`musicfs-plugins/src/wasm.rs`) + +```rust +pub struct WasmPluginHost { + engine: wasmtime::Engine, + linker: wasmtime::Linker, +} + +impl WasmPluginHost { + pub fn new() -> Result; + + /// Load WASM plugin with sandboxing (FR-23.3) + pub fn load(&mut self, wasm_bytes: &[u8]) -> Result; + + /// Resource limits for sandboxed execution + pub fn set_limits(&mut self, limits: ResourceLimits); +} + +pub struct ResourceLimits { + pub max_memory_mb: u32, + pub max_cpu_time_ms: u32, + pub allow_network: bool, + pub allow_filesystem: bool, +} +``` + +--- + +## Plugin Manager (`musicfs-plugins/src/manager.rs`) + +```rust +pub struct PluginManager { + native_host: NativePluginHost, + wasm_host: WasmPluginHost, + registry: PluginRegistry, +} + +impl PluginManager { + /// Initialize and load plugins from config + pub fn init(config: &PluginConfig) -> Result; + + /// Get all origin plugins + pub fn origin_plugins(&self) -> Vec<&dyn OriginPlugin>; + + /// Get all format plugins + pub fn format_plugins(&self) -> Vec<&dyn FormatPlugin>; + + /// Get all metadata plugins + pub fn metadata_plugins(&self) -> Vec<&dyn MetadataPlugin>; + + /// Reload all plugins (hot reload) + pub fn reload_all(&mut self) -> Result<(), PluginError>; +} +``` + +--- + +## Tests + +| Test | Type | Validates | +|------|------|-----------| +| `test_native_plugin_load` | Unit | Native plugin loading (FR-23.2) | +| `test_native_plugin_unload` | Unit | Clean unload | +| `test_wasm_plugin_sandbox` | Unit | WASM isolation (FR-23.3) | +| `test_wasm_resource_limits` | Unit | Memory/CPU limits enforced | +| `test_plugin_hot_reload` | Integration | Reload without restart (FR-23.4) | +| `test_example_origin_plugin` | Integration | Custom origin works | +| `test_example_format_plugin` | Integration | Custom format works | + +--- + +## Exit Criteria + +- [ ] Native plugins loadable at runtime +- [ ] WASM plugins sandboxed with resource limits +- [ ] Example plugins functional +- [ ] Plugins hot-reloadable without daemon restart +- [ ] Plugin lifecycle management (load, unload, reload) + +--- + +## Architecture Alignment + +Per architecture.md section 4.3.4: +- Plugin loading: Built-in → Native (.so) → WASM +- Origin plugins create `Box` +- Format plugins register file extensions +- WASM runs in wasmtime sandbox + +Per requirements.md: +- FR-23.1: Loadable plugins ✓ +- FR-23.2: Stable plugin API ✓ +- FR-23.3: Plugins for origins, metadata, formats ✓ +- FR-23.4: WASM sandbox ✓ +- FR-23.5: Plugin lifecycle ✓ diff --git a/docs/v2/plans/week-11-control-api.md b/docs/v2/plans/week-11-control-api.md new file mode 100644 index 0000000..e2bb0fc --- /dev/null +++ b/docs/v2/plans/week-11-control-api.md @@ -0,0 +1,539 @@ +# Week 11: Control API & Production + +**Phase**: 4 - Plugin System & Polish +**Goal**: gRPC control API, metrics, and production readiness +**Requirements**: FR-17.1-17.5, FR-18.1-18.4, NFR-6.1-6.4, NFR-10.1-10.4 + +--- + +## 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.3 | +| Webhook handler | musicfs-grpc | `webhook.rs` | FR-18.2 | +| Metrics export | musicfs-core | `metrics.rs` | NFR-6.1-6.4, NFR-10.2-10.4 | +| CLI completion | musicfs-cli | `main.rs` | FR-17 | +| systemd unit | dist/ | `musicfs.service` | Production | +| Packaging | dist/ | `PKGBUILD`, `musicfs.spec` | Production | +| E2E compatibility | tests/ | `e2e_players.rs` | NFR-12.1-12.3 | + +--- + +## Proto Definitions (`proto/musicfs.proto`) + +Per architecture.md section 4.3.7, implement full gRPC 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 (already implemented in Week 8) + 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. + +--- + +## gRPC Server (`musicfs-grpc/src/server.rs`) + +```rust +pub struct MusicFsService { + core: Arc, + events: broadcast::Sender, + metrics: Arc, +} + +#[tonic::async_trait] +impl musicfs::v1::music_fs_server::MusicFs for MusicFsService { + // Daemon lifecycle + async fn get_status(&self, _: Request) -> Result, Status>; + async fn shutdown(&self, req: Request) -> Result, Status>; + + // Cache management + async fn get_cache_stats(&self, _: Request) -> Result, Status>; + async fn clear_cache(&self, req: Request) -> Result, Status>; + + type PrefetchStream = ReceiverStream>; + async fn prefetch(&self, req: Request) -> Result, Status>; + + // Origin management + async fn list_origins(&self, _: Request) -> Result, Status>; + async fn get_origin_health(&self, req: Request) -> Result, Status>; + + type RescanOriginStream = ReceiverStream>; + async fn rescan_origin(&self, req: Request) -> Result, Status>; + + // Events + type SubscribeEventsStream = ReceiverStream>; + async fn subscribe_events(&self, req: Request) -> Result, Status>; +} +``` + +--- + +## Event Streaming (`musicfs-grpc/src/events.rs`) + +```rust +pub struct EventStreamer { + bus: Arc, +} + +impl EventStreamer { + /// Convert internal events to gRPC Event messages + pub fn subscribe(&self, filter: EventFilter) -> impl Stream; + + /// Filter events by type and origin + fn matches(event: &Event, filter: &EventFilter) -> bool; +} +``` + +--- + +## Webhook Handler (`musicfs-grpc/src/webhook.rs`) + +HTTP webhook notifications for external integrations (FR-18.2): + +```rust +use reqwest::Client; +use serde::Serialize; +use tokio::sync::broadcast; + +#[derive(Debug, Clone, Serialize)] +pub struct WebhookPayload { + pub event_type: String, + pub timestamp: i64, + pub data: serde_json::Value, +} + +pub struct WebhookConfig { + pub url: String, + pub secret: Option, + pub events: Vec, // Filter: ["file_accessed", "sync_completed", ...] + pub retry_count: u32, + pub timeout_ms: u64, +} + +pub struct WebhookHandler { + client: Client, + configs: Vec, +} + +impl WebhookHandler { + pub fn new(configs: Vec) -> Self; + + /// Start listening to event bus and dispatch webhooks + pub async fn run(&self, mut rx: broadcast::Receiver) { + while let Ok(event) = rx.recv().await { + for config in &self.configs { + if self.matches_filter(&event, config) { + self.dispatch(config, &event).await; + } + } + } + } + + /// Dispatch webhook with retry logic + async fn dispatch(&self, config: &WebhookConfig, event: &Event) { + let payload = WebhookPayload { + event_type: event.event_type(), + timestamp: event.timestamp(), + data: event.to_json(), + }; + + let mut attempts = 0; + loop { + let result = self.client + .post(&config.url) + .timeout(Duration::from_millis(config.timeout_ms)) + .header("X-MusicFS-Signature", self.sign(&payload, config)) + .json(&payload) + .send() + .await; + + match result { + Ok(resp) if resp.status().is_success() => break, + _ if attempts < config.retry_count => { + attempts += 1; + tokio::time::sleep(Duration::from_millis(100 * 2u64.pow(attempts))).await; + } + _ => { + tracing::warn!("Webhook delivery failed after {} attempts", attempts); + break; + } + } + } + } + + /// HMAC-SHA256 signature if secret configured + fn sign(&self, payload: &WebhookPayload, config: &WebhookConfig) -> String; + + fn matches_filter(&self, event: &Event, config: &WebhookConfig) -> bool; +} +``` + +Configuration in `config.toml`: + +```toml +[[webhooks]] +url = "https://example.com/musicfs/events" +secret = "your-webhook-secret" +events = ["file_accessed", "sync_completed", "origin_health_changed"] +retry_count = 3 +timeout_ms = 5000 +``` + +--- + +## E2E Compatibility Tests (`tests/e2e_players.rs`) + +Verify MusicFS works with common media players (NFR-12.1-12.3): + +```rust +//! E2E tests for media player compatibility +//! Requires: mpv, vlc, file manager (nautilus/dolphin) installed + +use std::process::Command; +use std::time::Duration; + +/// Test mpv can play files from MusicFS (NFR-12.1) +#[test] +#[ignore] // Run manually: cargo test --ignored +fn test_mpv_playback() { + let mountpoint = setup_test_mount(); + + // mpv should be able to: + // 1. Open file without hanging + // 2. Read metadata (duration, format) + // 3. Play first few seconds + // 4. Seek forward + // 5. Exit cleanly + + let output = Command::new("mpv") + .args([ + "--no-video", + "--no-audio", // Silent playback + "--length=2", // Play 2 seconds only + "--msg-level=all=debug", + &format!("{}/Artist/Album/01 - Track.flac", mountpoint), + ]) + .output() + .expect("mpv must be installed"); + + assert!(output.status.success(), "mpv playback failed: {:?}", output); +} + +/// Test VLC can browse and play (NFR-12.2) +#[test] +#[ignore] +fn test_vlc_playback() { + let mountpoint = setup_test_mount(); + + // VLC should handle: + // 1. Directory browsing + // 2. Playlist creation from folder + // 3. Metadata display + // 4. Gapless playback (if supported) + + let output = Command::new("cvlc") // Command-line VLC + .args([ + "--play-and-exit", + "--run-time=2", + &format!("{}/Artist/Album/", mountpoint), + ]) + .output() + .expect("vlc must be installed"); + + assert!(output.status.success(), "VLC playback failed"); +} + +/// Test file manager operations (NFR-12.3) +#[test] +#[ignore] +fn test_file_manager_operations() { + let mountpoint = setup_test_mount(); + + // File managers should be able to: + // 1. List directories without timeout + // 2. Show file previews/thumbnails + // 3. Display file properties + // 4. Copy files to local disk + + // Test basic stat operations that file managers use + let entries: Vec<_> = std::fs::read_dir(&mountpoint) + .expect("read_dir failed") + .collect(); + + assert!(!entries.is_empty(), "mountpoint should have entries"); + + // Test stat on each entry (file managers do this for icons) + for entry in entries { + let entry = entry.expect("entry should be valid"); + let metadata = entry.metadata().expect("metadata should work"); + assert!(metadata.is_dir() || metadata.is_file()); + } +} + +/// Test concurrent access from multiple players +#[test] +#[ignore] +fn test_concurrent_player_access() { + let mountpoint = setup_test_mount(); + + // Spawn multiple players accessing different files + let handles: Vec<_> = (0..3) + .map(|i| { + let mp = mountpoint.clone(); + std::thread::spawn(move || { + Command::new("mpv") + .args([ + "--no-video", "--no-audio", "--length=1", + &format!("{}/Artist/Album/0{} - Track.flac", mp, i + 1), + ]) + .output() + }) + }) + .collect(); + + for handle in handles { + let output = handle.join().unwrap().expect("mpv should run"); + assert!(output.status.success()); + } +} + +fn setup_test_mount() -> String { + // Returns path to test mount with sample files + std::env::var("MUSICFS_TEST_MOUNT") + .unwrap_or_else(|_| "/tmp/musicfs-test".to_string()) +} +``` + +--- + +## Metrics (`musicfs-core/src/metrics.rs`) + +Per architecture.md section 5.2: + +```rust +use prometheus::{IntCounterVec, HistogramVec, IntGauge, register_*}; + +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(); + + pub static ref CACHE_MISSES: IntCounter = register_int_counter!( + "musicfs_cache_misses_total", + "Cache misses" + ).unwrap(); + + pub static ref CACHE_SIZE_BYTES: IntGauge = register_int_gauge!( + "musicfs_cache_size_bytes", + "Current cache size in bytes" + ).unwrap(); + + pub static ref ORIGIN_HEALTH: IntGaugeVec = register_int_gauge_vec!( + "musicfs_origin_health", + "Origin health status (1=healthy, 0=unhealthy)", + &["origin"] + ).unwrap(); +} + +/// Expose metrics on HTTP endpoint +pub async fn serve_metrics(addr: SocketAddr) -> Result<(), MetricsError>; +``` + +--- + +## CLI Commands (`musicfs-cli/src/main.rs`) + +```rust +#[derive(Parser)] +enum Command { + /// Mount filesystem + Mount { + #[arg(short, long)] + config: PathBuf, + mountpoint: PathBuf, + }, + + /// Get daemon status + Status, + + /// Cache management + Cache { + #[command(subcommand)] + command: CacheCommand, + }, + + /// Search library + Search { + query: String, + #[arg(short, long, default_value = "100")] + limit: u32, + }, + + /// Origin management + Origin { + #[command(subcommand)] + command: OriginCommand, + }, + + /// Subscribe to events + Events { + #[arg(short, long)] + r#type: Option, + }, +} + +#[derive(Subcommand)] +enum CacheCommand { + Stats, + Clear { origin: Option }, + Prefetch { paths: Vec }, +} + +#[derive(Subcommand)] +enum OriginCommand { + List, + Health { origin_id: String }, + Rescan { origin_id: String }, +} +``` + +--- + +## systemd Service (`dist/musicfs.service`) + +```ini +[Unit] +Description=MusicFS - Metadata-Organized Music Filesystem +After=network.target + +[Service] +Type=notify +ExecStart=/usr/bin/musicfs mount --config /etc/musicfs/config.toml /mnt/music +ExecStop=/usr/bin/musicfs shutdown +Restart=on-failure +RestartSec=5 +User=musicfs +Group=musicfs + +# Security hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/var/cache/musicfs /mnt/music +PrivateTmp=true + +[Install] +WantedBy=multi-user.target +``` + +--- + +## Tests + +| Test | Type | Validates | +|------|------|-----------| +| `test_grpc_status` | Unit | GetStatus RPC (FR-17.1) | +| `test_grpc_cache_stats` | Unit | GetCacheStats RPC | +| `test_grpc_cache_clear` | Unit | ClearCache RPC (FR-17.3) | +| `test_grpc_origins_list` | Unit | ListOrigins RPC | +| `test_grpc_origin_rescan` | Integration | RescanOrigin streaming | +| `test_grpc_events_stream` | Integration | Event streaming (FR-18.1) | +| `test_grpc_prefetch_stream` | Integration | Prefetch progress | +| `test_webhook_dispatch` | Unit | Webhook delivery (FR-18.2) | +| `test_webhook_retry` | Unit | Webhook retry on failure | +| `test_webhook_hmac_signature` | Unit | HMAC-SHA256 signing | +| `test_metrics_prometheus` | Unit | Prometheus format (NFR-6.1) | +| `test_metrics_http_endpoint` | Integration | HTTP metrics endpoint | +| `test_cli_commands` | Integration | CLI works | +| `test_systemd_service` | E2E | Service lifecycle | +| `test_mpv_playback` | E2E | mpv compatibility (NFR-12.1) | +| `test_vlc_playback` | E2E | VLC compatibility (NFR-12.2) | +| `test_file_manager_operations` | E2E | File manager browsing (NFR-12.3) | +| `test_concurrent_player_access` | E2E | Multiple players concurrently | + +--- + +## Exit Criteria + +- [ ] gRPC API fully functional (all RPCs from architecture.md 4.3.7) +- [ ] Event streaming works with filtering +- [ ] Webhook notifications delivered with HMAC signing +- [ ] Prometheus metrics exported on HTTP endpoint +- [ ] CLI feature-complete with all commands +- [ ] systemd service works (start, stop, restart) +- [ ] mpv, VLC playback verified (E2E tests) +- [ ] File manager browsing verified +- [ ] All acceptance tests pass + +--- + +## Architecture Alignment + +Per architecture.md section 4.3.7: +- gRPC over Unix socket ✓ +- Protocol Buffers for type safety ✓ +- Server-streaming for events, sync progress, prefetch ✓ +- CLI wraps gRPC client ✓ + +Per architecture.md section 5.2: +- Prometheus metrics format ✓ +- Golden signals: latency, traffic, errors, saturation ✓ + +Per requirements.md: +- FR-17.1: Unix socket control ✓ +- FR-17.2: gRPC with Protocol Buffers ✓ +- FR-17.3: Cache management commands ✓ +- FR-17.4: Runtime configuration ✓ +- FR-17.5: Graceful shutdown ✓ +- FR-18.1: File access events ✓ +- FR-18.2: Webhook notifications ✓ (HTTP webhooks with HMAC) +- FR-18.3: Event streaming ✓ +- FR-18.4: Access pattern logging ✓ +- NFR-10.1: Configurable logging ✓ +- NFR-10.2: Metrics exposure ✓ +- NFR-10.3: Health check ✓ +- NFR-10.4: Prometheus integration ✓ +- NFR-12.1: mpv compatibility ✓ (E2E tests) +- NFR-12.2: VLC compatibility ✓ (E2E tests) +- NFR-12.3: File manager compatibility ✓ (E2E tests) diff --git a/docs/v2/plans/week-12-external-metadata.md b/docs/v2/plans/week-12-external-metadata.md new file mode 100644 index 0000000..6d309ca --- /dev/null +++ b/docs/v2/plans/week-12-external-metadata.md @@ -0,0 +1,624 @@ +# Week 12: External Metadata Integration + +**Phase**: 5 - P1 Feature Completion +**Goal**: Integrate external metadata sources for automatic tagging and artwork +**Requirements**: FR-21.1-21.4, FR-16.5 + +--- + +## Deliverables + +| Task | Crate | Files | Requirements | +|------|-------|-------|--------------| +| MusicBrainz client | musicfs-external | `musicbrainz.rs` | FR-21.1 | +| Discogs client | musicfs-external | `discogs.rs` | FR-21.2 | +| Last.fm client | musicfs-external | `lastfm.rs` | FR-21.3 | +| AcoustID/Chromaprint | musicfs-external | `acoustid.rs` | FR-21.4 | +| Online artwork fetch | musicfs-external | `artwork_fetch.rs` | FR-16.5 | +| Metadata enrichment | musicfs-external | `enrichment.rs` | All | +| Plugin integration | musicfs-plugins | `metadata_plugin.rs` | FR-21.5 | + +--- + +## Task 1: Create `musicfs-external` Crate + +### 1.1 `Cargo.toml` + +```toml +[package] +name = "musicfs-external" +version.workspace = true +edition.workspace = true + +[dependencies] +musicfs-core = { path = "../musicfs-core" } +reqwest = { version = "0.11", features = ["json"] } +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true +thiserror.workspace = true +chromaprint = "0.6" # Audio fingerprinting +base64 = "0.21" + +[dev-dependencies] +wiremock = "0.5" # Mock HTTP responses +tokio-test = "0.4" +``` + +### 1.2 `src/lib.rs` + +```rust +pub mod musicbrainz; +pub mod discogs; +pub mod lastfm; +pub mod acoustid; +pub mod artwork_fetch; +pub mod enrichment; + +pub use enrichment::MetadataEnricher; +``` + +--- + +## Task 2: MusicBrainz Client (`musicfs-external/src/musicbrainz.rs`) + +```rust +use serde::Deserialize; + +const MB_API: &str = "https://musicbrainz.org/ws/2"; +const USER_AGENT: &str = "MusicFS/0.1.0 (https://github.com/user/musicfs)"; + +#[derive(Debug, Deserialize)] +pub struct MbRecording { + pub id: String, + pub title: String, + pub length: Option, + #[serde(rename = "artist-credit")] + pub artist_credit: Vec, + pub releases: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct MbRelease { + pub id: String, + pub title: String, + pub date: Option, + #[serde(rename = "release-group")] + pub release_group: Option, +} + +#[derive(Debug, Deserialize)] +pub struct MbReleaseGroup { + pub id: String, + #[serde(rename = "primary-type")] + pub primary_type: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ArtistCredit { + pub artist: MbArtist, +} + +#[derive(Debug, Deserialize)] +pub struct MbArtist { + pub id: String, + pub name: String, + #[serde(rename = "sort-name")] + pub sort_name: String, +} + +pub struct MusicBrainzClient { + client: reqwest::Client, + rate_limiter: RateLimiter, // 1 req/sec per MB guidelines +} + +impl MusicBrainzClient { + pub fn new() -> Self { + let client = reqwest::Client::builder() + .user_agent(USER_AGENT) + .build() + .expect("client build"); + + Self { + client, + rate_limiter: RateLimiter::new(Duration::from_secs(1)), + } + } + + /// Search by recording title + artist (FR-21.1) + pub async fn search_recording( + &self, + title: &str, + artist: Option<&str>, + ) -> Result, ExternalError> { + self.rate_limiter.wait().await; + + let mut query = format!("recording:{}", title); + if let Some(artist) = artist { + query.push_str(&format!(" AND artist:{}", artist)); + } + + let resp = self.client + .get(format!("{}/recording", MB_API)) + .query(&[ + ("query", query.as_str()), + ("fmt", "json"), + ("limit", "5"), + ]) + .send() + .await?; + + let body: SearchResponse = resp.json().await?; + Ok(body.recordings) + } + + /// Get release artwork from Cover Art Archive + pub async fn get_cover_art(&self, release_id: &str) -> Result>, ExternalError> { + let url = format!("https://coverartarchive.org/release/{}/front-500", release_id); + + let resp = self.client.get(&url).send().await?; + if resp.status() == 404 { + return Ok(None); + } + + let bytes = resp.bytes().await?; + Ok(Some(bytes.to_vec())) + } + + /// Lookup recording by MusicBrainz ID + pub async fn get_recording(&self, mbid: &str) -> Result { + self.rate_limiter.wait().await; + + let resp = self.client + .get(format!("{}/recording/{}", MB_API, mbid)) + .query(&[ + ("inc", "artist-credits+releases+release-groups"), + ("fmt", "json"), + ]) + .send() + .await?; + + Ok(resp.json().await?) + } +} + +struct RateLimiter { + interval: Duration, + last_request: Mutex, +} + +impl RateLimiter { + fn new(interval: Duration) -> Self { + Self { + interval, + last_request: Mutex::new(Instant::now() - interval), + } + } + + async fn wait(&self) { + let mut last = self.last_request.lock().await; + let elapsed = last.elapsed(); + if elapsed < self.interval { + tokio::time::sleep(self.interval - elapsed).await; + } + *last = Instant::now(); + } +} +``` + +--- + +## Task 3: Discogs Client (`musicfs-external/src/discogs.rs`) + +```rust +const DISCOGS_API: &str = "https://api.discogs.com"; + +pub struct DiscogsClient { + client: reqwest::Client, + token: Option, + rate_limiter: RateLimiter, // 60 req/min authenticated +} + +impl DiscogsClient { + pub fn new(token: Option) -> Self; + + /// Search releases (FR-21.2) + pub async fn search( + &self, + query: &str, + artist: Option<&str>, + ) -> Result, ExternalError>; + + /// Get master release details + pub async fn get_master(&self, id: u64) -> Result; + + /// Get release images + pub async fn get_images(&self, release_id: u64) -> Result, ExternalError>; +} + +#[derive(Debug, Deserialize)] +pub struct DiscogsRelease { + pub id: u64, + pub title: String, + pub year: Option, + pub thumb: Option, + pub master_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct DiscogsImage { + pub uri: String, + pub width: u32, + pub height: u32, + #[serde(rename = "type")] + pub image_type: String, // "primary" or "secondary" +} +``` + +--- + +## Task 4: Last.fm Client (`musicfs-external/src/lastfm.rs`) + +```rust +const LASTFM_API: &str = "https://ws.audioscrobbler.com/2.0"; + +pub struct LastFmClient { + client: reqwest::Client, + api_key: String, +} + +impl LastFmClient { + pub fn new(api_key: String) -> Self; + + /// Get track info with play counts, tags (FR-21.3) + pub async fn get_track_info( + &self, + track: &str, + artist: &str, + ) -> Result; + + /// Get album info with artwork + pub async fn get_album_info( + &self, + album: &str, + artist: &str, + ) -> Result; + + /// Get artist info + pub async fn get_artist_info(&self, artist: &str) -> Result; +} + +#[derive(Debug, Deserialize)] +pub struct LastFmTrack { + pub name: String, + pub playcount: Option, + pub listeners: Option, + pub duration: Option, + pub toptags: Option, + pub album: Option, +} + +#[derive(Debug, Deserialize)] +pub struct LastFmAlbum { + pub name: String, + pub artist: String, + pub image: Vec, + pub tracks: Option, +} + +#[derive(Debug, Deserialize)] +pub struct LastFmImage { + #[serde(rename = "#text")] + pub url: String, + pub size: String, // "small", "medium", "large", "extralarge", "mega" +} +``` + +--- + +## Task 5: AcoustID/Chromaprint (`musicfs-external/src/acoustid.rs`) + +```rust +use chromaprint::{Fingerprinter, Configuration}; + +const ACOUSTID_API: &str = "https://api.acoustid.org/v2/lookup"; + +pub struct AcoustIdClient { + client: reqwest::Client, + api_key: String, +} + +impl AcoustIdClient { + pub fn new(api_key: String) -> Self; + + /// Generate fingerprint from audio data (FR-21.4) + pub fn fingerprint(&self, samples: &[i16], sample_rate: u32) -> Result { + let config = Configuration::preset_test1(); + let mut fp = Fingerprinter::new(&config); + + fp.start(sample_rate, 1)?; // mono + fp.feed(samples)?; + fp.finish()?; + + Ok(fp.fingerprint().to_string()) + } + + /// Lookup fingerprint on AcoustID database + pub async fn lookup( + &self, + fingerprint: &str, + duration: u32, + ) -> Result, ExternalError> { + let resp = self.client + .get(ACOUSTID_API) + .query(&[ + ("client", self.api_key.as_str()), + ("fingerprint", fingerprint), + ("duration", &duration.to_string()), + ("meta", "recordings+releasegroups"), + ]) + .send() + .await?; + + let body: AcoustIdResponse = resp.json().await?; + Ok(body.results) + } +} + +#[derive(Debug, Deserialize)] +pub struct AcoustIdResult { + pub id: String, + pub score: f32, + pub recordings: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct AcoustIdRecording { + pub id: String, // MusicBrainz recording ID + pub title: Option, + pub artists: Option>, +} +``` + +--- + +## Task 6: Online Artwork Fetch (`musicfs-external/src/artwork_fetch.rs`) + +```rust +pub struct ArtworkFetcher { + musicbrainz: MusicBrainzClient, + discogs: Option, + lastfm: Option, +} + +impl ArtworkFetcher { + /// Fetch missing artwork from online sources (FR-16.5) + /// Tries sources in order: MusicBrainz Cover Art Archive → Discogs → Last.fm + pub async fn fetch_artwork( + &self, + artist: &str, + album: &str, + size: ArtworkSize, + ) -> Result, ExternalError> { + // 1. Try MusicBrainz release search → Cover Art Archive + if let Some(art) = self.try_musicbrainz(artist, album, size).await? { + return Ok(Some(art)); + } + + // 2. Try Discogs + if let Some(discogs) = &self.discogs { + if let Some(art) = self.try_discogs(discogs, artist, album, size).await? { + return Ok(Some(art)); + } + } + + // 3. Try Last.fm + if let Some(lastfm) = &self.lastfm { + if let Some(art) = self.try_lastfm(lastfm, artist, album, size).await? { + return Ok(Some(art)); + } + } + + Ok(None) + } + + async fn try_musicbrainz( + &self, + artist: &str, + album: &str, + size: ArtworkSize, + ) -> Result, ExternalError> { + // Search for release, get cover art from Cover Art Archive + let releases = self.musicbrainz.search_release(album, Some(artist)).await?; + + for release in releases.iter().take(3) { + if let Some(art) = self.musicbrainz.get_cover_art(&release.id).await? { + return Ok(Some(ArtworkData { + data: art, + source: ArtworkSource::MusicBrainz, + mime_type: "image/jpeg".to_string(), + })); + } + } + + Ok(None) + } +} + +#[derive(Debug)] +pub struct ArtworkData { + pub data: Vec, + pub source: ArtworkSource, + pub mime_type: String, +} + +#[derive(Debug)] +pub enum ArtworkSource { + MusicBrainz, + Discogs, + LastFm, + Embedded, +} + +pub enum ArtworkSize { + Small, // 150px + Medium, // 300px + Large, // 500px + Original, +} +``` + +--- + +## Task 7: Metadata Enrichment (`musicfs-external/src/enrichment.rs`) + +```rust +pub struct MetadataEnricher { + musicbrainz: MusicBrainzClient, + acoustid: Option, + artwork_fetcher: ArtworkFetcher, +} + +impl MetadataEnricher { + /// Enrich metadata from external sources + pub async fn enrich(&self, meta: &AudioMeta) -> Result { + let mut enriched = EnrichedMetadata::from(meta); + + // If we have title + artist, search MusicBrainz + if let (Some(title), Some(artist)) = (&meta.title, &meta.artist) { + let recordings = self.musicbrainz.search_recording(title, Some(artist)).await?; + + if let Some(best) = recordings.first() { + enriched.musicbrainz_recording_id = Some(best.id.clone()); + + // Enrich with release info + if let Some(releases) = &best.releases { + if let Some(release) = releases.first() { + enriched.musicbrainz_release_id = Some(release.id.clone()); + } + } + } + } + + Ok(enriched) + } + + /// Identify unknown track by audio fingerprint + pub async fn identify_by_fingerprint( + &self, + samples: &[i16], + sample_rate: u32, + duration: u32, + ) -> Result, ExternalError> { + let acoustid = self.acoustid.as_ref() + .ok_or(ExternalError::ServiceNotConfigured("AcoustID"))?; + + let fingerprint = acoustid.fingerprint(samples, sample_rate)?; + let results = acoustid.lookup(&fingerprint, duration).await?; + + // Return best match above threshold + results.into_iter() + .filter(|r| r.score > 0.8) + .flat_map(|r| r.recordings) + .flatten() + .next() + .map(|rec| IdentifiedTrack { + title: rec.title, + musicbrainz_id: Some(rec.id), + artists: rec.artists.map(|a| a.into_iter().map(|x| x.name).collect()), + }) + .pipe(Ok) + } +} + +#[derive(Debug)] +pub struct EnrichedMetadata { + pub original: AudioMeta, + pub musicbrainz_recording_id: Option, + pub musicbrainz_release_id: Option, + pub musicbrainz_artist_id: Option, + pub genres: Vec, + pub play_count: Option, +} + +#[derive(Debug)] +pub struct IdentifiedTrack { + pub title: Option, + pub musicbrainz_id: Option, + pub artists: Option>, +} +``` + +--- + +## Configuration + +```toml +[external] +# MusicBrainz (no auth required, rate limited to 1 req/sec) +musicbrainz.enabled = true + +# Discogs (optional, requires token for higher rate limits) +discogs.enabled = true +discogs.token = "your_discogs_token" + +# Last.fm (requires API key) +lastfm.enabled = true +lastfm.api_key = "your_lastfm_api_key" + +# AcoustID (requires API key) +acoustid.enabled = true +acoustid.api_key = "your_acoustid_api_key" + +# Artwork fetching behavior +artwork.fetch_missing = true +artwork.cache_fetched = true +artwork.preferred_size = "large" # small, medium, large, original +``` + +--- + +## Tests + +| Test | Type | Validates | +|------|------|-----------| +| `test_musicbrainz_search` | Integration | Recording search (FR-21.1) | +| `test_musicbrainz_cover_art` | Integration | Cover Art Archive | +| `test_discogs_search` | Integration | Release search (FR-21.2) | +| `test_lastfm_track_info` | Integration | Track metadata (FR-21.3) | +| `test_acoustid_fingerprint` | Unit | Chromaprint generation | +| `test_acoustid_lookup` | Integration | Fingerprint lookup (FR-21.4) | +| `test_artwork_fetch_cascade` | Integration | Multi-source artwork (FR-16.5) | +| `test_metadata_enrichment` | Integration | Full enrichment flow | +| `test_rate_limiting` | Unit | Rate limiter works | +| `test_mock_responses` | Unit | Offline testing with mocks | + +--- + +## Exit Criteria + +- [ ] MusicBrainz search returns relevant recordings +- [ ] Cover Art Archive artwork downloads work +- [ ] Discogs integration retrieves release info +- [ ] Last.fm integration retrieves track/artist info +- [ ] AcoustID fingerprinting identifies tracks +- [ ] Artwork fetcher tries all sources in cascade +- [ ] Metadata enricher adds external IDs +- [ ] Rate limiting prevents API abuse +- [ ] All tests pass with mock HTTP responses + +--- + +## Architecture Alignment + +Per requirements.md: +- FR-21.1: MusicBrainz for canonical metadata ✓ +- FR-21.2: Discogs for release info, artwork ✓ +- FR-21.3: Last.fm for play counts, tags ✓ +- FR-21.4: AcoustID for audio fingerprinting ✓ +- FR-16.5: Fetch missing artwork from online ✓ + +Per architecture.md section 4.3.4: +- External metadata via `MetadataPlugin` trait ✓ +- Plugin architecture allows adding more sources ✓ diff --git a/docs/v2/plans/week-13-import-export.md b/docs/v2/plans/week-13-import-export.md new file mode 100644 index 0000000..63a6e28 --- /dev/null +++ b/docs/v2/plans/week-13-import-export.md @@ -0,0 +1,699 @@ +# Week 13: Import & Export + +**Phase**: 5 - P1 Feature Completion +**Goal**: Import metadata from existing library managers, export library data +**Requirements**: FR-22.1-22.3 + +--- + +## Deliverables + +| Task | Crate | Files | Requirements | +|------|-------|-------|--------------| +| Beets database import | musicfs-import | `beets.rs` | FR-22.1 | +| iTunes/Apple Music import | musicfs-import | `itunes.rs` | FR-22.2 | +| Library export | musicfs-import | `export.rs` | FR-22.3 | +| Import CLI | musicfs-cli | `import.rs` | All | + +--- + +## Task 1: Create `musicfs-import` Crate + +### 1.1 `Cargo.toml` + +```toml +[package] +name = "musicfs-import" +version.workspace = true +edition.workspace = true + +[dependencies] +musicfs-core = { path = "../musicfs-core" } +musicfs-cache = { path = "../musicfs-cache" } +rusqlite = { workspace = true, features = ["bundled"] } +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +plist = "1.5" # For iTunes XML parsing +tokio.workspace = true +tracing.workspace = true +thiserror.workspace = true +csv = "1.3" +url = "2.4" +percent-encoding = "2.3" +chrono = { version = "0.4", features = ["serde"] } + +[dev-dependencies] +tempfile.workspace = true +``` + +### 1.2 `src/lib.rs` + +```rust +pub mod beets; +pub mod itunes; +pub mod export; + +use musicfs_core::Result; + +/// Common import result +#[derive(Debug, Default)] +pub struct ImportResult { + pub imported: usize, + pub skipped: usize, + pub errors: Vec, +} + +#[derive(Debug)] +pub struct ImportError { + pub path: String, + pub reason: String, +} + +/// Import progress callback +pub type ProgressCallback = Box; + +#[derive(Debug, Clone)] +pub struct ImportProgress { + pub current: usize, + pub total: usize, + pub current_file: String, +} +``` + +--- + +## Task 2: Beets Database Import (`musicfs-import/src/beets.rs`) + +```rust +use rusqlite::{Connection, params}; +use std::path::Path; + +/// Beets database schema (simplified) +/// Full schema: https://beets.readthedocs.io/en/stable/dev/db.html +#[derive(Debug)] +pub struct BeetsItem { + pub id: i64, + pub path: String, + pub title: Option, + pub artist: Option, + pub album: Option, + pub album_artist: Option, + pub genre: Option, + pub year: Option, + pub track: Option, + pub disc: Option, + pub length: Option, + pub bitrate: Option, + pub sample_rate: Option, + pub format: Option, + pub mb_trackid: Option, + pub mb_albumid: Option, + pub mb_artistid: Option, + pub mtime: f64, +} + +pub struct BeetsImporter { + beets_db: Connection, + target_db: Arc, +} + +impl BeetsImporter { + /// Open beets database for import (FR-22.1) + pub fn new(beets_db_path: &Path, target_db: Arc) -> Result { + let conn = Connection::open_with_flags( + beets_db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + )?; + + // Verify this is a beets database + let tables: Vec = conn + .prepare("SELECT name FROM sqlite_master WHERE type='table'")? + .query_map([], |row| row.get(0))? + .filter_map(|r| r.ok()) + .collect(); + + if !tables.contains(&"items".to_string()) { + return Err(ImportError::InvalidDatabase("Not a beets database")); + } + + Ok(Self { + beets_db: conn, + target_db, + }) + } + + /// Count items to import + pub fn count_items(&self) -> Result { + self.beets_db + .query_row("SELECT COUNT(*) FROM items", [], |row| row.get(0)) + .map_err(Into::into) + } + + /// Import all items with progress callback + pub fn import_all(&self, progress: Option) -> Result { + let total = self.count_items()?; + let mut result = ImportResult::default(); + + let mut stmt = self.beets_db.prepare(r#" + SELECT id, path, title, artist, album, albumartist, genre, + year, track, disc, length, bitrate, samplerate, format, + mb_trackid, mb_albumid, mb_artistid, mtime + FROM items + "#)?; + + let items = stmt.query_map([], |row| { + Ok(BeetsItem { + id: row.get(0)?, + path: row.get(1)?, + title: row.get(2)?, + artist: row.get(3)?, + album: row.get(4)?, + album_artist: row.get(5)?, + genre: row.get(6)?, + year: row.get(7)?, + track: row.get(8)?, + disc: row.get(9)?, + length: row.get(10)?, + bitrate: row.get(11)?, + sample_rate: row.get(12)?, + format: row.get(13)?, + mb_trackid: row.get(14)?, + mb_albumid: row.get(15)?, + mb_artistid: row.get(16)?, + mtime: row.get(17)?, + }) + })?; + + for (idx, item) in items.enumerate() { + match item { + Ok(item) => { + if let Some(ref cb) = progress { + cb(ImportProgress { + current: idx + 1, + total, + current_file: item.path.clone(), + }); + } + + match self.import_item(&item) { + Ok(_) => result.imported += 1, + Err(e) => { + result.errors.push(ImportError { + path: item.path, + reason: e.to_string(), + }); + } + } + } + Err(e) => { + result.skipped += 1; + result.errors.push(ImportError { + path: format!("item_{}", idx), + reason: e.to_string(), + }); + } + } + } + + Ok(result) + } + + fn import_item(&self, item: &BeetsItem) -> Result<(), ImportError> { + let path = Path::new(&item.path); + + // Convert to our AudioMeta + let audio_meta = AudioMeta { + title: item.title.clone(), + artist: item.artist.clone(), + album: item.album.clone(), + album_artist: item.album_artist.clone(), + genre: item.genre.clone(), + year: item.year.map(|y| y as u32), + track: item.track.map(|t| t as u32), + disc: item.disc.map(|d| d as u32), + duration_ms: item.length.map(|l| (l * 1000.0) as u64), + bitrate: item.bitrate.map(|b| b as u32), + sample_rate: item.sample_rate.map(|s| s as u32), + format: AudioFormat::from_extension( + path.extension().and_then(|e| e.to_str()).unwrap_or("") + ), + ..Default::default() + }; + + // Generate virtual path using our resolver + let virtual_path = VirtualPath::from_metadata(&audio_meta, path); + + // Import to our database + self.target_db.upsert_file( + &OriginId::from("beets-import"), + path, + &virtual_path, + &audio_meta, + std::time::UNIX_EPOCH + std::time::Duration::from_secs_f64(item.mtime), + std::fs::metadata(path).map(|m| m.len()).unwrap_or(0), + )?; + + Ok(()) + } +} +``` + +--- + +## Task 3: iTunes/Apple Music Import (`musicfs-import/src/itunes.rs`) + +```rust +use plist::Value; +use std::collections::HashMap; +use url::Url; + +/// iTunes Library XML format +#[derive(Debug)] +pub struct ItunesTrack { + pub track_id: u64, + pub name: Option, + pub artist: Option, + pub album: Option, + pub album_artist: Option, + pub genre: Option, + pub year: Option, + pub track_number: Option, + pub disc_number: Option, + pub total_time: Option, // milliseconds + pub bit_rate: Option, + pub sample_rate: Option, + pub location: Option, // file:// URL + pub date_added: Option, +} + +pub struct ItunesImporter { + tracks: Vec, + target_db: Arc, +} + +impl ItunesImporter { + /// Parse iTunes Library.xml (FR-22.2) + pub fn from_xml(xml_path: &Path, target_db: Arc) -> Result { + let file = std::fs::File::open(xml_path)?; + let plist: Value = plist::from_reader(file)?; + + let dict = plist.as_dictionary() + .ok_or(ImportError::InvalidFormat("Expected dictionary at root"))?; + + let tracks_dict = dict.get("Tracks") + .and_then(|v| v.as_dictionary()) + .ok_or(ImportError::InvalidFormat("Missing Tracks dictionary"))?; + + let mut tracks = Vec::new(); + + for (_, track_value) in tracks_dict { + if let Some(track_dict) = track_value.as_dictionary() { + tracks.push(Self::parse_track(track_dict)?); + } + } + + Ok(Self { tracks, target_db }) + } + + fn parse_track(dict: &plist::Dictionary) -> Result { + Ok(ItunesTrack { + track_id: dict.get("Track ID") + .and_then(|v| v.as_unsigned_integer()) + .unwrap_or(0), + name: dict.get("Name").and_then(|v| v.as_string()).map(String::from), + artist: dict.get("Artist").and_then(|v| v.as_string()).map(String::from), + album: dict.get("Album").and_then(|v| v.as_string()).map(String::from), + album_artist: dict.get("Album Artist").and_then(|v| v.as_string()).map(String::from), + genre: dict.get("Genre").and_then(|v| v.as_string()).map(String::from), + year: dict.get("Year").and_then(|v| v.as_unsigned_integer()).map(|v| v as u32), + track_number: dict.get("Track Number").and_then(|v| v.as_unsigned_integer()).map(|v| v as u32), + disc_number: dict.get("Disc Number").and_then(|v| v.as_unsigned_integer()).map(|v| v as u32), + total_time: dict.get("Total Time").and_then(|v| v.as_unsigned_integer()), + bit_rate: dict.get("Bit Rate").and_then(|v| v.as_unsigned_integer()).map(|v| v as u32), + sample_rate: dict.get("Sample Rate").and_then(|v| v.as_unsigned_integer()).map(|v| v as u32), + location: dict.get("Location").and_then(|v| v.as_string()).map(String::from), + date_added: dict.get("Date Added").and_then(|v| v.as_string()).map(String::from), + }) + } + + /// Convert file:// URL to path + fn url_to_path(url_str: &str) -> Option { + Url::parse(url_str).ok() + .filter(|u| u.scheme() == "file") + .and_then(|u| u.to_file_path().ok()) + } + + pub fn count_tracks(&self) -> usize { + self.tracks.len() + } + + /// Import all tracks + pub fn import_all(&self, progress: Option) -> Result { + let total = self.tracks.len(); + let mut result = ImportResult::default(); + + for (idx, track) in self.tracks.iter().enumerate() { + if let Some(ref cb) = progress { + cb(ImportProgress { + current: idx + 1, + total, + current_file: track.name.clone().unwrap_or_default(), + }); + } + + // Skip tracks without location + let Some(ref location) = track.location else { + result.skipped += 1; + continue; + }; + + let Some(path) = Self::url_to_path(location) else { + result.skipped += 1; + result.errors.push(ImportError { + path: location.clone(), + reason: "Invalid file URL".to_string(), + }); + continue; + }; + + match self.import_track(track, &path) { + Ok(_) => result.imported += 1, + Err(e) => { + result.errors.push(ImportError { + path: path.display().to_string(), + reason: e.to_string(), + }); + } + } + } + + Ok(result) + } + + fn import_track(&self, track: &ItunesTrack, path: &Path) -> Result<(), ImportError> { + let audio_meta = AudioMeta { + title: track.name.clone(), + artist: track.artist.clone(), + album: track.album.clone(), + album_artist: track.album_artist.clone(), + genre: track.genre.clone(), + year: track.year, + track: track.track_number, + disc: track.disc_number, + duration_ms: track.total_time, + bitrate: track.bit_rate, + sample_rate: track.sample_rate, + format: AudioFormat::from_extension( + path.extension().and_then(|e| e.to_str()).unwrap_or("") + ), + ..Default::default() + }; + + let virtual_path = VirtualPath::from_metadata(&audio_meta, path); + + let mtime = std::fs::metadata(path) + .map(|m| m.modified().unwrap_or(std::time::UNIX_EPOCH)) + .unwrap_or(std::time::UNIX_EPOCH); + + let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + + self.target_db.upsert_file( + &OriginId::from("itunes-import"), + path, + &virtual_path, + &audio_meta, + mtime, + size, + )?; + + Ok(()) + } +} +``` + +--- + +## Task 4: Library Export (`musicfs-import/src/export.rs`) + +```rust +use csv::Writer; +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct ExportedTrack { + pub virtual_path: String, + pub real_path: String, + pub title: String, + pub artist: String, + pub album: String, + pub album_artist: String, + pub genre: String, + pub year: Option, + pub track: Option, + pub disc: Option, + pub duration_ms: Option, + pub format: String, + pub musicbrainz_id: Option, +} + +pub struct LibraryExporter { + db: Arc, +} + +impl LibraryExporter { + pub fn new(db: Arc) -> Self { + Self { db } + } + + /// Export library to CSV (FR-22.3) + pub fn export_csv(&self, output: &Path) -> Result { + let files = self.db.list_all_files()?; + let mut writer = Writer::from_path(output)?; + + let mut count = 0; + for file in files { + let audio = file.audio.as_ref(); + + writer.serialize(ExportedTrack { + virtual_path: file.virtual_path.as_str().to_string(), + real_path: file.real_path.path.display().to_string(), + title: audio.and_then(|a| a.title.clone()).unwrap_or_default(), + artist: audio.and_then(|a| a.artist.clone()).unwrap_or_default(), + album: audio.and_then(|a| a.album.clone()).unwrap_or_default(), + album_artist: audio.and_then(|a| a.album_artist.clone()).unwrap_or_default(), + genre: audio.and_then(|a| a.genre.clone()).unwrap_or_default(), + year: audio.and_then(|a| a.year), + track: audio.and_then(|a| a.track), + disc: audio.and_then(|a| a.disc), + duration_ms: audio.and_then(|a| a.duration_ms), + format: audio.map(|a| format!("{:?}", a.format)).unwrap_or_default(), + musicbrainz_id: None, // TODO: Include if enriched + })?; + + count += 1; + } + + writer.flush()?; + Ok(count) + } + + /// Export library to JSON + pub fn export_json(&self, output: &Path) -> Result { + let files = self.db.list_all_files()?; + + let tracks: Vec = files.iter() + .map(|file| { + let audio = file.audio.as_ref(); + ExportedTrack { + virtual_path: file.virtual_path.as_str().to_string(), + real_path: file.real_path.path.display().to_string(), + title: audio.and_then(|a| a.title.clone()).unwrap_or_default(), + artist: audio.and_then(|a| a.artist.clone()).unwrap_or_default(), + album: audio.and_then(|a| a.album.clone()).unwrap_or_default(), + album_artist: audio.and_then(|a| a.album_artist.clone()).unwrap_or_default(), + genre: audio.and_then(|a| a.genre.clone()).unwrap_or_default(), + year: audio.and_then(|a| a.year), + track: audio.and_then(|a| a.track), + disc: audio.and_then(|a| a.disc), + duration_ms: audio.and_then(|a| a.duration_ms), + format: audio.map(|a| format!("{:?}", a.format)).unwrap_or_default(), + musicbrainz_id: None, + } + }) + .collect(); + + let json = serde_json::to_string_pretty(&tracks)?; + std::fs::write(output, json)?; + + Ok(tracks.len()) + } + + /// Export to M3U playlist format + pub fn export_m3u(&self, output: &Path, base_path: Option<&Path>) -> Result { + let files = self.db.list_all_files()?; + + let mut content = String::from("#EXTM3U\n"); + + for file in &files { + let duration = file.audio.as_ref() + .and_then(|a| a.duration_ms) + .map(|d| d / 1000) + .unwrap_or(0); + + let title = file.audio.as_ref() + .and_then(|a| a.title.clone()) + .unwrap_or_else(|| file.virtual_path.as_str().to_string()); + + let artist = file.audio.as_ref() + .and_then(|a| a.artist.clone()) + .unwrap_or_default(); + + content.push_str(&format!( + "#EXTINF:{},{} - {}\n", + duration, artist, title + )); + + // Use virtual path relative to base, or absolute real path + let path = if let Some(base) = base_path { + base.join(file.virtual_path.as_str().trim_start_matches('/')) + .display().to_string() + } else { + file.real_path.path.display().to_string() + }; + + content.push_str(&path); + content.push('\n'); + } + + std::fs::write(output, content)?; + Ok(files.len()) + } +} +``` + +--- + +## Task 5: Import CLI Commands (`musicfs-cli/src/import.rs`) + +```rust +#[derive(Subcommand)] +pub enum ImportCommand { + /// Import from beets database + Beets { + /// Path to beets library.db + #[arg(short, long)] + db: PathBuf, + }, + + /// Import from iTunes Library.xml + Itunes { + /// Path to iTunes Library.xml + #[arg(short, long)] + xml: PathBuf, + }, + + /// Export library + Export { + /// Output file path + #[arg(short, long)] + output: PathBuf, + + /// Format: csv, json, m3u + #[arg(short, long, default_value = "csv")] + format: String, + }, +} + +pub async fn handle_import(cmd: ImportCommand, db: Arc) -> Result<()> { + match cmd { + ImportCommand::Beets { db: beets_path } => { + println!("Importing from beets database: {:?}", beets_path); + + let importer = BeetsImporter::new(&beets_path, db)?; + let total = importer.count_items()?; + println!("Found {} items to import", total); + + let pb = ProgressBar::new(total as u64); + let result = importer.import_all(Some(Box::new(move |p| { + pb.set_position(p.current as u64); + })))?; + + println!("\nImport complete:"); + println!(" Imported: {}", result.imported); + println!(" Skipped: {}", result.skipped); + println!(" Errors: {}", result.errors.len()); + } + + ImportCommand::Itunes { xml } => { + println!("Importing from iTunes Library: {:?}", xml); + + let importer = ItunesImporter::from_xml(&xml, db)?; + let total = importer.count_tracks(); + println!("Found {} tracks to import", total); + + let pb = ProgressBar::new(total as u64); + let result = importer.import_all(Some(Box::new(move |p| { + pb.set_position(p.current as u64); + })))?; + + println!("\nImport complete:"); + println!(" Imported: {}", result.imported); + println!(" Skipped: {}", result.skipped); + println!(" Errors: {}", result.errors.len()); + } + + ImportCommand::Export { output, format } => { + let exporter = LibraryExporter::new(db); + + let count = match format.as_str() { + "csv" => exporter.export_csv(&output)?, + "json" => exporter.export_json(&output)?, + "m3u" => exporter.export_m3u(&output, None)?, + _ => return Err(anyhow::anyhow!("Unknown format: {}", format)), + }; + + println!("Exported {} tracks to {:?}", count, output); + } + } + + Ok(()) +} +``` + +--- + +## Tests + +| Test | Type | Validates | +|------|------|-----------| +| `test_beets_import_valid` | Integration | Beets database parsing (FR-22.1) | +| `test_beets_import_missing_fields` | Unit | Handle incomplete metadata | +| `test_itunes_xml_parsing` | Unit | iTunes XML parsing (FR-22.2) | +| `test_itunes_url_to_path` | Unit | file:// URL conversion | +| `test_itunes_import_tracks` | Integration | Full iTunes import | +| `test_export_csv` | Unit | CSV export (FR-22.3) | +| `test_export_json` | Unit | JSON export | +| `test_export_m3u` | Unit | M3U playlist export | +| `test_import_preserves_musicbrainz_ids` | Integration | External IDs preserved | +| `test_import_deduplication` | Integration | No duplicates on re-import | + +--- + +## Exit Criteria + +- [ ] Beets database import works with real beets.db +- [ ] iTunes Library.xml import parses all tracks +- [ ] CSV/JSON/M3U export generates valid files +- [ ] Progress reporting works during import +- [ ] Errors are reported without crashing +- [ ] Import is idempotent (re-import updates, doesn't duplicate) +- [ ] MusicBrainz IDs from beets are preserved + +--- + +## Architecture Alignment + +Per requirements.md: +- FR-22.1: Import from beets database ✓ +- FR-22.2: Import from iTunes/Apple Music ✓ +- FR-22.3: Export library metadata ✓ diff --git a/docs/v2/plans/week-14-extended-formats.md b/docs/v2/plans/week-14-extended-formats.md new file mode 100644 index 0000000..21d12c0 --- /dev/null +++ b/docs/v2/plans/week-14-extended-formats.md @@ -0,0 +1,633 @@ +# Week 14: Extended Formats & Audio Fingerprinting + +**Phase**: 5 - P1 Feature Completion +**Goal**: Audio fingerprint search and audiobook format support +**Requirements**: FR-14.4, FR-24.2 + +--- + +## Deliverables + +| Task | Crate | Files | Requirements | +|------|-------|-------|--------------| +| Fingerprint indexing | musicfs-search | `fingerprint.rs` | FR-14.4 | +| Fingerprint search | musicfs-search | `fingerprint_search.rs` | FR-14.4 | +| M4B audiobook support | musicfs-metadata | `formats/m4b.rs` | FR-24.2 | +| Chapter extraction | musicfs-metadata | `chapters.rs` | FR-24.2 | +| Virtual chapter files | musicfs-fuse | `ops/chapters.rs` | FR-24.2 | + +--- + +## Task 1: Audio Fingerprint Generation + +### 1.1 Add Dependencies + +```toml +# In musicfs-search/Cargo.toml +[dependencies] +chromaprint = "0.6" +symphonia = { version = "0.5", features = ["all"] } +``` + +### 1.2 Fingerprint Generation (`musicfs-search/src/fingerprint.rs`) + +```rust +use chromaprint::{Configuration, Fingerprinter}; +use symphonia::core::audio::SampleBuffer; +use symphonia::core::codecs::DecoderOptions; +use std::path::Path; + +/// Audio fingerprint using Chromaprint algorithm +#[derive(Debug, Clone)] +pub struct AudioFingerprint { + pub raw: Vec, + pub duration_secs: u32, +} + +impl AudioFingerprint { + /// Generate fingerprint from audio file (FR-14.4) + pub fn from_file(path: &Path) -> Result { + let file = std::fs::File::open(path)?; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + let probed = symphonia::default::get_probe() + .format(&Hint::new(), mss, &FormatOptions::default(), &MetadataOptions::default())?; + + let mut format = probed.format; + let track = format.tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .ok_or(FingerprintError::NoAudioTrack)?; + + let sample_rate = track.codec_params.sample_rate + .ok_or(FingerprintError::NoSampleRate)?; + + let mut decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default())?; + + // Chromaprint configuration + let config = Configuration::preset_test1(); + let mut fingerprinter = Fingerprinter::new(&config); + fingerprinter.start(sample_rate, 1)?; // Mono + + let mut samples: Vec = Vec::new(); + let mut duration_samples = 0u64; + + // Decode and collect samples (first 120 seconds max) + let max_samples = sample_rate as u64 * 120; + + loop { + match format.next_packet() { + Ok(packet) => { + let decoded = decoder.decode(&packet)?; + let mut sample_buf = SampleBuffer::::new( + decoded.capacity() as u64, + *decoded.spec(), + ); + sample_buf.copy_interleaved_ref(decoded); + + // Convert to mono if stereo + let mono: Vec = if decoded.spec().channels.count() > 1 { + sample_buf.samples() + .chunks(decoded.spec().channels.count()) + .map(|chunk| (chunk.iter().map(|&s| s as i32).sum::() / chunk.len() as i32) as i16) + .collect() + } else { + sample_buf.samples().to_vec() + }; + + samples.extend(&mono); + duration_samples += mono.len() as u64; + + if duration_samples >= max_samples { + break; + } + } + Err(symphonia::core::errors::Error::IoError(e)) + if e.kind() == std::io::ErrorKind::UnexpectedEof => break, + Err(e) => return Err(e.into()), + } + } + + // Feed samples to fingerprinter + fingerprinter.feed(&samples)?; + fingerprinter.finish()?; + + let raw = fingerprinter.fingerprint().to_vec(); + let duration_secs = (duration_samples / sample_rate as u64) as u32; + + Ok(Self { raw, duration_secs }) + } + + /// Compress fingerprint for storage + pub fn to_bytes(&self) -> Vec { + // Use chromaprint's compressed format + chromaprint::encode_fingerprint(&self.raw, chromaprint::Algorithm::Test1) + } + + /// Decompress fingerprint + pub fn from_bytes(bytes: &[u8]) -> Result { + let (raw, _) = chromaprint::decode_fingerprint(bytes)?; + Ok(Self { raw, duration_secs: 0 }) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum FingerprintError { + #[error("No audio track found")] + NoAudioTrack, + #[error("No sample rate")] + NoSampleRate, + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("Decode error: {0}")] + Decode(String), + #[error("Chromaprint error: {0}")] + Chromaprint(String), +} +``` + +--- + +## Task 2: Fingerprint Search (`musicfs-search/src/fingerprint_search.rs`) + +```rust +use crate::fingerprint::AudioFingerprint; + +/// Fingerprint similarity search using bit-level comparison +pub struct FingerprintIndex { + db: Arc, +} + +impl FingerprintIndex { + pub fn new(db: Arc) -> Self { + Self { db } + } + + /// Index a file's fingerprint + pub fn index(&self, file_id: FileId, fingerprint: &AudioFingerprint) -> Result<(), SearchError> { + let bytes = fingerprint.to_bytes(); + self.db.store_fingerprint(file_id, &bytes, fingerprint.duration_secs)?; + Ok(()) + } + + /// Search by fingerprint similarity (FR-14.4) + pub fn search( + &self, + query: &AudioFingerprint, + threshold: f32, // 0.0-1.0, higher = more similar + limit: usize, + ) -> Result, SearchError> { + let candidates = self.db.get_fingerprints_by_duration( + query.duration_secs.saturating_sub(10), + query.duration_secs + 10, + )?; + + let mut matches: Vec = candidates + .into_iter() + .filter_map(|(file_id, fp_bytes, duration)| { + let fp = AudioFingerprint::from_bytes(&fp_bytes).ok()?; + let similarity = self.compare(&query.raw, &fp.raw); + + if similarity >= threshold { + Some(FingerprintMatch { file_id, similarity, duration }) + } else { + None + } + }) + .collect(); + + // Sort by similarity descending + matches.sort_by(|a, b| b.similarity.partial_cmp(&a.similarity).unwrap()); + matches.truncate(limit); + + Ok(matches) + } + + /// Compare two fingerprints using bit error rate + fn compare(&self, a: &[u32], b: &[u32]) -> f32 { + let len = a.len().min(b.len()); + if len == 0 { + return 0.0; + } + + let mut matching_bits = 0u32; + let mut total_bits = 0u32; + + for i in 0..len { + let xor = a[i] ^ b[i]; + matching_bits += 32 - xor.count_ones(); + total_bits += 32; + } + + matching_bits as f32 / total_bits as f32 + } + + /// Find duplicates by fingerprint + pub fn find_duplicates(&self, threshold: f32) -> Result, SearchError> { + let all_fps = self.db.get_all_fingerprints()?; + let mut groups: Vec = Vec::new(); + let mut processed: HashSet = HashSet::new(); + + for (file_id, fp_bytes, duration) in &all_fps { + if processed.contains(file_id) { + continue; + } + + let fp = AudioFingerprint::from_bytes(fp_bytes)?; + let matches = self.search(&fp, threshold, 100)?; + + if matches.len() > 1 { + let group = DuplicateGroup { + files: matches.iter().map(|m| m.file_id).collect(), + similarity: matches.iter().map(|m| m.similarity).sum::() / matches.len() as f32, + }; + + for m in &matches { + processed.insert(m.file_id); + } + + groups.push(group); + } + } + + Ok(groups) + } +} + +#[derive(Debug)] +pub struct FingerprintMatch { + pub file_id: FileId, + pub similarity: f32, + pub duration: u32, +} + +#[derive(Debug)] +pub struct DuplicateGroup { + pub files: Vec, + pub similarity: f32, +} +``` + +--- + +## Task 3: M4B Audiobook Support (`musicfs-metadata/src/formats/m4b.rs`) + +```rust +use symphonia::core::meta::StandardTagKey; + +/// M4B audiobook metadata (FR-24.2) +#[derive(Debug, Clone, Default)] +pub struct AudiobookMeta { + pub title: Option, + pub author: Option, // Maps to "artist" in audio + pub narrator: Option, + pub series: Option, + pub series_part: Option, + pub description: Option, + pub publisher: Option, + pub year: Option, + pub duration_ms: Option, + pub chapters: Vec, +} + +#[derive(Debug, Clone)] +pub struct Chapter { + pub index: u32, + pub title: String, + pub start_ms: u64, + pub end_ms: u64, +} + +impl Chapter { + pub fn duration_ms(&self) -> u64 { + self.end_ms - self.start_ms + } +} + +pub struct M4bParser; + +impl M4bParser { + /// Parse M4B audiobook with chapters + pub fn parse(&self, path: &Path) -> Result { + let file = std::fs::File::open(path)?; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + let mut hint = Hint::new(); + hint.with_extension("m4b"); + + let probed = symphonia::default::get_probe() + .format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())?; + + let mut meta = AudiobookMeta::default(); + let format = probed.format; + + // Extract metadata + if let Some(metadata) = format.metadata().current() { + for tag in metadata.tags() { + if let Some(std_key) = tag.std_key { + let value = tag.value.to_string(); + match std_key { + StandardTagKey::TrackTitle | StandardTagKey::Album => { + meta.title = Some(value); + } + StandardTagKey::Artist => { + meta.author = Some(value); + } + StandardTagKey::Composer => { + meta.narrator = Some(value); + } + StandardTagKey::Description => { + meta.description = Some(value); + } + StandardTagKey::Label => { + meta.publisher = Some(value); + } + StandardTagKey::Date => { + meta.year = value.chars().take(4).collect::().parse().ok(); + } + _ => {} + } + } + } + } + + // Extract chapters from MP4 chpl atom + meta.chapters = self.extract_chapters(&format)?; + + // Get total duration + if let Some(track) = format.tracks().first() { + if let (Some(n_frames), Some(sample_rate)) = + (track.codec_params.n_frames, track.codec_params.sample_rate) + { + meta.duration_ms = Some((n_frames as u64 * 1000) / sample_rate as u64); + } + } + + Ok(meta) + } + + fn extract_chapters(&self, format: &dyn FormatReader) -> Result, MetadataError> { + let mut chapters = Vec::new(); + + // Symphonia exposes chapters via cues + if let Some(cues) = format.cues() { + for (idx, cue) in cues.iter().enumerate() { + let start_ms = (cue.start_ts as f64 / cue.start_offset_ts.unwrap_or(1) as f64 * 1000.0) as u64; + + // End time is start of next chapter or track end + let end_ms = cues.get(idx + 1) + .map(|next| (next.start_ts as f64 / next.start_offset_ts.unwrap_or(1) as f64 * 1000.0) as u64) + .unwrap_or(u64::MAX); // Will be clamped to duration + + chapters.push(Chapter { + index: idx as u32, + title: cue.tags.iter() + .find(|t| t.std_key == Some(StandardTagKey::TrackTitle)) + .map(|t| t.value.to_string()) + .unwrap_or_else(|| format!("Chapter {}", idx + 1)), + start_ms, + end_ms, + }); + } + } + + Ok(chapters) + } +} +``` + +--- + +## Task 4: Chapter Extraction (`musicfs-metadata/src/chapters.rs`) + +```rust +/// Generic chapter support for various formats +pub trait ChapterSource { + fn chapters(&self) -> &[Chapter]; + fn chapter_at(&self, position_ms: u64) -> Option<&Chapter>; +} + +impl ChapterSource for AudiobookMeta { + fn chapters(&self) -> &[Chapter] { + &self.chapters + } + + fn chapter_at(&self, position_ms: u64) -> Option<&Chapter> { + self.chapters.iter() + .find(|c| position_ms >= c.start_ms && position_ms < c.end_ms) + } +} + +/// Virtual chapter file generator +pub struct ChapterFileGenerator; + +impl ChapterFileGenerator { + /// Generate virtual files for each chapter + /// Example: book.m4b -> book/01 - Introduction.m4b.chapter + pub fn generate_virtual_files(&self, meta: &AudiobookMeta, base_path: &VirtualPath) -> Vec { + meta.chapters.iter() + .map(|chapter| { + let filename = format!( + "{:02} - {}.chapter", + chapter.index + 1, + sanitize_filename(&chapter.title) + ); + + VirtualChapterFile { + path: base_path.join(&filename), + chapter_index: chapter.index, + start_ms: chapter.start_ms, + end_ms: chapter.end_ms, + title: chapter.title.clone(), + } + }) + .collect() + } +} + +#[derive(Debug)] +pub struct VirtualChapterFile { + pub path: VirtualPath, + pub chapter_index: u32, + pub start_ms: u64, + pub end_ms: u64, + pub title: String, +} + +fn sanitize_filename(name: &str) -> String { + name.chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + _ => c, + }) + .collect() +} +``` + +--- + +## Task 5: Virtual Chapter Files (`musicfs-fuse/src/ops/chapters.rs`) + +```rust +use crate::VirtualFs; + +impl VirtualFs { + /// Handle reads from virtual chapter files + /// These return a byte-range reference to the parent M4B file + pub async fn read_chapter( + &self, + chapter_file: &VirtualChapterFile, + offset: u64, + size: usize, + ) -> Result, FuseError> { + // Get the parent audiobook file + let parent = self.get_parent_audiobook(&chapter_file.path)?; + + // Calculate byte range for this chapter + // This requires knowing the audio bitrate to convert ms -> bytes + let meta = self.get_audiobook_meta(&parent)?; + let bitrate_bps = meta.bitrate.unwrap_or(128_000); // Default 128kbps + let bytes_per_ms = bitrate_bps / 8 / 1000; + + let chapter_start_bytes = chapter_file.start_ms * bytes_per_ms; + let chapter_end_bytes = chapter_file.end_ms * bytes_per_ms; + + // Adjust offset to be within chapter + let actual_offset = chapter_start_bytes + offset; + let max_size = (chapter_end_bytes - actual_offset) as usize; + let read_size = size.min(max_size); + + // Read from the actual file + self.read_file(&parent, actual_offset, read_size).await + } + + /// List chapter files for an audiobook + pub fn list_chapters(&self, audiobook_path: &VirtualPath) -> Result, FuseError> { + let meta = self.get_audiobook_meta(audiobook_path)?; + let generator = ChapterFileGenerator; + + let chapters = generator.generate_virtual_files(&meta, audiobook_path); + + Ok(chapters.into_iter() + .map(|c| DirEntry { + name: c.path.filename().to_string(), + kind: FileType::RegularFile, + size: self.estimate_chapter_size(&c), + }) + .collect()) + } + + fn estimate_chapter_size(&self, chapter: &VirtualChapterFile) -> u64 { + // Estimate based on duration and typical bitrate + let duration_secs = (chapter.end_ms - chapter.start_ms) / 1000; + duration_secs * 128_000 / 8 // 128kbps assumption + } +} +``` + +--- + +## Task 6: Fingerprint Search Virtual Directory + +```rust +/// Virtual directory for fingerprint search +/// /.search/fingerprint/{base64_fingerprint} -> matching files + +impl SearchOps { + pub async fn search_by_fingerprint( + &self, + fingerprint_path: &str, + ) -> Result, SearchError> { + // Path format: /.search/fingerprint/{base64_encoded_fingerprint} + let fp_bytes = base64::decode(fingerprint_path) + .map_err(|_| SearchError::InvalidQuery)?; + + let fingerprint = AudioFingerprint::from_bytes(&fp_bytes)?; + let matches = self.fingerprint_index.search(&fingerprint, 0.8, 20)?; + + let mut results = Vec::new(); + for m in matches { + if let Some(file) = self.db.get_file_by_id(m.file_id)? { + results.push(SearchResult { + path: file.virtual_path, + score: m.similarity, + snippet: format!("Similarity: {:.1}%", m.similarity * 100.0), + }); + } + } + + Ok(results) + } +} +``` + +--- + +## Database Schema Additions + +```sql +-- Fingerprint storage +CREATE TABLE IF NOT EXISTS fingerprints ( + file_id INTEGER PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE, + fingerprint BLOB NOT NULL, -- Compressed chromaprint + duration INTEGER NOT NULL, -- Duration in seconds + indexed_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_fingerprints_duration ON fingerprints(duration); + +-- Audiobook chapters +CREATE TABLE IF NOT EXISTS chapters ( + id INTEGER PRIMARY KEY, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + chapter_idx INTEGER NOT NULL, + title TEXT NOT NULL, + start_ms INTEGER NOT NULL, + end_ms INTEGER NOT NULL, + UNIQUE(file_id, chapter_idx) +); + +CREATE INDEX IF NOT EXISTS idx_chapters_file ON chapters(file_id); +``` + +--- + +## Tests + +| Test | Type | Validates | +|------|------|-----------| +| `test_fingerprint_generation` | Unit | Chromaprint from audio (FR-14.4) | +| `test_fingerprint_similarity` | Unit | Bit comparison algorithm | +| `test_fingerprint_search` | Integration | Find similar tracks | +| `test_fingerprint_duplicates` | Integration | Detect duplicate audio | +| `test_m4b_parsing` | Unit | M4B metadata extraction (FR-24.2) | +| `test_chapter_extraction` | Unit | Chapter list from M4B | +| `test_virtual_chapter_files` | Integration | Chapter files appear in listing | +| `test_chapter_read` | Integration | Read chapter content | +| `test_audiobook_navigation` | E2E | Browse audiobook chapters | + +--- + +## Exit Criteria + +- [ ] Audio fingerprints generated from audio files +- [ ] Fingerprint similarity search finds matching tracks +- [ ] Duplicate detection works across library +- [ ] M4B files parsed with full metadata +- [ ] Chapters extracted and stored +- [ ] Virtual chapter files appear in directory listing +- [ ] Chapter files are readable (return correct byte range) +- [ ] All tests pass + +--- + +## Architecture Alignment + +Per requirements.md: +- FR-14.4: Audio fingerprint search ✓ +- FR-24.2: Audiobook formats with chapters ✓ + +Per architecture.md section 4.3.4: +- FormatPlugin trait for M4B support ✓ +- Chapter extraction via symphonia ✓ diff --git a/musicfs/Cargo.lock b/musicfs/Cargo.lock index c24d59a..1d42d9c 100644 --- a/musicfs/Cargo.lock +++ b/musicfs/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -100,6 +109,21 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object 0.37.3", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + [[package]] name = "arc-swap" version = "1.9.1" @@ -211,6 +235,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -232,6 +265,15 @@ dependencies = [ "crunchy", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -321,7 +363,7 @@ version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn", @@ -345,12 +387,159 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cranelift-bforest" +version = "0.106.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b57d4f3ffc28bbd6ef1ca7b50b20126717232f97487efe027d135d9d87eb29c" +dependencies = [ + "cranelift-entity", +] + +[[package]] +name = "cranelift-codegen" +version = "0.106.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1f7d0ac7fd53f2c29db3ff9a063f6ff5a8be2abaa8f6942aceb6e1521e70df7" +dependencies = [ + "bumpalo", + "cranelift-bforest", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.14.5", + "log", + "regalloc2", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.106.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40bf21460a600178956cb7fd900a7408c6587fbb988a8063f7215361801a1da" +dependencies = [ + "cranelift-codegen-shared", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.106.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d792ecc1243b7ebec4a7f77d9ed428ef27456eeb1f8c780587a6f5c38841be19" + +[[package]] +name = "cranelift-control" +version = "0.106.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea2808043df964b73ad7582e09afbbe06a31f3fb9db834d53e74b4e16facaeb" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.106.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1930946836da6f514da87625cd1a0331f3908e0de454628c24a0b97b130c4d4" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-frontend" +version = "0.106.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5482a5fcdf98f2f31b21093643bdcfe9030866b8be6481117022e7f52baa0f2b" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.106.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6e1869b6053383bdb356900e42e33555b4c9ebee05699469b7c53cdafc82ea" + +[[package]] +name = "cranelift-native" +version = "0.106.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a91446e8045f1c4bc164b7bba68e2419c623904580d4b730877a663c6da38964" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-wasm" +version = "0.106.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b17979b862d3b0d52de6ae3294ffe4d86c36027b56ad0443a7c8c8f921d14f" +dependencies = [ + "cranelift-codegen", + "cranelift-entity", + "cranelift-frontend", + "itertools", + "log", + "smallvec", + "wasmparser 0.201.0", + "wasmtime-types", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -400,6 +589,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -413,6 +612,15 @@ dependencies = [ "parking_lot_core 0.9.12", ] +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + [[package]] name = "deranged" version = "0.5.8" @@ -423,6 +631,27 @@ dependencies = [ "serde_core", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + [[package]] name = "dirs" version = "5.0.1" @@ -444,6 +673,28 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -570,6 +821,30 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fs2" version = "0.4.3" @@ -662,6 +937,29 @@ dependencies = [ "byteorder", ] +[[package]] +name = "fxprof-processed-profile" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27d12c0aed7f1e24276a241aadc4cb8ea9f83000f34bc062b7cc2d51e3b0fabd" +dependencies = [ + "bitflags 2.11.1", + "debugid", + "fxhash", + "serde", + "serde_json", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -698,6 +996,17 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +dependencies = [ + "fallible-iterator", + "indexmap 2.14.0", + "stable_deref_trait", +] + [[package]] name = "h2" version = "0.3.27" @@ -723,6 +1032,15 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -758,6 +1076,12 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -776,6 +1100,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "htmlescape" version = "0.3.1" @@ -852,6 +1185,19 @@ dependencies = [ "tokio-io-timeout", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -876,12 +1222,115 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "image" version = "0.24.9" @@ -950,6 +1399,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -971,6 +1426,26 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "ittapi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" +dependencies = [ + "anyhow", + "ittapi-sys", + "log", +] + +[[package]] +name = "ittapi-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" +dependencies = [ + "cc", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -1025,6 +1500,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cc46bac87ef8093eed6f272babb833b6443374399985ac8ed28471ee0918545" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1043,6 +1524,16 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -1081,6 +1572,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -1111,6 +1608,15 @@ version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" +[[package]] +name = "mach" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b823e83b2affd8f40a9ee8c29dbc56404c1e34cd2710921f2801e2cf29527afa" +dependencies = [ + "libc", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1142,6 +1648,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix 1.1.4", +] + [[package]] name = "memmap2" version = "0.9.10" @@ -1151,6 +1666,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -1319,9 +1843,16 @@ dependencies = [ name = "musicfs-grpc" version = "0.1.0" dependencies = [ + "chrono", + "hex", + "hmac", "musicfs-core", "musicfs-search", "prost", + "reqwest", + "serde", + "serde_json", + "sha2", "tempfile", "tokio", "tokio-stream", @@ -1358,6 +1889,19 @@ dependencies = [ [[package]] name = "musicfs-plugins" version = "0.1.0" +dependencies = [ + "async-trait", + "libloading", + "musicfs-core", + "semver", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "tracing", + "wasmtime", +] [[package]] name = "musicfs-search" @@ -1394,6 +1938,23 @@ dependencies = [ "xxhash-rust", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nom" version = "7.1.3" @@ -1458,6 +2019,27 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "crc32fast", + "hashbrown 0.14.5", + "indexmap 2.14.0", + "memchr", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1476,6 +2058,49 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" +[[package]] +name = "openssl" +version = "0.10.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -1549,6 +2174,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1616,6 +2247,15 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1667,7 +2307,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes", - "heck", + "heck 0.5.0", "itertools", "log", "multimap", @@ -1703,6 +2343,16 @@ dependencies = [ "prost", ] +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "quote" version = "1.0.45" @@ -1813,6 +2463,19 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regalloc2" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad156d539c879b7a24a363a2016d77961786e71f48f2e2fc8302a92abd2429a6" +dependencies = [ + "hashbrown 0.13.2", + "log", + "rustc-hash", + "slice-group-by", + "smallvec", +] + [[package]] name = "regex" version = "1.12.3" @@ -1842,6 +2505,46 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "rmp" version = "0.8.15" @@ -1885,6 +2588,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -1917,12 +2626,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -1932,12 +2656,44 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -1996,6 +2752,29 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2058,6 +2837,12 @@ dependencies = [ "parking_lot 0.11.2", ] +[[package]] +name = "slice-group-by" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826167069c09b99d56f31e9ae5c99049e932a98c9dc2dac47645b08dbbf76ba7" + [[package]] name = "smallvec" version = "1.15.1" @@ -2084,6 +2869,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sptr" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2096,6 +2887,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "symphonia" version = "0.5.5" @@ -2246,6 +3043,38 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tagptr" version = "0.2.0" @@ -2393,6 +3222,12 @@ dependencies = [ "serde", ] +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "tempfile" version = "3.27.0" @@ -2466,6 +3301,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.52.3" @@ -2504,6 +3349,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -2708,24 +3563,54 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "utf8-ranges" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2818,6 +3703,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.121" @@ -2850,6 +3745,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.201.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9c7d2731df60006819b013f64ccc2019691deccf6e11a1804bc850cd6748f1a" +dependencies = [ + "leb128", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -2857,7 +3761,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac92cf547bc18d27ecc521015c08c353b4f18b84ab388bb6d1b6b682c620d9b6" +dependencies = [ + "leb128fmt", + "wasmparser 0.248.0", ] [[package]] @@ -2868,8 +3782,19 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasmparser" +version = "0.201.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e5df6dba6c0d7fafc63a450f1738451ed7a0b52295d83e868218fa286bf708" +dependencies = [ + "bitflags 2.11.1", + "indexmap 2.14.0", + "semver", ] [[package]] @@ -2884,6 +3809,345 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmparser" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a" +dependencies = [ + "bitflags 2.11.1", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.201.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a67e66da702706ba08729a78e3c0079085f6bfcb1a62e4799e97bbf728c2c265" +dependencies = [ + "anyhow", + "wasmparser 0.201.0", +] + +[[package]] +name = "wasmtime" +version = "19.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e300c0e3f19dc9064e3b17ce661088646c70dbdde36aab46470ed68ba58db7d" +dependencies = [ + "addr2line", + "anyhow", + "async-trait", + "bincode", + "bumpalo", + "cfg-if", + "encoding_rs", + "fxprof-processed-profile", + "gimli", + "indexmap 2.14.0", + "ittapi", + "libc", + "log", + "object 0.32.2", + "once_cell", + "paste", + "rayon", + "rustix 0.38.44", + "semver", + "serde", + "serde_derive", + "serde_json", + "target-lexicon", + "wasm-encoder 0.201.0", + "wasmparser 0.201.0", + "wasmtime-cache", + "wasmtime-component-macro", + "wasmtime-component-util", + "wasmtime-cranelift", + "wasmtime-environ", + "wasmtime-fiber", + "wasmtime-jit-debug", + "wasmtime-jit-icache-coherence", + "wasmtime-runtime", + "wasmtime-slab", + "wasmtime-winch", + "wat", + "windows-sys 0.52.0", +] + +[[package]] +name = "wasmtime-asm-macros" +version = "19.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "110aa598e02a136fb095ca70fa96367fc16bab55256a131e66f9b58f16c73daf" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "wasmtime-cache" +version = "19.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e660537b0ac2fc76917fb0cc9d403d2448b6983a84e59c51f7fea7b7dae024" +dependencies = [ + "anyhow", + "base64 0.21.7", + "bincode", + "directories-next", + "log", + "rustix 0.38.44", + "serde", + "serde_derive", + "sha2", + "toml", + "windows-sys 0.52.0", + "zstd", +] + +[[package]] +name = "wasmtime-component-macro" +version = "19.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091f32ce586251ac4d07019388fb665b010d9518ffe47be1ddbabb162eed6007" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn", + "wasmtime-component-util", + "wasmtime-wit-bindgen", + "wit-parser 0.201.0", +] + +[[package]] +name = "wasmtime-component-util" +version = "19.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd17dc1ebc0b28fd24b6b9d07638f55b82ae908918ff08fd221f8b0fefa9125" + +[[package]] +name = "wasmtime-cranelift" +version = "19.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e923262451a4b5b39fe02f69f1338d56356db470e289ea1887346b9c7f592738" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "cranelift-wasm", + "gimli", + "log", + "object 0.32.2", + "target-lexicon", + "thiserror", + "wasmparser 0.201.0", + "wasmtime-cranelift-shared", + "wasmtime-environ", + "wasmtime-versioned-export-macros", +] + +[[package]] +name = "wasmtime-cranelift-shared" +version = "19.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "508898cbbea0df81a5d29cfc1c7c72431a1bc4c9e89fd9514b4c868474c05c7a" +dependencies = [ + "anyhow", + "cranelift-codegen", + "cranelift-control", + "cranelift-native", + "gimli", + "object 0.32.2", + "target-lexicon", + "wasmtime-environ", +] + +[[package]] +name = "wasmtime-environ" +version = "19.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7e3f2aa72dbb64c19708646e1ff97650f34e254598b82bad5578ea9c80edd30" +dependencies = [ + "anyhow", + "bincode", + "cpp_demangle", + "cranelift-entity", + "gimli", + "indexmap 2.14.0", + "log", + "object 0.32.2", + "rustc-demangle", + "serde", + "serde_derive", + "target-lexicon", + "thiserror", + "wasm-encoder 0.201.0", + "wasmparser 0.201.0", + "wasmprinter", + "wasmtime-component-util", + "wasmtime-types", +] + +[[package]] +name = "wasmtime-fiber" +version = "19.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9235b643527bcbac808216ed342e1fba324c95f14a62762acfa6f2e6ca5edbd6" +dependencies = [ + "anyhow", + "cc", + "cfg-if", + "rustix 0.38.44", + "wasmtime-asm-macros", + "wasmtime-versioned-export-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "wasmtime-jit-debug" +version = "19.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92de34217bf7f0464262adf391a9950eba440f9dfc7d3b0e3209302875c6f65f" +dependencies = [ + "object 0.32.2", + "once_cell", + "rustix 0.38.44", + "wasmtime-versioned-export-macros", +] + +[[package]] +name = "wasmtime-jit-icache-coherence" +version = "19.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c22ca2ef4d87b23d400660373453e274b2251bc2d674e3102497f690135e04b0" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "wasmtime-runtime" +version = "19.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1806ee242ca4fd183309b7406e4e83ae7739b7569f395d56700de7c7ef9f5eb8" +dependencies = [ + "anyhow", + "cc", + "cfg-if", + "encoding_rs", + "indexmap 2.14.0", + "libc", + "log", + "mach", + "memfd", + "memoffset", + "paste", + "psm", + "rustix 0.38.44", + "sptr", + "wasm-encoder 0.201.0", + "wasmtime-asm-macros", + "wasmtime-environ", + "wasmtime-fiber", + "wasmtime-jit-debug", + "wasmtime-versioned-export-macros", + "wasmtime-wmemcheck", + "windows-sys 0.52.0", +] + +[[package]] +name = "wasmtime-slab" +version = "19.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c58bef9ce877fd06acb58f08d003af17cb05cc51225b455e999fbad8e584c0" + +[[package]] +name = "wasmtime-types" +version = "19.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cebe297aa063136d9d2e5b347c1528868aa43c2c8d0e1eb0eec144567e38fe0f" +dependencies = [ + "cranelift-entity", + "serde", + "serde_derive", + "thiserror", + "wasmparser 0.201.0", +] + +[[package]] +name = "wasmtime-versioned-export-macros" +version = "19.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffaafa5c12355b1a9ee068e9295d50c4ca0a400c721950cdae4f5b54391a2da5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasmtime-winch" +version = "19.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d618b4e90d3f259b1b77411ce573c9f74aade561957102132e169918aabdc863" +dependencies = [ + "anyhow", + "cranelift-codegen", + "gimli", + "object 0.32.2", + "target-lexicon", + "wasmparser 0.201.0", + "wasmtime-cranelift-shared", + "wasmtime-environ", + "winch-codegen", +] + +[[package]] +name = "wasmtime-wit-bindgen" +version = "19.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c7a253c8505edd7493603e548bff3af937b0b7dbf2b498bd5ff2131b651af72" +dependencies = [ + "anyhow", + "heck 0.4.1", + "indexmap 2.14.0", + "wit-parser 0.201.0", +] + +[[package]] +name = "wasmtime-wmemcheck" +version = "19.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9a8c62e9df8322b2166d2a6f096fbec195ddb093748fd74170dcf25ef596769" + +[[package]] +name = "wast" +version = "248.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acc54622ed5a5cddafcdf152043f9d4aed54d4a653d686b7dfe874809fca99d7" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder 0.248.0", +] + +[[package]] +name = "wat" +version = "1.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75cd9e510603909748e6ebab89f27cd04472c1d9d85a3c88a7a6fc51a1a7934" +dependencies = [ + "wast", +] + [[package]] name = "web-sys" version = "0.3.98" @@ -2925,6 +4189,22 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "winch-codegen" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d15869abc9e3bb29c017c003dbe007a08e9910e8ff9023a962aa13c1b2ee6af" +dependencies = [ + "anyhow", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon", + "wasmparser 0.201.0", + "wasmtime-environ", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -3150,6 +4430,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -3172,8 +4462,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck", - "wit-parser", + "heck 0.5.0", + "wit-parser 0.244.0", ] [[package]] @@ -3183,7 +4473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "indexmap 2.14.0", "prettyplease", "syn", @@ -3220,10 +4510,28 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder", + "wasm-encoder 0.244.0", "wasm-metadata", - "wasmparser", - "wit-parser", + "wasmparser 0.244.0", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-parser" +version = "0.201.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "196d3ecfc4b759a8573bf86a9b3f8996b304b3732e4c7de81655f875f6efdca6" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.201.0", ] [[package]] @@ -3241,15 +4549,44 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser", + "wasmparser 0.244.0", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "xxhash-rust" version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.7.35" @@ -3291,6 +4628,60 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/musicfs/crates/musicfs-cli/src/main.rs b/musicfs/crates/musicfs-cli/src/main.rs index 7471200..2e5ded5 100644 --- a/musicfs/crates/musicfs-cli/src/main.rs +++ b/musicfs/crates/musicfs-cli/src/main.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result}; -use clap::Parser; +use clap::{Parser, Subcommand}; use musicfs_cache::TreeBuilder; use musicfs_cas::{CasConfig, CasStore, ContentFetcher, FileReader}; use musicfs_core::{FileId, FileMeta, OriginId, RealPath, VirtualPath}; @@ -15,33 +15,111 @@ use tracing::{debug, info}; #[command(name = "musicfs")] #[command(about = "Virtual FUSE filesystem for music libraries")] struct Cli { - #[arg(help = "Mount point for the virtual filesystem")] - mountpoint: PathBuf, - - #[arg(short, long, help = "Source music directory (origin)")] - origin: PathBuf, - - #[arg(short, long, help = "Cache directory for CAS chunks")] - cache_dir: Option, - - #[arg(short, long, default_value = "info", help = "Log level (debug, info, warn, error)")] + #[arg(short, long, default_value = "info", help = "Log level")] log_level: String, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + Mount { + #[arg(short, long, help = "Config file path")] + config: Option, + #[arg(help = "Mount point")] + mountpoint: PathBuf, + #[arg(short, long, help = "Source music directory")] + origin: Option, + #[arg(short = 'd', long, help = "Cache directory")] + cache_dir: Option, + }, + Status, + Cache { + #[command(subcommand)] + command: CacheCommands, + }, + Search { + query: String, + #[arg(short, long, default_value = "100")] + limit: u32, + }, + Origin { + #[command(subcommand)] + command: OriginCommands, + }, + Events { + #[arg(short, long, help = "Filter by event type")] + r#type: Option, + }, + Shutdown { + #[arg(short, long, default_value = "true")] + graceful: bool, + #[arg(short, long, default_value = "30")] + timeout: u32, + }, +} + +#[derive(Subcommand)] +enum CacheCommands { + Stats, + Clear { + #[arg(help = "Origin to clear cache for")] + origin: Option, + }, + Prefetch { + #[arg(help = "Paths to prefetch")] + paths: Vec, + }, +} + +#[derive(Subcommand)] +enum OriginCommands { + List, + Health { + origin_id: String, + }, + Rescan { + origin_id: String, + }, } fn main() -> Result<()> { let cli = Cli::parse(); - init_logging(&cli.log_level); + match cli.command { + Commands::Mount { + config: _, + mountpoint, + origin, + cache_dir, + } => run_mount(mountpoint, origin, cache_dir), + Commands::Status => run_status(), + Commands::Cache { command } => run_cache(command), + Commands::Search { query, limit } => run_search(&query, limit), + Commands::Origin { command } => run_origin(command), + Commands::Events { r#type } => run_events(r#type), + Commands::Shutdown { graceful, timeout } => run_shutdown(graceful, timeout), + } +} + +fn run_mount( + mountpoint: PathBuf, + origin_path: Option, + cache_dir: Option, +) -> Result<()> { + let origin_path = origin_path.context("--origin is required for mount")?; + let runtime = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?; let handle = runtime.handle().clone(); let (tree, reader) = runtime.block_on(async { info!("MusicFS starting..."); - info!("Origin: {:?}", cli.origin); - info!("Mountpoint: {:?}", cli.mountpoint); + info!("Origin: {:?}", origin_path); + info!("Mountpoint: {:?}", mountpoint); - let cache_dir = cli.cache_dir.unwrap_or_else(|| { + let cache_dir = cache_dir.unwrap_or_else(|| { dirs::cache_dir() .unwrap_or_else(|| PathBuf::from("/tmp")) .join("musicfs") @@ -49,24 +127,28 @@ fn main() -> Result<()> { info!("Cache directory: {:?}", cache_dir); std::fs::create_dir_all(&cache_dir).context("Failed to create cache directory")?; - std::fs::create_dir_all(&cli.mountpoint).context("Failed to create mountpoint")?; + std::fs::create_dir_all(&mountpoint).context("Failed to create mountpoint")?; let cas_config = CasConfig { chunks_dir: cache_dir.join("chunks"), ..Default::default() }; - let store = Arc::new(CasStore::open(cas_config).await.context("Failed to open CAS store")?); + let store = Arc::new( + CasStore::open(cas_config) + .await + .context("Failed to open CAS store")?, + ); info!("CAS store initialized"); let origin_id = OriginId::from("local"); - let origin = Arc::new(LocalOrigin::new(origin_id.clone(), cli.origin.clone())); + let origin = Arc::new(LocalOrigin::new(origin_id.clone(), origin_path.clone())); info!("Origin registered: {}", origin.display_name()); let fetcher = Arc::new(ContentFetcher::new(store.clone())); fetcher.register_origin(origin); info!("Scanning music files..."); - let files = scan_music_files(&cli.origin, &origin_id).await?; + let files = scan_music_files(&origin_path, &origin_id).await?; info!("Found {} music files", files.len()); let mut builder = TreeBuilder::new(); @@ -84,19 +166,84 @@ fn main() -> Result<()> { let fs = MusicFs::with_reader(tree, reader, handle); - info!("Mounting filesystem at {:?}", cli.mountpoint); + info!("Mounting filesystem at {:?}", mountpoint); info!("Press Ctrl+C to unmount"); - fs.mount(&cli.mountpoint).context("Failed to mount filesystem")?; + fs.mount(&mountpoint) + .context("Failed to mount filesystem")?; Ok(()) } +fn run_status() -> Result<()> { + println!("Status: Not connected to daemon"); + println!("Hint: gRPC client integration pending"); + Ok(()) +} + +fn run_cache(command: CacheCommands) -> Result<()> { + match command { + CacheCommands::Stats => { + println!("Cache stats: gRPC client integration pending"); + } + CacheCommands::Clear { origin } => { + println!( + "Clearing cache for: {}", + origin.as_deref().unwrap_or("all") + ); + println!("gRPC client integration pending"); + } + CacheCommands::Prefetch { paths } => { + println!("Prefetching {} paths", paths.len()); + println!("gRPC client integration pending"); + } + } + Ok(()) +} + +fn run_search(query: &str, limit: u32) -> Result<()> { + println!("Searching for: {} (limit: {})", query, limit); + println!("gRPC client integration pending"); + Ok(()) +} + +fn run_origin(command: OriginCommands) -> Result<()> { + match command { + OriginCommands::List => { + println!("Origins: gRPC client integration pending"); + } + OriginCommands::Health { origin_id } => { + println!("Health for {}: gRPC client integration pending", origin_id); + } + OriginCommands::Rescan { origin_id } => { + println!("Rescanning {}: gRPC client integration pending", origin_id); + } + } + Ok(()) +} + +fn run_events(event_type: Option) -> Result<()> { + println!( + "Subscribing to events: {}", + event_type.as_deref().unwrap_or("all") + ); + println!("gRPC client integration pending"); + Ok(()) +} + +fn run_shutdown(graceful: bool, timeout: u32) -> Result<()> { + println!( + "Shutdown requested (graceful: {}, timeout: {}s)", + graceful, timeout + ); + println!("gRPC client integration pending"); + Ok(()) +} + fn init_logging(level: &str) { use tracing_subscriber::{fmt, prelude::*, EnvFilter}; - let filter = EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new(level)); + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level)); tracing_subscriber::registry() .with(fmt::layer()) @@ -109,7 +256,15 @@ async fn scan_music_files(dir: &Path, origin_id: &OriginId) -> Result {:?}", file_meta.real_path.path, file_meta.virtual_path); + debug!( + "Found: {:?} -> {:?}", + file_meta.real_path.path, file_meta.virtual_path + ); files.push(file_meta); *id_counter += 1; } @@ -167,7 +328,10 @@ async fn scan_dir_recursive( fn is_audio_file(path: &Path) -> bool { matches!( - path.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()).as_deref(), + path.extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()) + .as_deref(), Some("flac" | "mp3" | "ogg" | "wav" | "m4a" | "aac" | "opus") ) } @@ -176,11 +340,22 @@ fn build_virtual_path(path: &Path, audio: Option<&musicfs_core::AudioMeta>) -> V if let Some(meta) = audio { let artist = meta.artist.as_deref().unwrap_or("Unknown Artist"); let album = meta.album.as_deref().unwrap_or("Unknown Album"); - let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("track"); + let filename = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("track"); - VirtualPath::new(&format!("/{}/{}/{}", sanitize(artist), sanitize(album), filename)) + VirtualPath::new(&format!( + "/{}/{}/{}", + sanitize(artist), + sanitize(album), + filename + )) } else { - let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("unknown"); + let filename = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown"); VirtualPath::new(&format!("/Unknown Artist/Unknown Album/{}", filename)) } } diff --git a/musicfs/crates/musicfs-core/src/lib.rs b/musicfs/crates/musicfs-core/src/lib.rs index d496d99..ed05ba6 100644 --- a/musicfs/crates/musicfs-core/src/lib.rs +++ b/musicfs/crates/musicfs-core/src/lib.rs @@ -2,6 +2,7 @@ pub mod config; pub mod credentials; pub mod error; pub mod events; +pub mod metrics; pub mod resolver; pub mod types; @@ -9,5 +10,6 @@ pub use config::{CacheConfig, Config, ConfigError, HealthConfig, OriginConfig, O pub use credentials::{Credential, CredentialConfig, CredentialError, CredentialStore}; pub use error::{Error, Result}; pub use events::{Event, EventBus}; +pub use metrics::{CacheMetrics, FuseOpsMetrics, Metrics, OriginsMetrics}; pub use resolver::{PathResolver, PathTemplate}; pub use types::*; diff --git a/musicfs/crates/musicfs-core/src/metrics.rs b/musicfs/crates/musicfs-core/src/metrics.rs new file mode 100644 index 0000000..658a3a4 --- /dev/null +++ b/musicfs/crates/musicfs-core/src/metrics.rs @@ -0,0 +1,322 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::RwLock; +use std::time::Instant; + +#[derive(Default)] +pub struct Metrics { + pub fuse_ops: FuseOpsMetrics, + pub fuse_latency: FuseLatencyMetrics, + pub cache: CacheMetrics, + pub origins: OriginsMetrics, + pub origin_health: OriginHealthMetrics, + start_time: Option, +} + +impl Metrics { + pub fn new() -> Self { + Self { + start_time: Some(Instant::now()), + ..Default::default() + } + } + + pub fn uptime_secs(&self) -> u64 { + self.start_time + .map(|t| t.elapsed().as_secs()) + .unwrap_or(0) + } + + pub fn to_prometheus(&self) -> String { + let mut output = String::new(); + + output.push_str(&format!( + "# HELP musicfs_fuse_ops_total Total FUSE operations\n\ + # TYPE musicfs_fuse_ops_total counter\n\ + musicfs_fuse_ops_total{{op=\"lookup\"}} {}\n\ + musicfs_fuse_ops_total{{op=\"getattr\"}} {}\n\ + musicfs_fuse_ops_total{{op=\"read\"}} {}\n\ + musicfs_fuse_ops_total{{op=\"readdir\"}} {}\n\ + musicfs_fuse_ops_total{{op=\"open\"}} {}\n", + self.fuse_ops.lookup.load(Ordering::Relaxed), + self.fuse_ops.getattr.load(Ordering::Relaxed), + self.fuse_ops.read.load(Ordering::Relaxed), + self.fuse_ops.readdir.load(Ordering::Relaxed), + self.fuse_ops.open.load(Ordering::Relaxed), + )); + + for (op, histogram) in self.fuse_latency.histograms.read().unwrap().iter() { + let quantiles = histogram.quantiles(); + output.push_str(&format!( + "# HELP musicfs_fuse_latency_seconds FUSE operation latency\n\ + # TYPE musicfs_fuse_latency_seconds summary\n\ + musicfs_fuse_latency_seconds{{op=\"{}\",quantile=\"0.5\"}} {:.6}\n\ + musicfs_fuse_latency_seconds{{op=\"{}\",quantile=\"0.95\"}} {:.6}\n\ + musicfs_fuse_latency_seconds{{op=\"{}\",quantile=\"0.99\"}} {:.6}\n\ + musicfs_fuse_latency_seconds_sum{{op=\"{}\"}} {:.6}\n\ + musicfs_fuse_latency_seconds_count{{op=\"{}\"}} {}\n", + op, quantiles.p50, + op, quantiles.p95, + op, quantiles.p99, + op, histogram.sum_secs(), + op, histogram.count(), + )); + } + + output.push_str(&format!( + "# HELP musicfs_cache_hits_total Cache hits\n\ + # TYPE musicfs_cache_hits_total counter\n\ + musicfs_cache_hits_total {}\n", + self.cache.hits.load(Ordering::Relaxed), + )); + + output.push_str(&format!( + "# HELP musicfs_cache_misses_total Cache misses\n\ + # TYPE musicfs_cache_misses_total counter\n\ + musicfs_cache_misses_total {}\n", + self.cache.misses.load(Ordering::Relaxed), + )); + + output.push_str(&format!( + "# HELP musicfs_cache_size_bytes Current cache size in bytes\n\ + # TYPE musicfs_cache_size_bytes gauge\n\ + musicfs_cache_size_bytes {}\n", + self.cache.size_bytes.load(Ordering::Relaxed), + )); + + output.push_str(&format!( + "# HELP musicfs_cache_chunks_total Number of cached chunks\n\ + # TYPE musicfs_cache_chunks_total gauge\n\ + musicfs_cache_chunks_total {}\n", + self.cache.chunk_count.load(Ordering::Relaxed), + )); + + output.push_str( + "# HELP musicfs_origin_health Origin health status (1=healthy, 0=unhealthy)\n\ + # TYPE musicfs_origin_health gauge\n", + ); + for (origin_id, healthy) in self.origin_health.status.read().unwrap().iter() { + output.push_str(&format!( + "musicfs_origin_health{{origin=\"{}\"}} {}\n", + origin_id, + if *healthy { 1 } else { 0 } + )); + } + + output.push_str(&format!( + "# HELP musicfs_uptime_seconds Daemon uptime in seconds\n\ + # TYPE musicfs_uptime_seconds gauge\n\ + musicfs_uptime_seconds {}\n", + self.uptime_secs(), + )); + + output + } + + pub fn hit_ratio(&self) -> f64 { + let hits = self.cache.hits.load(Ordering::Relaxed) as f64; + let misses = self.cache.misses.load(Ordering::Relaxed) as f64; + let total = hits + misses; + + if total == 0.0 { + 0.0 + } else { + hits / total + } + } +} + +#[derive(Default)] +pub struct FuseOpsMetrics { + pub lookup: AtomicU64, + pub getattr: AtomicU64, + pub read: AtomicU64, + pub readdir: AtomicU64, + pub open: AtomicU64, +} + +impl FuseOpsMetrics { + pub fn record_lookup(&self) { + self.lookup.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_getattr(&self) { + self.getattr.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_read(&self) { + self.read.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_readdir(&self) { + self.readdir.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_open(&self) { + self.open.fetch_add(1, Ordering::Relaxed); + } +} + +#[derive(Default)] +pub struct CacheMetrics { + pub hits: AtomicU64, + pub misses: AtomicU64, + pub size_bytes: AtomicU64, + pub chunk_count: AtomicU64, +} + +impl CacheMetrics { + pub fn record_hit(&self) { + self.hits.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_miss(&self) { + self.misses.fetch_add(1, Ordering::Relaxed); + } + + pub fn update_size(&self, size: u64) { + self.size_bytes.store(size, Ordering::Relaxed); + } + + pub fn update_chunk_count(&self, count: u64) { + self.chunk_count.store(count, Ordering::Relaxed); + } +} + +#[derive(Default)] +pub struct OriginsMetrics { + pub healthy_count: AtomicU64, + pub total_count: AtomicU64, +} + +impl OriginsMetrics { + pub fn update(&self, healthy: u64, total: u64) { + self.healthy_count.store(healthy, Ordering::Relaxed); + self.total_count.store(total, Ordering::Relaxed); + } +} + +#[derive(Default)] +pub struct FuseLatencyMetrics { + pub histograms: RwLock>, +} + +impl FuseLatencyMetrics { + pub fn record(&self, op: &str, latency_secs: f64) { + let mut histograms = self.histograms.write().unwrap(); + histograms + .entry(op.to_string()) + .or_default() + .record(latency_secs); + } +} + +#[derive(Default)] +pub struct LatencyHistogram { + samples: Vec, + sum: f64, +} + +impl LatencyHistogram { + pub fn record(&mut self, latency_secs: f64) { + self.samples.push(latency_secs); + self.sum += latency_secs; + + if self.samples.len() > 10000 { + self.samples.drain(..5000); + } + } + + pub fn quantiles(&self) -> Quantiles { + if self.samples.is_empty() { + return Quantiles::default(); + } + + let mut sorted = self.samples.clone(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + + let len = sorted.len(); + Quantiles { + p50: sorted[len / 2], + p95: sorted[(len as f64 * 0.95) as usize], + p99: sorted[(len as f64 * 0.99) as usize], + } + } + + pub fn sum_secs(&self) -> f64 { + self.sum + } + + pub fn count(&self) -> u64 { + self.samples.len() as u64 + } +} + +#[derive(Default)] +pub struct Quantiles { + pub p50: f64, + pub p95: f64, + pub p99: f64, +} + +#[derive(Default)] +pub struct OriginHealthMetrics { + pub status: RwLock>, +} + +impl OriginHealthMetrics { + pub fn set_health(&self, origin_id: &str, healthy: bool) { + self.status + .write() + .unwrap() + .insert(origin_id.to_string(), healthy); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_metrics_new() { + let metrics = Metrics::new(); + assert!(metrics.uptime_secs() < 5); + } + + #[test] + fn test_fuse_ops_recording() { + let metrics = Metrics::new(); + metrics.fuse_ops.record_lookup(); + metrics.fuse_ops.record_lookup(); + metrics.fuse_ops.record_read(); + + assert_eq!(metrics.fuse_ops.lookup.load(Ordering::Relaxed), 2); + assert_eq!(metrics.fuse_ops.read.load(Ordering::Relaxed), 1); + } + + #[test] + fn test_cache_hit_ratio() { + let metrics = Metrics::new(); + metrics.cache.hits.store(8, Ordering::Relaxed); + metrics.cache.misses.store(2, Ordering::Relaxed); + + assert!((metrics.hit_ratio() - 0.8).abs() < 0.001); + } + + #[test] + fn test_cache_hit_ratio_zero() { + let metrics = Metrics::new(); + assert_eq!(metrics.hit_ratio(), 0.0); + } + + #[test] + fn test_prometheus_format() { + let metrics = Metrics::new(); + metrics.fuse_ops.record_lookup(); + metrics.cache.record_hit(); + + let output = metrics.to_prometheus(); + assert!(output.contains("musicfs_fuse_ops_total")); + assert!(output.contains("musicfs_cache_hits_total")); + } +} diff --git a/musicfs/crates/musicfs-grpc/Cargo.toml b/musicfs/crates/musicfs-grpc/Cargo.toml index 842b22b..e36469f 100644 --- a/musicfs/crates/musicfs-grpc/Cargo.toml +++ b/musicfs/crates/musicfs-grpc/Cargo.toml @@ -11,6 +11,13 @@ prost.workspace = true tokio.workspace = true tokio-stream.workspace = true tracing.workspace = true +serde.workspace = true +serde_json.workspace = true +chrono.workspace = true +reqwest = { version = "0.11", features = ["json"] } +hmac = "0.12" +sha2 = "0.10" +hex.workspace = true [build-dependencies] tonic-build.workspace = true diff --git a/musicfs/crates/musicfs-grpc/proto/musicfs.proto b/musicfs/crates/musicfs-grpc/proto/musicfs.proto index 816d957..9229a2f 100644 --- a/musicfs/crates/musicfs-grpc/proto/musicfs.proto +++ b/musicfs/crates/musicfs-grpc/proto/musicfs.proto @@ -5,8 +5,19 @@ package musicfs.v1; service MusicFS { rpc Search(SearchRequest) returns (SearchResponse); rpc SearchStream(SearchRequest) returns (stream SearchResult); + rpc GetStatus(Empty) returns (StatusResponse); + rpc Shutdown(ShutdownRequest) returns (Empty); + rpc GetCacheStats(Empty) returns (CacheStats); + rpc ClearCache(ClearCacheRequest) returns (ClearCacheResponse); + rpc Prefetch(PrefetchRequest) returns (stream PrefetchProgress); + rpc ListOrigins(Empty) returns (OriginsResponse); + rpc GetOriginHealth(OriginRequest) returns (OriginHealthResponse); + rpc RescanOrigin(OriginRequest) returns (stream SyncProgress); + rpc SubscribeEvents(EventFilter) returns (stream Event); } +message Empty {} + message SearchRequest { string query = 1; optional uint32 limit = 2; @@ -29,3 +40,137 @@ message SearchResult { float score = 6; map highlights = 7; } + +enum MountState { + MOUNT_UNKNOWN = 0; + MOUNT_MOUNTING = 1; + MOUNT_READY = 2; + MOUNT_SYNCING = 3; + MOUNT_DEGRADED = 4; + MOUNT_UNMOUNTING = 5; +} + +message StatusResponse { + string version = 1; + uint64 uptime_secs = 2; + string mount_point = 3; + MountState state = 4; + uint32 open_file_handles = 5; + uint64 fuse_ops_total = 6; + uint64 files_indexed = 7; + uint64 cache_size_bytes = 8; + repeated OriginStatus origins = 9; +} + +message OriginStatus { + string id = 1; + string origin_type = 2; + HealthStatus health = 3; + uint64 files_count = 4; +} + +enum HealthStatus { + HEALTH_UNKNOWN = 0; + HEALTH_HEALTHY = 1; + HEALTH_DEGRADED = 2; + HEALTH_UNHEALTHY = 3; +} + +message ShutdownRequest { + bool graceful = 1; + uint32 timeout_secs = 2; +} + +message TierStats { + uint64 entries = 1; + uint64 size_bytes = 2; + uint64 hits = 3; + uint64 misses = 4; +} + +message CacheStats { + uint64 total_size_bytes = 1; + uint64 used_size_bytes = 2; + uint64 size_limit_bytes = 3; + uint64 chunk_count = 4; + uint64 chunks_unique = 5; + double dedup_ratio = 6; + uint64 hit_count = 7; + uint64 miss_count = 8; + double hit_ratio = 9; + uint64 metadata_entries = 10; + uint64 metadata_bytes = 11; + TierStats l1_metadata = 12; + TierStats l2_headers = 13; + TierStats l3_chunks = 14; +} + +message ClearCacheRequest { + optional string origin_id = 1; + bool clear_metadata = 2; + bool clear_chunks = 3; +} + +message ClearCacheResponse { + uint64 bytes_cleared = 1; + uint64 chunks_cleared = 2; +} + +message PrefetchRequest { + repeated string paths = 1; + optional string origin_id = 2; +} + +message PrefetchProgress { + string current_path = 1; + uint32 completed = 2; + uint32 total = 3; + uint64 bytes_fetched = 4; +} + +message OriginsResponse { + repeated OriginInfo origins = 1; +} + +message OriginInfo { + string id = 1; + string origin_type = 2; + string display_name = 3; + string root_path = 4; + HealthStatus health = 5; + uint64 files_count = 6; + uint64 total_size_bytes = 7; +} + +message OriginRequest { + string origin_id = 1; +} + +message OriginHealthResponse { + string origin_id = 1; + HealthStatus status = 2; + optional string message = 3; + uint64 last_check_secs = 4; +} + +message SyncProgress { + string phase = 1; + uint32 current = 2; + uint32 total = 3; + string current_path = 4; + uint64 bytes_synced = 5; +} + +message EventFilter { + repeated string event_types = 1; + optional string origin_id = 2; +} + +message Event { + string event_type = 1; + int64 timestamp_ms = 2; + optional string origin_id = 3; + optional string path = 4; + optional int64 file_id = 5; + map metadata = 6; +} diff --git a/musicfs/crates/musicfs-grpc/src/lib.rs b/musicfs/crates/musicfs-grpc/src/lib.rs index 51d90ca..37424a5 100644 --- a/musicfs/crates/musicfs-grpc/src/lib.rs +++ b/musicfs/crates/musicfs-grpc/src/lib.rs @@ -7,6 +7,11 @@ pub mod proto { } mod search_service; +mod server; +mod webhook; -pub use proto::musicfs::v1::music_fs_server::{MusicFs, MusicFsServer}; +pub use proto::musicfs::v1::music_fs_server::{MusicFs, MusicFsServer as MusicFsGrpcServer}; +pub use proto::musicfs::v1::*; pub use search_service::SearchService; +pub use server::MusicFsServer; +pub use webhook::{WebhookConfig, WebhookHandler, WebhookPayload}; diff --git a/musicfs/crates/musicfs-grpc/src/search_service.rs b/musicfs/crates/musicfs-grpc/src/search_service.rs index b7fb88e..a40c9fa 100644 --- a/musicfs/crates/musicfs-grpc/src/search_service.rs +++ b/musicfs/crates/musicfs-grpc/src/search_service.rs @@ -1,9 +1,13 @@ use crate::proto::musicfs::v1::{ - music_fs_server::MusicFs, SearchRequest, SearchResponse, SearchResult, + music_fs_server::MusicFs, CacheStats, ClearCacheRequest, ClearCacheResponse, Empty, Event, + EventFilter, OriginHealthResponse, OriginRequest, OriginsResponse, PrefetchProgress, + PrefetchRequest, SearchRequest, SearchResponse, SearchResult, ShutdownRequest, StatusResponse, + SyncProgress, }; use musicfs_search::SearchIndex; use std::sync::Arc; use std::time::Instant; +use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status}; use tracing::debug; @@ -74,7 +78,7 @@ impl MusicFs for SearchService { })) } - type SearchStreamStream = tokio_stream::wrappers::ReceiverStream>; + type SearchStreamStream = ReceiverStream>; async fn search_stream( &self, @@ -112,9 +116,94 @@ impl MusicFs for SearchService { } }); - Ok(Response::new(tokio_stream::wrappers::ReceiverStream::new( - rx, - ))) + Ok(Response::new(ReceiverStream::new(rx))) + } + + async fn get_status( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "Use MusicFsServer for control operations", + )) + } + + async fn shutdown( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "Use MusicFsServer for control operations", + )) + } + + async fn get_cache_stats( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "Use MusicFsServer for control operations", + )) + } + + async fn clear_cache( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "Use MusicFsServer for control operations", + )) + } + + type PrefetchStream = ReceiverStream>; + + async fn prefetch( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "Use MusicFsServer for control operations", + )) + } + + async fn list_origins( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "Use MusicFsServer for control operations", + )) + } + + async fn get_origin_health( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "Use MusicFsServer for control operations", + )) + } + + type RescanOriginStream = ReceiverStream>; + + async fn rescan_origin( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "Use MusicFsServer for control operations", + )) + } + + type SubscribeEventsStream = ReceiverStream>; + + async fn subscribe_events( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "Use MusicFsServer for control operations", + )) } } diff --git a/musicfs/crates/musicfs-grpc/src/server.rs b/musicfs/crates/musicfs-grpc/src/server.rs new file mode 100644 index 0000000..3f3f5eb --- /dev/null +++ b/musicfs/crates/musicfs-grpc/src/server.rs @@ -0,0 +1,428 @@ +use crate::proto::musicfs::v1::{ + music_fs_server::MusicFs, CacheStats, ClearCacheRequest, ClearCacheResponse, Empty, Event, + EventFilter, HealthStatus, MountState, OriginHealthResponse, OriginRequest, OriginsResponse, + PrefetchProgress, PrefetchRequest, SearchRequest, SearchResponse, SearchResult, + ShutdownRequest, StatusResponse, SyncProgress, TierStats, +}; +use musicfs_core::{Event as CoreEvent, EventBus}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; +use tonic::{Request, Response, Status}; +use tracing::{debug, info}; + +pub struct MusicFsServer { + start_time: Instant, + event_bus: Arc, + version: String, +} + +impl MusicFsServer { + pub fn new(event_bus: Arc) -> Self { + Self { + start_time: Instant::now(), + event_bus, + version: env!("CARGO_PKG_VERSION").to_string(), + } + } + + fn event_to_proto(event: &CoreEvent) -> Event { + let (event_type, origin_id, path, file_id) = match event { + CoreEvent::FileAccessed { + file_id, + origin_id, + path, + .. + } => ( + "file_accessed".to_string(), + Some(origin_id.to_string()), + Some(path.as_str().to_string()), + Some(file_id.0), + ), + CoreEvent::FileAdded { path, origin_id } => ( + "file_added".to_string(), + Some(origin_id.to_string()), + Some(path.as_str().to_string()), + None, + ), + CoreEvent::FileRemoved { path, file_id } => ( + "file_removed".to_string(), + None, + Some(path.as_str().to_string()), + file_id.map(|id| id.0), + ), + CoreEvent::FileModified { path } => ( + "file_modified".to_string(), + None, + Some(path.as_str().to_string()), + None, + ), + CoreEvent::SyncStarted { origin_id } => ( + "sync_started".to_string(), + Some(origin_id.to_string()), + None, + None, + ), + CoreEvent::SyncCompleted { + origin_id, + files_changed, + } => { + let mut metadata = std::collections::HashMap::new(); + metadata.insert("files_changed".to_string(), files_changed.to_string()); + return Event { + event_type: "sync_completed".to_string(), + timestamp_ms: chrono::Utc::now().timestamp_millis(), + origin_id: Some(origin_id.to_string()), + path: None, + file_id: None, + metadata, + }; + } + CoreEvent::OriginHealthChanged { origin_id, healthy } => { + let mut metadata = std::collections::HashMap::new(); + metadata.insert("healthy".to_string(), healthy.to_string()); + return Event { + event_type: "origin_health_changed".to_string(), + timestamp_ms: chrono::Utc::now().timestamp_millis(), + origin_id: Some(origin_id.to_string()), + path: None, + file_id: None, + metadata, + }; + } + CoreEvent::CacheEviction { bytes_freed } => { + let mut metadata = std::collections::HashMap::new(); + metadata.insert("bytes_freed".to_string(), bytes_freed.to_string()); + return Event { + event_type: "cache_eviction".to_string(), + timestamp_ms: chrono::Utc::now().timestamp_millis(), + origin_id: None, + path: None, + file_id: None, + metadata, + }; + } + CoreEvent::OriginConnected { origin_id } => ( + "origin_connected".to_string(), + Some(origin_id.to_string()), + None, + None, + ), + CoreEvent::OriginDisconnected { origin_id } => ( + "origin_disconnected".to_string(), + Some(origin_id.to_string()), + None, + None, + ), + CoreEvent::AllOriginsUnhealthy { candidate_count } => { + let mut metadata = std::collections::HashMap::new(); + metadata.insert("candidate_count".to_string(), candidate_count.to_string()); + return Event { + event_type: "all_origins_unhealthy".to_string(), + timestamp_ms: chrono::Utc::now().timestamp_millis(), + origin_id: None, + path: None, + file_id: None, + metadata, + }; + } + }; + + Event { + event_type, + timestamp_ms: chrono::Utc::now().timestamp_millis(), + origin_id, + path, + file_id, + metadata: Default::default(), + } + } + + fn matches_filter(event: &CoreEvent, filter: &EventFilter) -> bool { + if !filter.event_types.is_empty() { + let event_type = match event { + CoreEvent::FileAccessed { .. } => "file_accessed", + CoreEvent::FileAdded { .. } => "file_added", + CoreEvent::FileRemoved { .. } => "file_removed", + CoreEvent::FileModified { .. } => "file_modified", + CoreEvent::SyncStarted { .. } => "sync_started", + CoreEvent::SyncCompleted { .. } => "sync_completed", + CoreEvent::OriginHealthChanged { .. } => "origin_health_changed", + CoreEvent::CacheEviction { .. } => "cache_eviction", + CoreEvent::OriginConnected { .. } => "origin_connected", + CoreEvent::OriginDisconnected { .. } => "origin_disconnected", + CoreEvent::AllOriginsUnhealthy { .. } => "all_origins_unhealthy", + }; + + if !filter.event_types.iter().any(|t| t == event_type) { + return false; + } + } + + if let Some(ref origin_filter) = filter.origin_id { + let event_origin = match event { + CoreEvent::FileAccessed { origin_id, .. } + | CoreEvent::FileAdded { origin_id, .. } + | CoreEvent::SyncStarted { origin_id } + | CoreEvent::SyncCompleted { origin_id, .. } + | CoreEvent::OriginHealthChanged { origin_id, .. } + | CoreEvent::OriginConnected { origin_id } + | CoreEvent::OriginDisconnected { origin_id } => Some(origin_id.to_string()), + CoreEvent::FileRemoved { .. } + | CoreEvent::FileModified { .. } + | CoreEvent::CacheEviction { .. } + | CoreEvent::AllOriginsUnhealthy { .. } => None, + }; + + if event_origin.as_ref() != Some(origin_filter) { + return false; + } + } + + true + } +} + +#[tonic::async_trait] +impl MusicFs for MusicFsServer { + async fn search( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "Use SearchService for search operations", + )) + } + + type SearchStreamStream = ReceiverStream>; + + async fn search_stream( + &self, + _request: Request, + ) -> Result, Status> { + Err(Status::unimplemented( + "Use SearchService for search operations", + )) + } + + async fn get_status( + &self, + _request: Request, + ) -> Result, Status> { + let uptime = self.start_time.elapsed().as_secs(); + + Ok(Response::new(StatusResponse { + version: self.version.clone(), + uptime_secs: uptime, + mount_point: String::new(), + state: MountState::MountReady as i32, + open_file_handles: 0, + fuse_ops_total: 0, + files_indexed: 0, + cache_size_bytes: 0, + origins: vec![], + })) + } + + async fn shutdown( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + info!( + "Shutdown requested (graceful={}, timeout={}s)", + req.graceful, req.timeout_secs + ); + + Ok(Response::new(Empty {})) + } + + async fn get_cache_stats( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(CacheStats { + total_size_bytes: 0, + used_size_bytes: 0, + size_limit_bytes: 0, + chunk_count: 0, + chunks_unique: 0, + dedup_ratio: 0.0, + hit_count: 0, + miss_count: 0, + hit_ratio: 0.0, + metadata_entries: 0, + metadata_bytes: 0, + l1_metadata: Some(TierStats { + entries: 0, + size_bytes: 0, + hits: 0, + misses: 0, + }), + l2_headers: Some(TierStats { + entries: 0, + size_bytes: 0, + hits: 0, + misses: 0, + }), + l3_chunks: Some(TierStats { + entries: 0, + size_bytes: 0, + hits: 0, + misses: 0, + }), + })) + } + + async fn clear_cache( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + debug!( + "Clear cache requested: origin={:?}, metadata={}, chunks={}", + req.origin_id, req.clear_metadata, req.clear_chunks + ); + + Ok(Response::new(ClearCacheResponse { + bytes_cleared: 0, + chunks_cleared: 0, + })) + } + + type PrefetchStream = ReceiverStream>; + + async fn prefetch( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let total = req.paths.len() as u32; + + let (tx, rx) = mpsc::channel(32); + + tokio::spawn(async move { + for (i, path) in req.paths.into_iter().enumerate() { + let progress = PrefetchProgress { + current_path: path, + completed: i as u32 + 1, + total, + bytes_fetched: 0, + }; + if tx.send(Ok(progress)).await.is_err() { + break; + } + } + }); + + Ok(Response::new(ReceiverStream::new(rx))) + } + + async fn list_origins( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(OriginsResponse { origins: vec![] })) + } + + async fn get_origin_health( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + Ok(Response::new(OriginHealthResponse { + origin_id: req.origin_id, + status: HealthStatus::HealthUnknown as i32, + message: None, + last_check_secs: 0, + })) + } + + type RescanOriginStream = ReceiverStream>; + + async fn rescan_origin( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + info!("Rescan requested for origin: {}", req.origin_id); + + let (tx, rx) = mpsc::channel(32); + + tokio::spawn(async move { + let phases = ["scanning", "indexing", "complete"]; + for (i, phase) in phases.iter().enumerate() { + let progress = SyncProgress { + phase: phase.to_string(), + current: i as u32 + 1, + total: phases.len() as u32, + current_path: String::new(), + bytes_synced: 0, + }; + if tx.send(Ok(progress)).await.is_err() { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }); + + Ok(Response::new(ReceiverStream::new(rx))) + } + + type SubscribeEventsStream = ReceiverStream>; + + async fn subscribe_events( + &self, + request: Request, + ) -> Result, Status> { + let filter = request.into_inner(); + let mut rx = self.event_bus.subscribe(); + let (tx, out_rx) = mpsc::channel(100); + + tokio::spawn(async move { + while let Ok(event) = rx.recv().await { + if Self::matches_filter(&event, &filter) { + let proto_event = Self::event_to_proto(&event); + if tx.send(Ok(proto_event)).await.is_err() { + break; + } + } + } + }); + + Ok(Response::new(ReceiverStream::new(out_rx))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_get_status() { + let event_bus = Arc::new(EventBus::new(16)); + let server = MusicFsServer::new(event_bus); + + let response = server.get_status(Request::new(Empty {})).await.unwrap(); + let status = response.into_inner(); + + assert!(!status.version.is_empty()); + assert!(status.uptime_secs < 5); + } + + #[tokio::test] + async fn test_get_cache_stats() { + let event_bus = Arc::new(EventBus::new(16)); + let server = MusicFsServer::new(event_bus); + + let response = server + .get_cache_stats(Request::new(Empty {})) + .await + .unwrap(); + let stats = response.into_inner(); + + assert_eq!(stats.hit_ratio, 0.0); + } +} diff --git a/musicfs/crates/musicfs-grpc/src/webhook.rs b/musicfs/crates/musicfs-grpc/src/webhook.rs new file mode 100644 index 0000000..bcf3f73 --- /dev/null +++ b/musicfs/crates/musicfs-grpc/src/webhook.rs @@ -0,0 +1,288 @@ +use musicfs_core::Event; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use tokio::sync::broadcast; +use tracing::{debug, warn}; + +#[derive(Debug, Clone, Serialize)] +pub struct WebhookPayload { + pub event_type: String, + pub timestamp: i64, + pub data: serde_json::Value, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct WebhookConfig { + pub url: String, + pub secret: Option, + pub events: Vec, + #[serde(default = "default_retry_count")] + pub retry_count: u32, + #[serde(default = "default_timeout_ms")] + pub timeout_ms: u64, +} + +fn default_retry_count() -> u32 { + 3 +} + +fn default_timeout_ms() -> u64 { + 5000 +} + +pub struct WebhookHandler { + client: reqwest::Client, + configs: Vec, +} + +impl WebhookHandler { + pub fn new(configs: Vec) -> Self { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .expect("Failed to create HTTP client"); + + Self { client, configs } + } + + pub async fn run(&self, mut rx: broadcast::Receiver) { + while let Ok(event) = rx.recv().await { + for config in &self.configs { + if self.matches_filter(&event, config) { + self.dispatch(config, &event).await; + } + } + } + } + + async fn dispatch(&self, config: &WebhookConfig, event: &Event) { + let payload = WebhookPayload { + event_type: self.event_type_name(event), + timestamp: chrono::Utc::now().timestamp_millis(), + data: self.event_to_json(event), + }; + + let signature = self.sign(&payload, config); + + let mut attempts = 0u32; + loop { + let result = self + .client + .post(&config.url) + .timeout(Duration::from_millis(config.timeout_ms)) + .header("Content-Type", "application/json") + .header("X-MusicFS-Signature", &signature) + .header("X-MusicFS-Event", &payload.event_type) + .json(&payload) + .send() + .await; + + match result { + Ok(resp) if resp.status().is_success() => { + debug!( + "Webhook delivered to {} for {}", + config.url, payload.event_type + ); + break; + } + Ok(resp) => { + warn!( + "Webhook to {} returned status {}, attempt {}/{}", + config.url, + resp.status(), + attempts + 1, + config.retry_count + 1 + ); + } + Err(e) => { + warn!( + "Webhook to {} failed: {}, attempt {}/{}", + config.url, + e, + attempts + 1, + config.retry_count + 1 + ); + } + } + + if attempts >= config.retry_count { + warn!( + "Webhook delivery to {} failed after {} attempts", + config.url, + attempts + 1 + ); + break; + } + + attempts += 1; + let delay = Duration::from_millis(100 * 2u64.pow(attempts)); + tokio::time::sleep(delay).await; + } + } + + fn sign(&self, payload: &WebhookPayload, config: &WebhookConfig) -> String { + match &config.secret { + Some(secret) => { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + type HmacSha256 = Hmac; + + let body = serde_json::to_string(payload).unwrap_or_default(); + let mut mac = + HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC key invalid"); + mac.update(body.as_bytes()); + let result = mac.finalize(); + + format!("sha256={}", hex::encode(result.into_bytes())) + } + None => String::new(), + } + } + + fn matches_filter(&self, event: &Event, config: &WebhookConfig) -> bool { + if config.events.is_empty() { + return true; + } + + let event_type = self.event_type_name(event); + config.events.iter().any(|e| e == &event_type) + } + + fn event_type_name(&self, event: &Event) -> String { + match event { + Event::FileAccessed { .. } => "file_accessed", + Event::FileAdded { .. } => "file_added", + Event::FileRemoved { .. } => "file_removed", + Event::FileModified { .. } => "file_modified", + Event::SyncStarted { .. } => "sync_started", + Event::SyncCompleted { .. } => "sync_completed", + Event::OriginHealthChanged { .. } => "origin_health_changed", + Event::CacheEviction { .. } => "cache_eviction", + Event::OriginConnected { .. } => "origin_connected", + Event::OriginDisconnected { .. } => "origin_disconnected", + Event::AllOriginsUnhealthy { .. } => "all_origins_unhealthy", + } + .to_string() + } + + fn event_to_json(&self, event: &Event) -> serde_json::Value { + match event { + Event::FileAccessed { + file_id, + origin_id, + path, + offset, + size, + } => serde_json::json!({ + "file_id": file_id.0, + "origin_id": origin_id.to_string(), + "path": path.as_str(), + "offset": offset, + "size": size, + }), + Event::FileAdded { path, origin_id } => serde_json::json!({ + "path": path.as_str(), + "origin_id": origin_id.to_string(), + }), + Event::FileRemoved { path, file_id } => serde_json::json!({ + "path": path.as_str(), + "file_id": file_id.map(|id| id.0), + }), + Event::FileModified { path } => serde_json::json!({ + "path": path.as_str(), + }), + Event::SyncStarted { origin_id } => serde_json::json!({ + "origin_id": origin_id.to_string(), + }), + Event::SyncCompleted { + origin_id, + files_changed, + } => serde_json::json!({ + "origin_id": origin_id.to_string(), + "files_changed": files_changed, + }), + Event::OriginHealthChanged { origin_id, healthy } => serde_json::json!({ + "origin_id": origin_id.to_string(), + "healthy": healthy, + }), + Event::CacheEviction { bytes_freed } => serde_json::json!({ + "bytes_freed": bytes_freed, + }), + Event::OriginConnected { origin_id } => serde_json::json!({ + "origin_id": origin_id.to_string(), + }), + Event::OriginDisconnected { origin_id } => serde_json::json!({ + "origin_id": origin_id.to_string(), + }), + Event::AllOriginsUnhealthy { candidate_count } => serde_json::json!({ + "candidate_count": candidate_count, + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use musicfs_core::OriginId; + + #[test] + fn test_webhook_config_defaults() { + let json = r#"{"url": "http://example.com", "events": []}"#; + let config: WebhookConfig = serde_json::from_str(json).unwrap(); + + assert_eq!(config.retry_count, 3); + assert_eq!(config.timeout_ms, 5000); + } + + #[test] + fn test_event_type_name() { + let handler = WebhookHandler::new(vec![]); + + let event = Event::SyncStarted { + origin_id: OriginId::from("test"), + }; + assert_eq!(handler.event_type_name(&event), "sync_started"); + } + + #[test] + fn test_matches_filter_empty() { + let handler = WebhookHandler::new(vec![]); + let config = WebhookConfig { + url: "http://example.com".to_string(), + secret: None, + events: vec![], + retry_count: 3, + timeout_ms: 5000, + }; + + let event = Event::SyncStarted { + origin_id: OriginId::from("test"), + }; + assert!(handler.matches_filter(&event, &config)); + } + + #[test] + fn test_matches_filter_specific() { + let handler = WebhookHandler::new(vec![]); + let config = WebhookConfig { + url: "http://example.com".to_string(), + secret: None, + events: vec!["sync_started".to_string()], + retry_count: 3, + timeout_ms: 5000, + }; + + let event = Event::SyncStarted { + origin_id: OriginId::from("test"), + }; + assert!(handler.matches_filter(&event, &config)); + + let event2 = Event::SyncCompleted { + origin_id: OriginId::from("test"), + files_changed: 0, + }; + assert!(!handler.matches_filter(&event2, &config)); + } +} diff --git a/musicfs/crates/musicfs-plugins/Cargo.toml b/musicfs/crates/musicfs-plugins/Cargo.toml index c1e3ace..ea8f6c0 100644 --- a/musicfs/crates/musicfs-plugins/Cargo.toml +++ b/musicfs/crates/musicfs-plugins/Cargo.toml @@ -4,3 +4,20 @@ version.workspace = true edition.workspace = true [dependencies] +musicfs-core = { path = "../musicfs-core" } +async-trait.workspace = true +tokio.workspace = true +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true +libloading = "0.8" +wasmtime = { version = "19", optional = true } +semver = "1" + +[features] +default = [] +wasm = ["wasmtime"] + +[dev-dependencies] +tempfile.workspace = true diff --git a/musicfs/crates/musicfs-plugins/src/error.rs b/musicfs/crates/musicfs-plugins/src/error.rs new file mode 100644 index 0000000..ad44795 --- /dev/null +++ b/musicfs/crates/musicfs-plugins/src/error.rs @@ -0,0 +1,42 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum PluginError { + #[error("Plugin not found: {0}")] + NotFound(String), + + #[error("Plugin load failed: {0}")] + LoadFailed(String), + + #[error("Plugin initialization failed: {0}")] + InitFailed(String), + + #[error("Plugin API version mismatch: expected {expected}, got {actual}")] + VersionMismatch { expected: String, actual: String }, + + #[error("Plugin already loaded: {0}")] + AlreadyLoaded(String), + + #[error("Plugin symbol not found: {0}")] + SymbolNotFound(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Plugin execution error: {0}")] + Execution(String), + + #[error("Plugin shutdown error: {0}")] + Shutdown(String), + + #[error("Configuration error: {0}")] + Config(String), + + #[error("WASM error: {0}")] + Wasm(String), + + #[error("Resource limit exceeded: {0}")] + ResourceLimit(String), +} + +pub type Result = std::result::Result; diff --git a/musicfs/crates/musicfs-plugins/src/lib.rs b/musicfs/crates/musicfs-plugins/src/lib.rs index f9da2c4..9561dd6 100644 --- a/musicfs/crates/musicfs-plugins/src/lib.rs +++ b/musicfs/crates/musicfs-plugins/src/lib.rs @@ -1 +1,15 @@ -#![allow(dead_code)] +pub mod error; +pub mod manager; +pub mod native; +pub mod traits; +pub mod wasm; + +pub use error::{PluginError, Result}; +pub use manager::{PluginConfig, PluginEntry, PluginManager, WasmConfig}; +pub use native::NativePluginHost; +pub use traits::{ + ExternalMetadata, FormatPlugin, MetadataPlugin, MetadataQuery, MetadataQueryType, + OriginDirEntry, OriginHealth, OriginInstance, OriginPlugin, OriginStat, Plugin, PluginId, + PluginInfo, PluginType, WatchEvent, WatchHandle, PLUGIN_API_VERSION, +}; +pub use wasm::{ResourceLimits, WasmPluginHost}; diff --git a/musicfs/crates/musicfs-plugins/src/manager.rs b/musicfs/crates/musicfs-plugins/src/manager.rs new file mode 100644 index 0000000..1d540b9 --- /dev/null +++ b/musicfs/crates/musicfs-plugins/src/manager.rs @@ -0,0 +1,345 @@ +use crate::error::{PluginError, Result}; +use crate::native::NativePluginHost; +use crate::traits::{Plugin, PluginId, PluginInfo, PluginType}; +use crate::wasm::{ResourceLimits, WasmPluginHost}; +use serde::Deserialize; +use serde_json::Value; +use std::collections::HashMap; +use std::path::PathBuf; +use tracing::{debug, info}; + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct PluginConfig { + #[serde(default)] + pub enabled: bool, + + #[serde(default)] + pub search_paths: Vec, + + #[serde(default)] + pub plugins: HashMap, + + #[serde(default)] + pub wasm: WasmConfig, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PluginEntry { + pub path: PathBuf, + + #[serde(default)] + pub enabled: bool, + + #[serde(default)] + pub config: Value, +} + +impl Default for PluginEntry { + fn default() -> Self { + Self { + path: PathBuf::new(), + enabled: true, + config: Value::Null, + } + } +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct WasmConfig { + #[serde(default)] + pub enabled: bool, + + #[serde(default)] + pub max_memory_mb: Option, + + #[serde(default)] + pub max_cpu_time_ms: Option, +} + +pub struct PluginManager { + native_host: NativePluginHost, + wasm_host: WasmPluginHost, + registry: PluginRegistry, + config: PluginConfig, +} + +struct PluginRegistry { + origin_plugins: Vec, + metadata_plugins: Vec, + format_plugins: Vec, +} + +impl PluginRegistry { + fn new() -> Self { + Self { + origin_plugins: Vec::new(), + metadata_plugins: Vec::new(), + format_plugins: Vec::new(), + } + } + + fn register(&mut self, id: PluginId, plugin_type: PluginType) { + match plugin_type { + PluginType::Origin => { + if !self.origin_plugins.contains(&id) { + self.origin_plugins.push(id); + } + } + PluginType::Metadata => { + if !self.metadata_plugins.contains(&id) { + self.metadata_plugins.push(id); + } + } + PluginType::Format => { + if !self.format_plugins.contains(&id) { + self.format_plugins.push(id); + } + } + } + } + + fn unregister(&mut self, id: PluginId) { + self.origin_plugins.retain(|&x| x != id); + self.metadata_plugins.retain(|&x| x != id); + self.format_plugins.retain(|&x| x != id); + } +} + +impl PluginManager { + pub fn new() -> Result { + Ok(Self { + native_host: NativePluginHost::new(), + wasm_host: WasmPluginHost::new()?, + registry: PluginRegistry::new(), + config: PluginConfig::default(), + }) + } + + pub fn init(config: &PluginConfig) -> Result { + let mut manager = Self::new()?; + manager.config = config.clone(); + + if !config.enabled { + info!("Plugin system disabled"); + return Ok(manager); + } + + info!("Initializing plugin system"); + + for path in &config.search_paths { + manager.native_host.add_search_path(path.clone()); + } + + if config.wasm.enabled { + let limits = ResourceLimits { + max_memory_mb: config.wasm.max_memory_mb.unwrap_or(64), + max_cpu_time_ms: config.wasm.max_cpu_time_ms.unwrap_or(5000), + ..Default::default() + }; + manager.wasm_host.set_limits(limits); + } + + for (name, entry) in &config.plugins { + if !entry.enabled { + debug!("Skipping disabled plugin: {}", name); + continue; + } + + match manager.load_and_init(&entry.path, &entry.config) { + Ok(id) => { + info!("Loaded plugin '{}' with id {:?}", name, id); + } + Err(e) => { + tracing::warn!("Failed to load plugin '{}': {}", name, e); + } + } + } + + let discovered = manager.native_host.discover()?; + for id in discovered { + if let Some(info) = manager.native_host.list().iter().find(|i| i.id == id) { + manager.registry.register(id, info.plugin_type); + } + } + + Ok(manager) + } + + pub fn load_and_init(&mut self, path: &PathBuf, config: &Value) -> Result { + let id = self.native_host.load(path)?; + + if let Some(plugin) = self.native_host.get_mut(id) { + plugin.init(config.clone())?; + } + + if let Some(info) = self.native_host.list().iter().find(|i| i.id == id) { + self.registry.register(id, info.plugin_type); + } + + Ok(id) + } + + pub fn load_wasm(&mut self, wasm_bytes: &[u8]) -> Result { + if !self.config.wasm.enabled { + return Err(PluginError::Config("WASM plugins disabled".to_string())); + } + + self.wasm_host.load(wasm_bytes) + } + + pub fn unload(&mut self, id: PluginId) -> Result<()> { + self.registry.unregister(id); + + if let Err(native_err) = self.native_host.unload(id) { + if let Err(wasm_err) = self.wasm_host.unload(id) { + return Err(PluginError::NotFound(format!( + "Plugin {:?} not found in native ({}) or WASM ({}) hosts", + id, native_err, wasm_err + ))); + } + } + + Ok(()) + } + + pub fn reload(&mut self, id: PluginId) -> Result<()> { + self.native_host.reload(id) + } + + pub fn reload_all(&mut self) -> Result<()> { + let ids: Vec = self.native_host.list().iter().map(|i| i.id).collect(); + + for id in ids { + self.reload(id)?; + } + + Ok(()) + } + + pub fn list(&self) -> Vec { + let mut all = self.native_host.list(); + + for (id, name) in self.wasm_host.list() { + all.push(PluginInfo { + id, + name: name.to_string(), + version: semver::Version::new(0, 0, 0), + description: "WASM plugin".to_string(), + plugin_type: PluginType::Origin, + }); + } + + all + } + + pub fn get(&self, id: PluginId) -> Option<&dyn Plugin> { + self.native_host.get(id) + } + + pub fn get_mut(&mut self, id: PluginId) -> Option<&mut dyn Plugin> { + self.native_host.get_mut(id) + } + + pub fn origin_plugin_ids(&self) -> &[PluginId] { + &self.registry.origin_plugins + } + + pub fn metadata_plugin_ids(&self) -> &[PluginId] { + &self.registry.metadata_plugins + } + + pub fn format_plugin_ids(&self) -> &[PluginId] { + &self.registry.format_plugins + } + + pub fn shutdown(&mut self) -> Result<()> { + info!("Shutting down plugin system"); + + let ids: Vec = self.list().iter().map(|i| i.id).collect(); + + for id in ids { + if let Err(e) = self.unload(id) { + tracing::warn!("Failed to unload plugin {:?}: {}", id, e); + } + } + + Ok(()) + } +} + +impl Default for PluginManager { + fn default() -> Self { + Self::new().expect("Failed to create plugin manager") + } +} + +impl Drop for PluginManager { + fn drop(&mut self) { + let _ = self.shutdown(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_plugin_manager_new() { + let manager = PluginManager::new(); + assert!(manager.is_ok()); + } + + #[test] + fn test_plugin_manager_disabled() { + let config = PluginConfig { + enabled: false, + ..Default::default() + }; + + let manager = PluginManager::init(&config); + assert!(manager.is_ok()); + + let manager = manager.unwrap(); + assert!(manager.list().is_empty()); + } + + #[test] + fn test_registry() { + let mut registry = PluginRegistry::new(); + + let id1 = PluginId::new(1); + let id2 = PluginId::new(2); + + registry.register(id1, PluginType::Origin); + registry.register(id2, PluginType::Metadata); + + assert_eq!(registry.origin_plugins.len(), 1); + assert_eq!(registry.metadata_plugins.len(), 1); + + registry.unregister(id1); + assert!(registry.origin_plugins.is_empty()); + } + + #[test] + fn test_plugin_config_deserialize() { + let json = r#"{ + "enabled": true, + "search_paths": ["/usr/lib/musicfs/plugins"], + "plugins": { + "example": { + "path": "/path/to/plugin.so", + "enabled": true, + "config": {"key": "value"} + } + }, + "wasm": { + "enabled": false + } + }"#; + + let config: PluginConfig = serde_json::from_str(json).unwrap(); + assert!(config.enabled); + assert_eq!(config.search_paths.len(), 1); + assert!(config.plugins.contains_key("example")); + } +} diff --git a/musicfs/crates/musicfs-plugins/src/native.rs b/musicfs/crates/musicfs-plugins/src/native.rs new file mode 100644 index 0000000..f2e78ce --- /dev/null +++ b/musicfs/crates/musicfs-plugins/src/native.rs @@ -0,0 +1,300 @@ +use crate::error::{PluginError, Result}; +use crate::traits::{Plugin, PluginId, PluginInfo, PluginType, PLUGIN_API_VERSION}; +use libloading::{Library, Symbol}; +use semver::Version; +use std::collections::HashMap; +use std::ffi::CStr; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use tracing::{debug, info, warn}; + +static NEXT_PLUGIN_ID: AtomicU64 = AtomicU64::new(1); + +fn next_plugin_id() -> PluginId { + PluginId::new(NEXT_PLUGIN_ID.fetch_add(1, Ordering::SeqCst)) +} + +struct LoadedPlugin { + id: PluginId, + path: PathBuf, + library: Library, + instance: Box, + plugin_type: PluginType, +} + +pub struct NativePluginHost { + plugins: HashMap, + search_paths: Vec, +} + +impl NativePluginHost { + pub fn new() -> Self { + Self { + plugins: HashMap::new(), + search_paths: Vec::new(), + } + } + + pub fn add_search_path(&mut self, path: PathBuf) { + if !self.search_paths.contains(&path) { + self.search_paths.push(path); + } + } + + pub fn load(&mut self, path: &Path) -> Result { + let canonical = path.canonicalize().map_err(|e| { + PluginError::LoadFailed(format!("Cannot resolve path {}: {}", path.display(), e)) + })?; + + for plugin in self.plugins.values() { + if plugin.path == canonical { + return Err(PluginError::AlreadyLoaded(canonical.display().to_string())); + } + } + + info!("Loading native plugin from {:?}", canonical); + + let library = unsafe { + Library::new(&canonical).map_err(|e| { + PluginError::LoadFailed(format!("Failed to load library: {}", e)) + })? + }; + + self.verify_api_version(&library)?; + + let instance = self.create_plugin_instance(&library)?; + let id = next_plugin_id(); + + let plugin_type = self.detect_plugin_type(&*instance); + + debug!( + "Loaded plugin '{}' v{} as {:?}", + instance.name(), + instance.version(), + plugin_type + ); + + self.plugins.insert( + id, + LoadedPlugin { + id, + path: canonical, + library, + instance, + plugin_type, + }, + ); + + Ok(id) + } + + pub fn unload(&mut self, id: PluginId) -> Result<()> { + let mut plugin = self + .plugins + .remove(&id) + .ok_or_else(|| PluginError::NotFound(format!("Plugin {:?}", id)))?; + + info!("Unloading plugin '{}'", plugin.instance.name()); + + plugin.instance.shutdown()?; + + drop(plugin.instance); + drop(plugin.library); + + Ok(()) + } + + pub fn reload(&mut self, id: PluginId) -> Result<()> { + let plugin = self + .plugins + .get(&id) + .ok_or_else(|| PluginError::NotFound(format!("Plugin {:?}", id)))?; + + let path = plugin.path.clone(); + + info!("Hot-reloading plugin from {:?}", path); + + self.unload(id)?; + + let new_id = self.load(&path)?; + + if let Some(plugin) = self.plugins.remove(&new_id) { + self.plugins.insert(id, LoadedPlugin { id, ..plugin }); + } + + Ok(()) + } + + pub fn get(&self, id: PluginId) -> Option<&dyn Plugin> { + self.plugins.get(&id).map(|p| &*p.instance as &dyn Plugin) + } + + pub fn get_mut(&mut self, id: PluginId) -> Option<&mut dyn Plugin> { + self.plugins + .get_mut(&id) + .map(|p| &mut *p.instance as &mut dyn Plugin) + } + + pub fn list(&self) -> Vec { + self.plugins + .values() + .map(|p| PluginInfo { + id: p.id, + name: p.instance.name().to_string(), + version: p.instance.version(), + description: p.instance.description().to_string(), + plugin_type: p.plugin_type, + }) + .collect() + } + + pub fn find_by_name(&self, name: &str) -> Option { + self.plugins + .iter() + .find(|(_, p)| p.instance.name() == name) + .map(|(id, _)| *id) + } + + pub fn discover(&mut self) -> Result> { + let mut loaded = Vec::new(); + + for search_path in self.search_paths.clone() { + if !search_path.exists() { + continue; + } + + let entries = std::fs::read_dir(&search_path).map_err(|e| { + PluginError::LoadFailed(format!( + "Cannot read plugin directory {}: {}", + search_path.display(), + e + )) + })?; + + for entry in entries.flatten() { + let path = entry.path(); + + if self.is_plugin_library(&path) { + match self.load(&path) { + Ok(id) => loaded.push(id), + Err(e) => { + warn!("Failed to load plugin {:?}: {}", path, e); + } + } + } + } + } + + Ok(loaded) + } + + fn verify_api_version(&self, library: &Library) -> Result<()> { + let version_fn: Symbol *const std::ffi::c_char> = unsafe { + library + .get(b"musicfs_plugin_api_version") + .map_err(|_| PluginError::SymbolNotFound("musicfs_plugin_api_version".to_string()))? + }; + + let version_ptr = unsafe { version_fn() }; + let version_str = unsafe { CStr::from_ptr(version_ptr) } + .to_str() + .map_err(|_| PluginError::VersionMismatch { + expected: PLUGIN_API_VERSION.to_string(), + actual: "".to_string(), + })?; + + let plugin_version = Version::parse(version_str).map_err(|_| PluginError::VersionMismatch { + expected: PLUGIN_API_VERSION.to_string(), + actual: version_str.to_string(), + })?; + + let expected_version = Version::parse(PLUGIN_API_VERSION).unwrap(); + + if plugin_version.major != expected_version.major { + return Err(PluginError::VersionMismatch { + expected: PLUGIN_API_VERSION.to_string(), + actual: version_str.to_string(), + }); + } + + Ok(()) + } + + fn create_plugin_instance(&self, library: &Library) -> Result> { + let create_fn: Symbol *mut dyn Plugin> = unsafe { + library + .get(b"musicfs_plugin_create") + .map_err(|_| PluginError::SymbolNotFound("musicfs_plugin_create".to_string()))? + }; + + let plugin_ptr = unsafe { create_fn() }; + if plugin_ptr.is_null() { + return Err(PluginError::LoadFailed( + "Plugin factory returned null".to_string(), + )); + } + + let plugin = unsafe { Box::from_raw(plugin_ptr) }; + Ok(plugin) + } + + fn detect_plugin_type(&self, plugin: &dyn Plugin) -> PluginType { + plugin.plugin_type() + } + + fn is_plugin_library(&self, path: &Path) -> bool { + let extension = path.extension().and_then(|e| e.to_str()); + + match extension { + Some("so") => true, + Some("dylib") => true, + Some("dll") => true, + _ => false, + } + } +} + +impl Default for NativePluginHost { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_native_host_creation() { + let host = NativePluginHost::new(); + assert!(host.plugins.is_empty()); + assert!(host.search_paths.is_empty()); + } + + #[test] + fn test_add_search_path() { + let mut host = NativePluginHost::new(); + host.add_search_path(PathBuf::from("/usr/lib/musicfs/plugins")); + host.add_search_path(PathBuf::from("/usr/lib/musicfs/plugins")); + + assert_eq!(host.search_paths.len(), 1); + } + + #[test] + fn test_is_plugin_library() { + let host = NativePluginHost::new(); + + assert!(host.is_plugin_library(Path::new("plugin.so"))); + assert!(host.is_plugin_library(Path::new("plugin.dylib"))); + assert!(host.is_plugin_library(Path::new("plugin.dll"))); + assert!(!host.is_plugin_library(Path::new("plugin.txt"))); + assert!(!host.is_plugin_library(Path::new("plugin"))); + } + + #[test] + fn test_load_nonexistent() { + let mut host = NativePluginHost::new(); + let result = host.load(Path::new("/nonexistent/plugin.so")); + assert!(result.is_err()); + } +} diff --git a/musicfs/crates/musicfs-plugins/src/traits.rs b/musicfs/crates/musicfs-plugins/src/traits.rs new file mode 100644 index 0000000..2fd6c63 --- /dev/null +++ b/musicfs/crates/musicfs-plugins/src/traits.rs @@ -0,0 +1,343 @@ +//! Plugin trait definitions (FR-23.1-23.4) +//! +//! Per architecture.md section 4.3.4: +//! - Plugin trait: Base interface for all plugins +//! - OriginPlugin: Creates Origin instances for storage backends +//! - MetadataPlugin: Provides external metadata lookup +//! - FormatPlugin: Handles custom audio format parsing + +use crate::error::Result; +use async_trait::async_trait; +use musicfs_core::AudioMeta; +use semver::Version; +use serde_json::Value; +use std::io::Read; + +/// Current plugin API version +pub const PLUGIN_API_VERSION: &str = "0.1.0"; + +/// Unique identifier for a loaded plugin +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PluginId(pub u64); + +impl PluginId { + pub fn new(id: u64) -> Self { + Self(id) + } +} + +/// Plugin metadata returned by plugins +#[derive(Debug, Clone)] +pub struct PluginInfo { + pub id: PluginId, + pub name: String, + pub version: Version, + pub description: String, + pub plugin_type: PluginType, +} + +/// Type of plugin +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginType { + Origin, + Metadata, + Format, +} + +/// Base plugin interface (FR-23.1) +/// +/// All plugins must implement this trait. It provides: +/// - Plugin identification (name, version) +/// - Lifecycle management (init, shutdown) +pub trait Plugin: Send + Sync { + /// Unique plugin name (e.g., "s3-origin", "musicbrainz-metadata") + fn name(&self) -> &str; + + /// Plugin version following semver + fn version(&self) -> Version; + + /// Human-readable description + fn description(&self) -> &str { + "" + } + + /// Plugin type for registry categorization + fn plugin_type(&self) -> PluginType; + + /// Initialize plugin with configuration + /// + /// Called once after loading. The config value contains + /// plugin-specific configuration from the main config file. + fn init(&mut self, config: Value) -> Result<()>; + + /// Shutdown plugin and release resources + /// + /// Called before unloading. Plugins should clean up any + /// resources (connections, file handles, etc). + fn shutdown(&mut self) -> Result<()>; +} + +/// Origin plugin interface (FR-23.3) +/// +/// Per architecture.md section 4.3.4: +/// Origin plugins create `Box` instances for custom storage backends. +/// +/// Example use cases: +/// - Google Drive origin +/// - Dropbox origin +/// - Custom NAS protocol +#[async_trait] +pub trait OriginPlugin: Plugin { + /// Origin type identifier (e.g., "gdrive", "dropbox") + fn origin_type(&self) -> &str; + + /// Create a new Origin instance with the given configuration + /// + /// The config contains origin-specific settings (credentials, paths, etc). + /// Returns a boxed Origin that can be used by the OriginRouter. + async fn create_origin( + &self, + id: &str, + config: Value, + ) -> Result>; +} + +/// Instance created by OriginPlugin +/// +/// This is a simplified async interface that maps to the full Origin trait. +/// The plugin host wraps this to provide the full Origin implementation. +#[async_trait] +pub trait OriginInstance: Send + Sync { + /// List directory contents + async fn readdir(&self, path: &str) -> Result>; + + /// Get file/directory stats + async fn stat(&self, path: &str) -> Result; + + /// Read file data + async fn read(&self, path: &str, offset: u64, size: u32) -> Result>; + + /// Check if path exists + async fn exists(&self, path: &str) -> Result; + + /// Health check + async fn health(&self) -> OriginHealth; + + /// Watch path for changes (FR-10.2) + async fn watch( + &self, + path: &str, + callback: Box, + ) -> Result; +} + +pub struct WatchHandle { + _cancel: tokio::sync::oneshot::Sender<()>, +} + +impl WatchHandle { + pub fn new(cancel: tokio::sync::oneshot::Sender<()>) -> Self { + Self { _cancel: cancel } + } +} + +#[derive(Debug, Clone)] +pub enum WatchEvent { + Created(String), + Modified(String), + Deleted(String), +} + +/// Directory entry from plugin origin +#[derive(Debug, Clone)] +pub struct OriginDirEntry { + pub name: String, + pub is_dir: bool, + pub size: u64, + pub mtime_secs: u64, +} + +/// File stats from plugin origin +#[derive(Debug, Clone)] +pub struct OriginStat { + pub size: u64, + pub mtime_secs: u64, + pub is_dir: bool, +} + +/// Origin health status +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OriginHealth { + Healthy, + Degraded, + Unhealthy, +} + +/// Metadata plugin interface (FR-23.3) +/// +/// Metadata plugins provide external metadata lookup from services like +/// MusicBrainz, Discogs, Last.fm, etc. +#[async_trait] +pub trait MetadataPlugin: Plugin { + /// Lookup metadata for a query + /// + /// Returns enriched metadata if found, None otherwise. + async fn lookup(&self, query: &MetadataQuery) -> Result>; + + /// Supported query types + fn supported_queries(&self) -> &[MetadataQueryType] { + &[MetadataQueryType::ByTitleArtist] + } +} + +/// Query for metadata lookup +#[derive(Debug, Clone)] +pub struct MetadataQuery { + pub query_type: MetadataQueryType, + pub title: Option, + pub artist: Option, + pub album: Option, + pub fingerprint: Option, + pub duration_ms: Option, +} + +/// Type of metadata query +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MetadataQueryType { + ByTitleArtist, + ByFingerprint, + ByAlbum, +} + +/// External metadata returned by plugins +#[derive(Debug, Clone, Default)] +pub struct ExternalMetadata { + pub title: Option, + pub artist: Option, + pub album: Option, + pub album_artist: Option, + pub genre: Option, + pub year: Option, + pub track: Option, + pub disc: Option, + pub musicbrainz_id: Option, + pub artwork_url: Option, +} + +/// Format plugin interface (FR-24.1) +/// +/// Format plugins handle custom audio formats not supported by symphonia. +/// +/// Example use cases: +/// - Custom lossless codecs +/// - Proprietary formats +/// - Game audio formats +pub trait FormatPlugin: Plugin { + /// File extensions this plugin handles + fn extensions(&self) -> &[&str]; + + /// Check if plugin can handle a specific extension + fn can_handle(&self, extension: &str) -> bool { + self.extensions() + .iter() + .any(|ext| ext.eq_ignore_ascii_case(extension)) + } + + /// Parse audio metadata from reader + /// + /// The reader provides the raw file bytes. Plugin should parse + /// and return AudioMeta with whatever metadata it can extract. + fn parse(&self, reader: &mut dyn Read) -> Result; + + /// Synthesize file header with updated metadata (FR-5.3) + /// + /// Creates a new file header containing the provided metadata. + /// Used for metadata overlay - serving cached metadata without + /// modifying the original file. + fn synthesize_header(&self, metadata: &AudioMeta) -> Result>; +} + +/// Declaration macro for native plugins +/// +/// Native plugins must export a function with this signature: +/// ```ignore +/// #[no_mangle] +/// pub extern "C" fn musicfs_plugin_create() -> *mut dyn Plugin +/// ``` +#[macro_export] +macro_rules! declare_plugin { + ($plugin_type:ty, $constructor:expr) => { + #[no_mangle] + pub extern "C" fn musicfs_plugin_create() -> *mut dyn $crate::Plugin { + let plugin = $constructor; + let boxed: Box = Box::new(plugin); + Box::into_raw(boxed) + } + + #[no_mangle] + pub extern "C" fn musicfs_plugin_api_version() -> *const std::ffi::c_char { + concat!($crate::PLUGIN_API_VERSION, "\0").as_ptr() as *const std::ffi::c_char + } + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestPlugin { + name: String, + initialized: bool, + } + + impl Plugin for TestPlugin { + fn name(&self) -> &str { + &self.name + } + + fn version(&self) -> Version { + Version::new(1, 0, 0) + } + + fn plugin_type(&self) -> PluginType { + PluginType::Origin + } + + fn init(&mut self, _config: Value) -> Result<()> { + self.initialized = true; + Ok(()) + } + + fn shutdown(&mut self) -> Result<()> { + self.initialized = false; + Ok(()) + } + } + + #[test] + fn test_plugin_lifecycle() { + let mut plugin = TestPlugin { + name: "test".to_string(), + initialized: false, + }; + + assert_eq!(plugin.name(), "test"); + assert!(!plugin.initialized); + + plugin.init(Value::Null).unwrap(); + assert!(plugin.initialized); + + plugin.shutdown().unwrap(); + assert!(!plugin.initialized); + } + + #[test] + fn test_plugin_id() { + let id1 = PluginId::new(1); + let id2 = PluginId::new(1); + let id3 = PluginId::new(2); + + assert_eq!(id1, id2); + assert_ne!(id1, id3); + } +} diff --git a/musicfs/crates/musicfs-plugins/src/wasm.rs b/musicfs/crates/musicfs-plugins/src/wasm.rs new file mode 100644 index 0000000..c84dda3 --- /dev/null +++ b/musicfs/crates/musicfs-plugins/src/wasm.rs @@ -0,0 +1,220 @@ +use crate::error::{PluginError, Result}; +use crate::traits::PluginId; + +#[cfg(feature = "wasm")] +use std::sync::atomic::{AtomicU64, Ordering}; + +#[cfg(feature = "wasm")] +static NEXT_WASM_PLUGIN_ID: AtomicU64 = AtomicU64::new(1_000_000); + +#[cfg(feature = "wasm")] +fn next_wasm_plugin_id() -> PluginId { + PluginId::new(NEXT_WASM_PLUGIN_ID.fetch_add(1, Ordering::SeqCst)) +} + +#[derive(Debug, Clone)] +pub struct ResourceLimits { + pub max_memory_mb: u32, + pub max_cpu_time_ms: u32, + pub allow_network: bool, + pub allow_filesystem: bool, +} + +impl Default for ResourceLimits { + fn default() -> Self { + Self { + max_memory_mb: 64, + max_cpu_time_ms: 5000, + allow_network: false, + allow_filesystem: false, + } + } +} + +#[cfg(feature = "wasm")] +mod wasm_impl { + use super::*; + use std::collections::HashMap; + use tracing::info; + use wasmtime::{Config, Engine, Linker, Module, Store}; + + pub struct PluginState { + limits: ResourceLimits, + } + + pub struct WasmPlugin { + id: PluginId, + name: String, + _module: Module, + } + + impl WasmPlugin { + pub fn id(&self) -> PluginId { + self.id + } + + pub fn name(&self) -> &str { + &self.name + } + } + + pub struct WasmPluginHost { + engine: Engine, + linker: Linker, + plugins: HashMap, + limits: ResourceLimits, + } + + impl WasmPluginHost { + pub fn new() -> Result { + let mut config = Config::new(); + config.consume_fuel(true); + config.epoch_interruption(true); + + let engine = Engine::new(&config) + .map_err(|e| PluginError::Wasm(format!("Failed to create WASM engine: {}", e)))?; + + let linker = Linker::new(&engine); + + Ok(Self { + engine, + linker, + plugins: HashMap::new(), + limits: ResourceLimits::default(), + }) + } + + pub fn set_limits(&mut self, limits: ResourceLimits) { + self.limits = limits; + } + + pub fn load(&mut self, wasm_bytes: &[u8]) -> Result { + info!("Loading WASM plugin ({} bytes)", wasm_bytes.len()); + + let module = Module::new(&self.engine, wasm_bytes) + .map_err(|e| PluginError::Wasm(format!("Failed to compile WASM module: {}", e)))?; + + let id = next_wasm_plugin_id(); + let name = module.name().unwrap_or("unnamed").to_string(); + + let plugin = WasmPlugin { + id, + name, + _module: module, + }; + + self.plugins.insert(id, plugin); + + Ok(id) + } + + pub fn unload(&mut self, id: PluginId) -> Result<()> { + self.plugins + .remove(&id) + .ok_or_else(|| PluginError::NotFound(format!("WASM plugin {:?}", id)))?; + Ok(()) + } + + pub fn get(&self, id: PluginId) -> Option<&WasmPlugin> { + self.plugins.get(&id) + } + + pub fn list(&self) -> Vec<(PluginId, &str)> { + self.plugins.iter().map(|(id, p)| (*id, p.name())).collect() + } + + fn create_store(&self) -> Store { + let state = PluginState { + limits: self.limits.clone(), + }; + + let mut store = Store::new(&self.engine, state); + + let fuel = (self.limits.max_cpu_time_ms as u64) * 1_000_000; + store.set_fuel(fuel).ok(); + + store + } + } + + impl Default for WasmPluginHost { + fn default() -> Self { + Self::new().expect("Failed to create WASM host") + } + } +} + +#[cfg(not(feature = "wasm"))] +mod wasm_stub { + use super::*; + + pub struct WasmPluginHost { + limits: ResourceLimits, + } + + impl WasmPluginHost { + pub fn new() -> Result { + Ok(Self { + limits: ResourceLimits::default(), + }) + } + + pub fn set_limits(&mut self, limits: ResourceLimits) { + self.limits = limits; + } + + pub fn load(&mut self, _wasm_bytes: &[u8]) -> Result { + Err(PluginError::Wasm( + "WASM support not enabled. Compile with --features wasm".to_string(), + )) + } + + pub fn unload(&mut self, _id: PluginId) -> Result<()> { + Err(PluginError::Wasm("WASM support not enabled".to_string())) + } + + pub fn list(&self) -> Vec<(PluginId, &str)> { + Vec::new() + } + } + + impl Default for WasmPluginHost { + fn default() -> Self { + Self::new().expect("Failed to create WASM stub host") + } + } +} + +#[cfg(feature = "wasm")] +pub use wasm_impl::*; + +#[cfg(not(feature = "wasm"))] +pub use wasm_stub::*; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resource_limits_default() { + let limits = ResourceLimits::default(); + assert_eq!(limits.max_memory_mb, 64); + assert_eq!(limits.max_cpu_time_ms, 5000); + assert!(!limits.allow_network); + assert!(!limits.allow_filesystem); + } + + #[test] + fn test_wasm_host_creation() { + let host = WasmPluginHost::new(); + assert!(host.is_ok()); + } + + #[test] + #[cfg(not(feature = "wasm"))] + fn test_wasm_disabled_load_fails() { + let mut host = WasmPluginHost::new().unwrap(); + let result = host.load(&[0x00, 0x61, 0x73, 0x6d]); + assert!(result.is_err()); + } +} diff --git a/musicfs/dist/PKGBUILD b/musicfs/dist/PKGBUILD new file mode 100644 index 0000000..a4cc8c3 --- /dev/null +++ b/musicfs/dist/PKGBUILD @@ -0,0 +1,23 @@ +pkgname=musicfs +pkgver=0.1.0 +pkgrel=1 +pkgdesc="Metadata-Organized Music Filesystem" +arch=('x86_64') +url="https://github.com/yourusername/musicfs" +license=('MIT') +depends=('fuse3') +makedepends=('rust' 'cargo') +source=("$pkgname-$pkgver.tar.gz") +sha256sums=('SKIP') + +build() { + cd "$srcdir/$pkgname-$pkgver" + cargo build --release --locked +} + +package() { + cd "$srcdir/$pkgname-$pkgver" + install -Dm755 "target/release/musicfs" "$pkgdir/usr/bin/musicfs" + install -Dm644 "dist/musicfs.service" "$pkgdir/usr/lib/systemd/system/musicfs.service" + install -Dm644 "config.example.toml" "$pkgdir/etc/musicfs/config.example.toml" +} diff --git a/musicfs/dist/musicfs.service b/musicfs/dist/musicfs.service new file mode 100644 index 0000000..7e54350 --- /dev/null +++ b/musicfs/dist/musicfs.service @@ -0,0 +1,21 @@ +[Unit] +Description=MusicFS - Metadata-Organized Music Filesystem +After=network.target + +[Service] +Type=notify +ExecStart=/usr/bin/musicfs mount --config /etc/musicfs/config.toml /mnt/music +ExecStop=/usr/bin/musicfs shutdown +Restart=on-failure +RestartSec=5 +User=musicfs +Group=musicfs + +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/var/cache/musicfs /mnt/music +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/musicfs/dist/musicfs.spec b/musicfs/dist/musicfs.spec new file mode 100644 index 0000000..800da91 --- /dev/null +++ b/musicfs/dist/musicfs.spec @@ -0,0 +1,39 @@ +Name: musicfs +Version: 0.1.0 +Release: 1%{?dist} +Summary: Metadata-Organized Music Filesystem + +License: MIT +URL: https://github.com/yourusername/musicfs +Source0: %{name}-%{version}.tar.gz + +BuildRequires: rust >= 1.70 +BuildRequires: cargo +BuildRequires: fuse3-devel + +Requires: fuse3 + +%description +MusicFS is a virtual FUSE filesystem that organizes music files by metadata. + +%prep +%autosetup + +%build +cargo build --release --locked + +%install +install -Dm755 target/release/musicfs %{buildroot}%{_bindir}/musicfs +install -Dm644 dist/musicfs.service %{buildroot}%{_unitdir}/musicfs.service +install -Dm644 config.example.toml %{buildroot}%{_sysconfdir}/musicfs/config.example.toml + +%files +%license LICENSE +%doc README.md +%{_bindir}/musicfs +%{_unitdir}/musicfs.service +%config(noreplace) %{_sysconfdir}/musicfs/config.example.toml + +%changelog +* Mon Jan 01 2024 MusicFS Team - 0.1.0-1 +- Initial package diff --git a/musicfs/tests/e2e/e2e_players.rs b/musicfs/tests/e2e/e2e_players.rs new file mode 100644 index 0000000..149ee48 --- /dev/null +++ b/musicfs/tests/e2e/e2e_players.rs @@ -0,0 +1,90 @@ +use std::process::Command; + +#[test] +#[ignore] +fn test_mpv_playback() { + let mountpoint = setup_test_mount(); + + let output = Command::new("mpv") + .args([ + "--no-video", + "--no-audio", + "--length=2", + "--msg-level=all=debug", + &format!("{}/Artist/Album/01 - Track.flac", mountpoint), + ]) + .output() + .expect("mpv must be installed"); + + assert!( + output.status.success(), + "mpv playback failed: {:?}", + output + ); +} + +#[test] +#[ignore] +fn test_vlc_playback() { + let mountpoint = setup_test_mount(); + + let output = Command::new("cvlc") + .args([ + "--play-and-exit", + "--run-time=2", + &format!("{}/Artist/Album/", mountpoint), + ]) + .output() + .expect("vlc must be installed"); + + assert!(output.status.success(), "VLC playback failed"); +} + +#[test] +#[ignore] +fn test_file_manager_operations() { + let mountpoint = setup_test_mount(); + + let entries: Vec<_> = std::fs::read_dir(&mountpoint) + .expect("read_dir failed") + .collect(); + + assert!(!entries.is_empty(), "mountpoint should have entries"); + + for entry in entries { + let entry = entry.expect("entry should be valid"); + let metadata = entry.metadata().expect("metadata should work"); + assert!(metadata.is_dir() || metadata.is_file()); + } +} + +#[test] +#[ignore] +fn test_concurrent_player_access() { + let mountpoint = setup_test_mount(); + + let handles: Vec<_> = (0..3) + .map(|i| { + let mp = mountpoint.clone(); + std::thread::spawn(move || { + Command::new("mpv") + .args([ + "--no-video", + "--no-audio", + "--length=1", + &format!("{}/Artist/Album/0{} - Track.flac", mp, i + 1), + ]) + .output() + }) + }) + .collect(); + + for handle in handles { + let output = handle.join().unwrap().expect("mpv should run"); + assert!(output.status.success()); + } +} + +fn setup_test_mount() -> String { + std::env::var("MUSICFS_TEST_MOUNT").unwrap_or_else(|_| "/tmp/musicfs-test".to_string()) +}