Move the files around
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "musicfs-grpc"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
musicfs-search = { path = "../musicfs-search" }
|
||||
musicfs-core = { path = "../musicfs-core" }
|
||||
tonic.workspace = true
|
||||
prost.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.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
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,4 @@
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tonic_build::compile_protos("proto/musicfs.proto")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
syntax = "proto3";
|
||||
|
||||
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;
|
||||
optional uint32 offset = 3;
|
||||
optional string origin_id = 4;
|
||||
}
|
||||
|
||||
message SearchResponse {
|
||||
repeated SearchResult results = 1;
|
||||
uint64 total_matches = 2;
|
||||
uint32 query_time_ms = 3;
|
||||
}
|
||||
|
||||
message SearchResult {
|
||||
int64 file_id = 1;
|
||||
string virtual_path = 2;
|
||||
optional string artist = 3;
|
||||
optional string album = 4;
|
||||
optional string title = 5;
|
||||
float score = 6;
|
||||
map<string, string> 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<string, string> metadata = 6;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
pub mod proto {
|
||||
pub mod musicfs {
|
||||
pub mod v1 {
|
||||
tonic::include_proto!("musicfs.v1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod search_service;
|
||||
mod server;
|
||||
mod webhook;
|
||||
|
||||
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};
|
||||
@@ -0,0 +1,251 @@
|
||||
use crate::proto::musicfs::v1::{
|
||||
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;
|
||||
|
||||
pub struct SearchService {
|
||||
index: Arc<SearchIndex>,
|
||||
}
|
||||
|
||||
impl SearchService {
|
||||
pub fn new(index: Arc<SearchIndex>) -> Self {
|
||||
Self { index }
|
||||
}
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl MusicFs for SearchService {
|
||||
async fn search(
|
||||
&self,
|
||||
request: Request<SearchRequest>,
|
||||
) -> Result<Response<SearchResponse>, Status> {
|
||||
let start = Instant::now();
|
||||
let req = request.into_inner();
|
||||
|
||||
if req.query.is_empty() {
|
||||
return Err(Status::invalid_argument("Query cannot be empty"));
|
||||
}
|
||||
|
||||
if req.query.len() > 256 {
|
||||
return Err(Status::invalid_argument(
|
||||
"Query exceeds maximum length (256)",
|
||||
));
|
||||
}
|
||||
|
||||
let limit = req.limit.unwrap_or(100).min(10000) as usize;
|
||||
let offset = req.offset.unwrap_or(0) as usize;
|
||||
|
||||
let results = self
|
||||
.index
|
||||
.search(&req.query, limit + offset)
|
||||
.map_err(|e| Status::internal(format!("Search failed: {}", e)))?;
|
||||
|
||||
let hits: Vec<SearchResult> = results
|
||||
.into_iter()
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.map(|hit| SearchResult {
|
||||
file_id: hit.file_id.0,
|
||||
virtual_path: hit.virtual_path.as_str().to_string(),
|
||||
artist: hit.artist,
|
||||
album: hit.album,
|
||||
title: hit.title,
|
||||
score: hit.score,
|
||||
highlights: Default::default(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total_matches = self.index.count();
|
||||
let query_time_ms = start.elapsed().as_millis() as u32;
|
||||
|
||||
debug!(
|
||||
"Search '{}' returned {} results in {}ms",
|
||||
req.query,
|
||||
hits.len(),
|
||||
query_time_ms
|
||||
);
|
||||
|
||||
Ok(Response::new(SearchResponse {
|
||||
results: hits,
|
||||
total_matches,
|
||||
query_time_ms,
|
||||
}))
|
||||
}
|
||||
|
||||
type SearchStreamStream = ReceiverStream<Result<SearchResult, Status>>;
|
||||
|
||||
async fn search_stream(
|
||||
&self,
|
||||
request: Request<SearchRequest>,
|
||||
) -> Result<Response<Self::SearchStreamStream>, Status> {
|
||||
let req = request.into_inner();
|
||||
|
||||
if req.query.is_empty() {
|
||||
return Err(Status::invalid_argument("Query cannot be empty"));
|
||||
}
|
||||
|
||||
let limit = req.limit.unwrap_or(1000).min(10000) as usize;
|
||||
|
||||
let results = self
|
||||
.index
|
||||
.search(&req.query, limit)
|
||||
.map_err(|e| Status::internal(format!("Search failed: {}", e)))?;
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(100);
|
||||
|
||||
tokio::spawn(async move {
|
||||
for hit in results {
|
||||
let result = SearchResult {
|
||||
file_id: hit.file_id.0,
|
||||
virtual_path: hit.virtual_path.as_str().to_string(),
|
||||
artist: hit.artist,
|
||||
album: hit.album,
|
||||
title: hit.title,
|
||||
score: hit.score,
|
||||
highlights: Default::default(),
|
||||
};
|
||||
if tx.send(Ok(result)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Response::new(ReceiverStream::new(rx)))
|
||||
}
|
||||
|
||||
async fn get_status(
|
||||
&self,
|
||||
_request: Request<Empty>,
|
||||
) -> Result<Response<StatusResponse>, Status> {
|
||||
Err(Status::unimplemented(
|
||||
"Use MusicFsServer for control operations",
|
||||
))
|
||||
}
|
||||
|
||||
async fn shutdown(
|
||||
&self,
|
||||
_request: Request<ShutdownRequest>,
|
||||
) -> Result<Response<Empty>, Status> {
|
||||
Err(Status::unimplemented(
|
||||
"Use MusicFsServer for control operations",
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_cache_stats(
|
||||
&self,
|
||||
_request: Request<Empty>,
|
||||
) -> Result<Response<CacheStats>, Status> {
|
||||
Err(Status::unimplemented(
|
||||
"Use MusicFsServer for control operations",
|
||||
))
|
||||
}
|
||||
|
||||
async fn clear_cache(
|
||||
&self,
|
||||
_request: Request<ClearCacheRequest>,
|
||||
) -> Result<Response<ClearCacheResponse>, Status> {
|
||||
Err(Status::unimplemented(
|
||||
"Use MusicFsServer for control operations",
|
||||
))
|
||||
}
|
||||
|
||||
type PrefetchStream = ReceiverStream<Result<PrefetchProgress, Status>>;
|
||||
|
||||
async fn prefetch(
|
||||
&self,
|
||||
_request: Request<PrefetchRequest>,
|
||||
) -> Result<Response<Self::PrefetchStream>, Status> {
|
||||
Err(Status::unimplemented(
|
||||
"Use MusicFsServer for control operations",
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_origins(
|
||||
&self,
|
||||
_request: Request<Empty>,
|
||||
) -> Result<Response<OriginsResponse>, Status> {
|
||||
Err(Status::unimplemented(
|
||||
"Use MusicFsServer for control operations",
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_origin_health(
|
||||
&self,
|
||||
_request: Request<OriginRequest>,
|
||||
) -> Result<Response<OriginHealthResponse>, Status> {
|
||||
Err(Status::unimplemented(
|
||||
"Use MusicFsServer for control operations",
|
||||
))
|
||||
}
|
||||
|
||||
type RescanOriginStream = ReceiverStream<Result<SyncProgress, Status>>;
|
||||
|
||||
async fn rescan_origin(
|
||||
&self,
|
||||
_request: Request<OriginRequest>,
|
||||
) -> Result<Response<Self::RescanOriginStream>, Status> {
|
||||
Err(Status::unimplemented(
|
||||
"Use MusicFsServer for control operations",
|
||||
))
|
||||
}
|
||||
|
||||
type SubscribeEventsStream = ReceiverStream<Result<Event, Status>>;
|
||||
|
||||
async fn subscribe_events(
|
||||
&self,
|
||||
_request: Request<EventFilter>,
|
||||
) -> Result<Response<Self::SubscribeEventsStream>, Status> {
|
||||
Err(Status::unimplemented(
|
||||
"Use MusicFsServer for control operations",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_grpc_search_empty_query() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let index = Arc::new(SearchIndex::open(dir.path()).unwrap());
|
||||
let service = SearchService::new(index);
|
||||
|
||||
let request = Request::new(SearchRequest {
|
||||
query: String::new(),
|
||||
limit: Some(10),
|
||||
offset: None,
|
||||
origin_id: None,
|
||||
});
|
||||
|
||||
let result = service.search(request).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err().code(), tonic::Code::InvalidArgument);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_grpc_search_returns_response() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let index = Arc::new(SearchIndex::open(dir.path()).unwrap());
|
||||
let service = SearchService::new(index);
|
||||
|
||||
let request = Request::new(SearchRequest {
|
||||
query: "test".to_string(),
|
||||
limit: Some(10),
|
||||
offset: None,
|
||||
origin_id: None,
|
||||
});
|
||||
|
||||
let response = service.search(request).await.unwrap();
|
||||
assert!(response.get_ref().results.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
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, instrument};
|
||||
|
||||
pub struct MusicFsServer {
|
||||
start_time: Instant,
|
||||
event_bus: Arc<EventBus>,
|
||||
version: String,
|
||||
}
|
||||
|
||||
impl MusicFsServer {
|
||||
pub fn new(event_bus: Arc<EventBus>) -> 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<SearchRequest>,
|
||||
) -> Result<Response<SearchResponse>, Status> {
|
||||
Err(Status::unimplemented(
|
||||
"Use SearchService for search operations",
|
||||
))
|
||||
}
|
||||
|
||||
type SearchStreamStream = ReceiverStream<Result<SearchResult, Status>>;
|
||||
|
||||
async fn search_stream(
|
||||
&self,
|
||||
_request: Request<SearchRequest>,
|
||||
) -> Result<Response<Self::SearchStreamStream>, Status> {
|
||||
Err(Status::unimplemented(
|
||||
"Use SearchService for search operations",
|
||||
))
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, _request), fields(method = "get_status"))]
|
||||
async fn get_status(
|
||||
&self,
|
||||
_request: Request<Empty>,
|
||||
) -> Result<Response<StatusResponse>, Status> {
|
||||
debug!("gRPC get_status called");
|
||||
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![],
|
||||
}))
|
||||
}
|
||||
|
||||
#[instrument(level = "info", skip(self, request), fields(method = "shutdown"))]
|
||||
async fn shutdown(&self, request: Request<ShutdownRequest>) -> Result<Response<Empty>, Status> {
|
||||
let req = request.into_inner();
|
||||
info!(
|
||||
graceful = req.graceful,
|
||||
timeout_secs = req.timeout_secs,
|
||||
"gRPC shutdown requested"
|
||||
);
|
||||
|
||||
Ok(Response::new(Empty {}))
|
||||
}
|
||||
|
||||
#[instrument(
|
||||
level = "debug",
|
||||
skip(self, _request),
|
||||
fields(method = "get_cache_stats")
|
||||
)]
|
||||
async fn get_cache_stats(
|
||||
&self,
|
||||
_request: Request<Empty>,
|
||||
) -> Result<Response<CacheStats>, Status> {
|
||||
debug!("gRPC get_cache_stats called");
|
||||
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,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
#[instrument(level = "info", skip(self, request), fields(method = "clear_cache"))]
|
||||
async fn clear_cache(
|
||||
&self,
|
||||
request: Request<ClearCacheRequest>,
|
||||
) -> Result<Response<ClearCacheResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
info!(
|
||||
origin_id = ?req.origin_id,
|
||||
clear_metadata = req.clear_metadata,
|
||||
clear_chunks = req.clear_chunks,
|
||||
"gRPC clear_cache"
|
||||
);
|
||||
|
||||
Ok(Response::new(ClearCacheResponse {
|
||||
bytes_cleared: 0,
|
||||
chunks_cleared: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
type PrefetchStream = ReceiverStream<Result<PrefetchProgress, Status>>;
|
||||
|
||||
#[instrument(level = "debug", skip(self, request), fields(method = "prefetch"))]
|
||||
async fn prefetch(
|
||||
&self,
|
||||
request: Request<PrefetchRequest>,
|
||||
) -> Result<Response<Self::PrefetchStream>, Status> {
|
||||
let req = request.into_inner();
|
||||
let total = req.paths.len() as u32;
|
||||
debug!(file_count = total, "gRPC prefetch started");
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, _request), fields(method = "list_origins"))]
|
||||
async fn list_origins(
|
||||
&self,
|
||||
_request: Request<Empty>,
|
||||
) -> Result<Response<OriginsResponse>, Status> {
|
||||
debug!("gRPC list_origins called");
|
||||
Ok(Response::new(OriginsResponse { origins: vec![] }))
|
||||
}
|
||||
|
||||
#[instrument(
|
||||
level = "debug",
|
||||
skip(self, request),
|
||||
fields(method = "get_origin_health")
|
||||
)]
|
||||
async fn get_origin_health(
|
||||
&self,
|
||||
request: Request<OriginRequest>,
|
||||
) -> Result<Response<OriginHealthResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
debug!(origin_id = %req.origin_id, "gRPC get_origin_health");
|
||||
|
||||
Ok(Response::new(OriginHealthResponse {
|
||||
origin_id: req.origin_id,
|
||||
status: HealthStatus::HealthUnknown as i32,
|
||||
message: None,
|
||||
last_check_secs: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
type RescanOriginStream = ReceiverStream<Result<SyncProgress, Status>>;
|
||||
|
||||
#[instrument(level = "info", skip(self, request), fields(method = "rescan_origin"))]
|
||||
async fn rescan_origin(
|
||||
&self,
|
||||
request: Request<OriginRequest>,
|
||||
) -> Result<Response<Self::RescanOriginStream>, Status> {
|
||||
let req = request.into_inner();
|
||||
info!(origin_id = %req.origin_id, "gRPC rescan_origin started");
|
||||
|
||||
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<Result<Event, Status>>;
|
||||
|
||||
#[instrument(
|
||||
level = "info",
|
||||
skip(self, request),
|
||||
fields(method = "subscribe_events")
|
||||
)]
|
||||
async fn subscribe_events(
|
||||
&self,
|
||||
request: Request<EventFilter>,
|
||||
) -> Result<Response<Self::SubscribeEventsStream>, Status> {
|
||||
info!("gRPC subscribe_events: client connected");
|
||||
let filter = request.into_inner();
|
||||
let mut rx = self.event_bus.subscribe();
|
||||
let (tx, out_rx) = mpsc::channel(100);
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
if Self::matches_filter(&event, &filter) {
|
||||
let proto_event = Self::event_to_proto(&event);
|
||||
if tx.send(Ok(proto_event)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!(skipped = n, "Event subscriber lagged, skipped events");
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
tracing::debug!("Event channel closed");
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
use musicfs_core::Event;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct WebhookPayload {
|
||||
pub event_type: String,
|
||||
pub timestamp: i64,
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct WebhookConfig {
|
||||
pub url: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub secret: Option<String>,
|
||||
pub events: Vec<String>,
|
||||
#[serde(default = "default_retry_count")]
|
||||
pub retry_count: u32,
|
||||
#[serde(default = "default_timeout_ms")]
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WebhookConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("WebhookConfig")
|
||||
.field("url", &self.url)
|
||||
.field("secret", &self.secret.as_ref().map(|_| "[REDACTED]"))
|
||||
.field("events", &self.events)
|
||||
.field("retry_count", &self.retry_count)
|
||||
.field("timeout_ms", &self.timeout_ms)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_retry_count() -> u32 {
|
||||
3
|
||||
}
|
||||
|
||||
fn default_timeout_ms() -> u64 {
|
||||
5000
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WebhookError {
|
||||
#[error("Failed to initialize HTTP client: {0}")]
|
||||
ClientInit(String),
|
||||
}
|
||||
|
||||
pub struct WebhookHandler {
|
||||
client: reqwest::Client,
|
||||
configs: Vec<WebhookConfig>,
|
||||
}
|
||||
|
||||
impl WebhookHandler {
|
||||
pub fn new(configs: Vec<WebhookConfig>) -> Result<Self, WebhookError> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
error!(error = %e, "Failed to create webhook HTTP client");
|
||||
WebhookError::ClientInit(e.to_string())
|
||||
})?;
|
||||
|
||||
Ok(Self { client, configs })
|
||||
}
|
||||
|
||||
pub async fn run(&self, mut rx: broadcast::Receiver<Event>) {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
for config in &self.configs {
|
||||
if self.matches_filter(&event, config) {
|
||||
self.dispatch(config, &event).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(skipped = n, "Webhook handler lagged, skipped events");
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
debug!("Event channel closed, webhook handler stopping");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<Sha256>;
|
||||
|
||||
let body = serde_json::to_string(payload).unwrap_or_default();
|
||||
let mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!(error = %e, "Invalid HMAC key for webhook signature");
|
||||
return String::new();
|
||||
}
|
||||
};
|
||||
let mut mac = mac;
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user