Define endpoints to update metadata
This commit is contained in:
@@ -36,3 +36,4 @@ chrono.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
sea-orm = { workspace = true, features = ["mock"] }
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use fuser::INodeNo;
|
||||
use musicfs_core::music::encoder::MusicMetadataEncoderFactory;
|
||||
use musicfs_proto::{
|
||||
ClientControl, GetMusicMetadataRequest, GetMusicMetadataResponse,
|
||||
MusicMetadata as ProtoMusicMetadata, PictureDataRange, UpdateMusicMetadataRequest,
|
||||
UpdateMusicMetadataResponse,
|
||||
};
|
||||
use sea_orm::DatabaseConnection;
|
||||
use tonic::{Request, Response, Status};
|
||||
|
||||
use crate::item::Item;
|
||||
use crate::music::db::save_music_metadata;
|
||||
|
||||
pub struct ClientControlServiceImpl {
|
||||
files: Arc<Mutex<BTreeMap<INodeNo, Item>>>,
|
||||
db: DatabaseConnection,
|
||||
}
|
||||
|
||||
impl ClientControlServiceImpl {
|
||||
pub fn new(files: Arc<Mutex<BTreeMap<INodeNo, Item>>>, db: DatabaseConnection) -> Self {
|
||||
ClientControlServiceImpl { files, db }
|
||||
}
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl ClientControl for ClientControlServiceImpl {
|
||||
async fn get_music_metadata(
|
||||
&self,
|
||||
request: Request<GetMusicMetadataRequest>,
|
||||
) -> Result<Response<GetMusicMetadataResponse>, Status> {
|
||||
let inode = INodeNo(request.into_inner().inode);
|
||||
let metadata = {
|
||||
let files = self.files.lock().unwrap();
|
||||
let item = files
|
||||
.get(&inode)
|
||||
.ok_or_else(|| Status::not_found(format!("inode {} not found", inode.0)))?;
|
||||
item.music_metadata.clone().ok_or_else(|| {
|
||||
Status::not_found(format!("inode {} has no music metadata", inode.0))
|
||||
})?
|
||||
};
|
||||
Ok(Response::new(GetMusicMetadataResponse {
|
||||
metadata: Some(music_metadata_to_proto(metadata)),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn update_music_metadata(
|
||||
&self,
|
||||
request: Request<UpdateMusicMetadataRequest>,
|
||||
) -> Result<Response<UpdateMusicMetadataResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
let inode = INodeNo(req.inode);
|
||||
|
||||
let final_metadata = {
|
||||
let mut files = self.files.lock().unwrap();
|
||||
let item = files
|
||||
.get_mut(&inode)
|
||||
.ok_or_else(|| Status::not_found(format!("inode {} not found", inode.0)))?;
|
||||
let mm = item.music_metadata.as_mut().ok_or_else(|| {
|
||||
Status::not_found(format!("inode {} has no music metadata", inode.0))
|
||||
})?;
|
||||
|
||||
mm.artist = req.artist;
|
||||
mm.album_artist = req.album_artist;
|
||||
mm.album = req.album;
|
||||
mm.track_number = req.track_number;
|
||||
mm.track_title = req.track_title;
|
||||
mm.other_tags = req.other_tags;
|
||||
|
||||
let encoder =
|
||||
MusicMetadataEncoderFactory::for_path(&item.local_path).ok_or_else(|| {
|
||||
Status::failed_precondition(format!(
|
||||
"no encoder for file type: {}",
|
||||
item.local_path.display()
|
||||
))
|
||||
})?;
|
||||
encoder.encode(mm);
|
||||
|
||||
mm.clone()
|
||||
};
|
||||
|
||||
save_music_metadata(inode.0 as i64, &final_metadata, &self.db)
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("DB save failed: {e}")))?;
|
||||
|
||||
tracing::debug!(ino = inode.0, "control: music metadata updated via RPC");
|
||||
|
||||
Ok(Response::new(UpdateMusicMetadataResponse {
|
||||
metadata: Some(music_metadata_to_proto(final_metadata)),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn music_metadata_to_proto(mm: crate::music::metadata::MusicMetadata) -> ProtoMusicMetadata {
|
||||
ProtoMusicMetadata {
|
||||
artist: mm.artist,
|
||||
album_artist: mm.album_artist,
|
||||
album: mm.album,
|
||||
track_number: mm.track_number,
|
||||
track_title: mm.track_title,
|
||||
other_tags: mm.other_tags,
|
||||
header: mm.header,
|
||||
picture_block_headers: mm.picture_block_headers,
|
||||
picture_data_ranges: mm
|
||||
.picture_data_ranges
|
||||
.into_iter()
|
||||
.map(|(offset, length)| PictureDataRange { offset, length })
|
||||
.collect(),
|
||||
real_audio_start: mm.real_audio_start,
|
||||
vorbis_comment_offset: mm.vorbis_comment_offset,
|
||||
vorbis_comment_length: mm.vorbis_comment_length,
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@ use std::{
|
||||
|
||||
use fuser::INodeNo;
|
||||
use musicfs_proto::{
|
||||
ClientStatus, ClientStatusResponse, ClientStatusServer, GetClientStatusRequest,
|
||||
ClientControlServer, ClientStatus, ClientStatusResponse, ClientStatusServer,
|
||||
GetClientStatusRequest,
|
||||
};
|
||||
use tokio::time::Duration;
|
||||
use tonic::{Request, Response, transport::Server};
|
||||
@@ -62,10 +63,11 @@ impl ServerStatus {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn spawn_health_server(
|
||||
pub async fn spawn_grpc_server(
|
||||
addr: SocketAddr,
|
||||
status: ServerStatus,
|
||||
files: Arc<Mutex<BTreeMap<INodeNo, crate::item::Item>>>,
|
||||
db: sea_orm::DatabaseConnection,
|
||||
) {
|
||||
let (reporter, health_service) = tonic_health::server::health_reporter();
|
||||
reporter
|
||||
@@ -106,16 +108,22 @@ pub async fn spawn_health_server(
|
||||
.build_v1alpha()
|
||||
.expect("build reflection v1alpha");
|
||||
|
||||
let svc = ClientStatusServer::new(ClientStatusServiceImpl { status, files });
|
||||
let status_svc = ClientStatusServer::new(ClientStatusServiceImpl {
|
||||
status,
|
||||
files: files.clone(),
|
||||
});
|
||||
let control_svc =
|
||||
ClientControlServer::new(crate::control::ClientControlServiceImpl::new(files, db));
|
||||
|
||||
tracing::info!(%addr, "health server listening");
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = Server::builder()
|
||||
.add_service(health_service)
|
||||
.add_service(status_svc)
|
||||
.add_service(reflection_v1)
|
||||
.add_service(reflection_v1alpha)
|
||||
.add_service(svc)
|
||||
.add_service(control_svc)
|
||||
.serve(addr)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod control;
|
||||
pub mod db;
|
||||
pub mod health;
|
||||
pub mod item;
|
||||
|
||||
@@ -128,6 +128,7 @@ async fn main() {
|
||||
Arc::new(std::sync::Mutex::new(snapshot));
|
||||
let watcher_handle = watcher.watch(files.clone());
|
||||
let health_files = files.clone();
|
||||
let grpc_db = db.clone();
|
||||
|
||||
let fs = FuseFs {
|
||||
files,
|
||||
@@ -143,7 +144,7 @@ async fn main() {
|
||||
});
|
||||
info!(mountpoint = %mountpoint, "FUSE mounted");
|
||||
|
||||
musicfs::health::spawn_health_server(args.listen, server_status, health_files).await;
|
||||
musicfs::health::spawn_grpc_server(args.listen, server_status, health_files, grpc_db).await;
|
||||
|
||||
let mut sigint = signal(SignalKind::interrupt()).unwrap_or_else(|e| {
|
||||
error!(error = %e, "failed to register SIGINT handler");
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::item::Item;
|
||||
use crate::music::metadata::MusicMetadata;
|
||||
use tracing::{debug, error};
|
||||
|
||||
mod entities {
|
||||
pub mod entities {
|
||||
pub mod music_metadata {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{Arc, Mutex},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
use fuser::INodeNo;
|
||||
use musicfs::control::ClientControlServiceImpl;
|
||||
use musicfs::item::{FileType, Item};
|
||||
use musicfs::music::db::entities::{artists, music_metadata as mm_entity};
|
||||
use musicfs::music::metadata::MusicMetadata;
|
||||
use musicfs::origins::attrs::FileAttrs;
|
||||
use musicfs_proto::{ClientControl, GetMusicMetadataRequest, UpdateMusicMetadataRequest};
|
||||
use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
|
||||
use tonic::{Code, Request};
|
||||
|
||||
// ── Test helpers ───────────────────────────────────────────────────────
|
||||
|
||||
fn make_attrs() -> FileAttrs {
|
||||
FileAttrs {
|
||||
size: 4096,
|
||||
blocks: 8,
|
||||
atime: SystemTime::UNIX_EPOCH,
|
||||
mtime: SystemTime::UNIX_EPOCH,
|
||||
ctime: SystemTime::UNIX_EPOCH,
|
||||
crtime: SystemTime::UNIX_EPOCH,
|
||||
perm: 0o644,
|
||||
nlink: 1,
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
rdev: 0,
|
||||
blksize: 4096,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_flac_item(inode: u64, name: &str, metadata: Option<MusicMetadata>) -> Item {
|
||||
Item {
|
||||
inode: INodeNo(inode),
|
||||
parent_inode: INodeNo(1),
|
||||
name: name.to_string(),
|
||||
original_path: format!("/{name}").into(),
|
||||
local_path: format!("Artist/Album/{name}").into(),
|
||||
file_type: FileType::File,
|
||||
attrs: make_attrs(),
|
||||
music_metadata: metadata,
|
||||
hash: inode,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_flac_metadata() -> MusicMetadata {
|
||||
MusicMetadata {
|
||||
artist: vec!["Test Artist".to_string()],
|
||||
album_artist: Some("Test Artist".to_string()),
|
||||
album: "Test Album".to_string(),
|
||||
track_number: 1,
|
||||
track_title: "Test Track".to_string(),
|
||||
other_tags: vec![],
|
||||
header: vec![],
|
||||
picture_block_headers: vec![],
|
||||
picture_data_ranges: vec![],
|
||||
real_audio_start: 0,
|
||||
vorbis_comment_offset: 0,
|
||||
vorbis_comment_length: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock DB for get tests (no INSERT operations expected).
|
||||
fn mock_db_readonly() -> sea_orm::DatabaseConnection {
|
||||
MockDatabase::new(DatabaseBackend::Postgres).into_connection()
|
||||
}
|
||||
|
||||
/// Mock DB for update with one artist.
|
||||
/// save_music_metadata does: DELETE, INSERT music_metadata, INSERT artists.
|
||||
fn mock_db_update_with_artist(inode: i64) -> sea_orm::DatabaseConnection {
|
||||
MockDatabase::new(DatabaseBackend::Postgres)
|
||||
.append_exec_results([MockExecResult {
|
||||
rows_affected: 1,
|
||||
..Default::default()
|
||||
}])
|
||||
.append_query_results([vec![mm_entity::Model {
|
||||
inode,
|
||||
track_title: String::new(),
|
||||
album: String::new(),
|
||||
track_number: 0,
|
||||
header: vec![],
|
||||
real_audio_start: 0,
|
||||
}]])
|
||||
.append_query_results([vec![artists::Model {
|
||||
inode,
|
||||
artist: String::new(),
|
||||
}]])
|
||||
.into_connection()
|
||||
}
|
||||
|
||||
/// Mock DB for update with no artists.
|
||||
/// save_music_metadata does: DELETE, INSERT music_metadata only.
|
||||
fn mock_db_update_no_artist(inode: i64) -> sea_orm::DatabaseConnection {
|
||||
MockDatabase::new(DatabaseBackend::Postgres)
|
||||
.append_exec_results([MockExecResult {
|
||||
rows_affected: 1,
|
||||
..Default::default()
|
||||
}])
|
||||
.append_query_results([vec![mm_entity::Model {
|
||||
inode,
|
||||
track_title: String::new(),
|
||||
album: String::new(),
|
||||
track_number: 0,
|
||||
header: vec![],
|
||||
real_audio_start: 0,
|
||||
}]])
|
||||
.into_connection()
|
||||
}
|
||||
|
||||
// ── GetMusicMetadata ──────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_returns_metadata_for_valid_inode() {
|
||||
let mut files = BTreeMap::new();
|
||||
files.insert(
|
||||
INodeNo(100),
|
||||
make_flac_item(100, "song.flac", Some(make_flac_metadata())),
|
||||
);
|
||||
let files = Arc::new(Mutex::new(files));
|
||||
let svc = ClientControlServiceImpl::new(files, mock_db_readonly());
|
||||
|
||||
let resp = svc
|
||||
.get_music_metadata(Request::new(GetMusicMetadataRequest { inode: 100 }))
|
||||
.await
|
||||
.expect("get should succeed");
|
||||
|
||||
let md = resp.into_inner().metadata.expect("metadata present");
|
||||
assert_eq!(md.track_title, "Test Track");
|
||||
assert_eq!(md.album, "Test Album");
|
||||
assert_eq!(md.artist, vec!["Test Artist".to_string()]);
|
||||
assert_eq!(md.track_number, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_returns_not_found_for_missing_inode() {
|
||||
let files = Arc::new(Mutex::new(BTreeMap::new()));
|
||||
let svc = ClientControlServiceImpl::new(files, mock_db_readonly());
|
||||
|
||||
let err = svc
|
||||
.get_music_metadata(Request::new(GetMusicMetadataRequest { inode: 999 }))
|
||||
.await
|
||||
.expect_err("should be NOT_FOUND");
|
||||
|
||||
assert_eq!(err.code(), Code::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_returns_not_found_when_item_has_no_metadata() {
|
||||
let mut files = BTreeMap::new();
|
||||
files.insert(INodeNo(100), make_flac_item(100, "song.flac", None));
|
||||
let files = Arc::new(Mutex::new(files));
|
||||
let svc = ClientControlServiceImpl::new(files, mock_db_readonly());
|
||||
|
||||
let err = svc
|
||||
.get_music_metadata(Request::new(GetMusicMetadataRequest { inode: 100 }))
|
||||
.await
|
||||
.expect_err("should be NOT_FOUND");
|
||||
|
||||
assert_eq!(err.code(), Code::NotFound);
|
||||
}
|
||||
|
||||
// ── UpdateMusicMetadata ───────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_changes_tags_and_response_reflects_new_values() {
|
||||
let mut files = BTreeMap::new();
|
||||
files.insert(
|
||||
INodeNo(100),
|
||||
make_flac_item(100, "song.flac", Some(make_flac_metadata())),
|
||||
);
|
||||
let files = Arc::new(Mutex::new(files));
|
||||
let svc = ClientControlServiceImpl::new(files.clone(), mock_db_update_with_artist(100));
|
||||
|
||||
let resp = svc
|
||||
.update_music_metadata(Request::new(UpdateMusicMetadataRequest {
|
||||
inode: 100,
|
||||
artist: vec!["Updated Artist".to_string()],
|
||||
album_artist: Some("Updated Artist".to_string()),
|
||||
album: "Updated Album".to_string(),
|
||||
track_number: 7,
|
||||
track_title: "Updated Title".to_string(),
|
||||
other_tags: vec![],
|
||||
}))
|
||||
.await
|
||||
.expect("update should succeed");
|
||||
|
||||
let md = resp.into_inner().metadata.expect("metadata present");
|
||||
assert_eq!(md.track_title, "Updated Title");
|
||||
assert_eq!(md.album, "Updated Album");
|
||||
assert_eq!(md.artist, vec!["Updated Artist".to_string()]);
|
||||
assert_eq!(md.track_number, 7);
|
||||
|
||||
let guard = files.lock().unwrap();
|
||||
let item = guard.get(&INodeNo(100)).expect("item still in map");
|
||||
let mm = item.music_metadata.as_ref().expect("metadata present");
|
||||
assert_eq!(mm.track_title, "Updated Title");
|
||||
assert_eq!(mm.album, "Updated Album");
|
||||
assert_eq!(mm.artist, vec!["Updated Artist".to_string()]);
|
||||
assert_eq!(mm.track_number, 7);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_returns_not_found_for_missing_inode() {
|
||||
let files = Arc::new(Mutex::new(BTreeMap::new()));
|
||||
let svc = ClientControlServiceImpl::new(files, mock_db_readonly());
|
||||
|
||||
let err = svc
|
||||
.update_music_metadata(Request::new(UpdateMusicMetadataRequest {
|
||||
inode: 999,
|
||||
artist: vec![],
|
||||
album_artist: None,
|
||||
album: String::new(),
|
||||
track_number: 0,
|
||||
track_title: String::new(),
|
||||
other_tags: vec![],
|
||||
}))
|
||||
.await
|
||||
.expect_err("should be NOT_FOUND");
|
||||
|
||||
assert_eq!(err.code(), Code::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_returns_not_found_when_item_has_no_metadata() {
|
||||
let mut files = BTreeMap::new();
|
||||
files.insert(INodeNo(100), make_flac_item(100, "song.flac", None));
|
||||
let files = Arc::new(Mutex::new(files));
|
||||
let svc = ClientControlServiceImpl::new(files, mock_db_readonly());
|
||||
|
||||
let err = svc
|
||||
.update_music_metadata(Request::new(UpdateMusicMetadataRequest {
|
||||
inode: 100,
|
||||
artist: vec!["X".to_string()],
|
||||
album_artist: None,
|
||||
album: "X".to_string(),
|
||||
track_number: 1,
|
||||
track_title: "X".to_string(),
|
||||
other_tags: vec![],
|
||||
}))
|
||||
.await
|
||||
.expect_err("should be NOT_FOUND");
|
||||
|
||||
assert_eq!(err.code(), Code::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_clears_tags_when_empty_values_sent() {
|
||||
let mut files = BTreeMap::new();
|
||||
files.insert(
|
||||
INodeNo(100),
|
||||
make_flac_item(100, "song.flac", Some(make_flac_metadata())),
|
||||
);
|
||||
let files = Arc::new(Mutex::new(files));
|
||||
let svc = ClientControlServiceImpl::new(files.clone(), mock_db_update_no_artist(100));
|
||||
|
||||
let resp = svc
|
||||
.update_music_metadata(Request::new(UpdateMusicMetadataRequest {
|
||||
inode: 100,
|
||||
artist: vec![],
|
||||
album_artist: None,
|
||||
album: String::new(),
|
||||
track_number: 0,
|
||||
track_title: String::new(),
|
||||
other_tags: vec![],
|
||||
}))
|
||||
.await
|
||||
.expect("update should succeed");
|
||||
|
||||
let md = resp.into_inner().metadata.expect("metadata present");
|
||||
assert!(md.artist.is_empty());
|
||||
assert!(md.album.is_empty());
|
||||
assert!(md.track_title.is_empty());
|
||||
|
||||
let guard = files.lock().unwrap();
|
||||
let mm = guard
|
||||
.get(&INodeNo(100))
|
||||
.unwrap()
|
||||
.music_metadata
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert!(mm.artist.is_empty());
|
||||
assert!(mm.album.is_empty());
|
||||
}
|
||||
Reference in New Issue
Block a user