Define endpoints to update metadata

This commit is contained in:
Alexander
2026-07-01 20:23:10 +02:00
parent 8f76dd5b39
commit f28091257e
12 changed files with 1341 additions and 338 deletions
+1
View File
@@ -36,3 +36,4 @@ chrono.workspace = true
[dev-dependencies]
tempfile.workspace = true
sea-orm = { workspace = true, features = ["mock"] }
+117
View File
@@ -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,
}
}
+12 -4
View File
@@ -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
View File
@@ -1,3 +1,4 @@
pub mod control;
pub mod db;
pub mod health;
pub mod item;
+2 -1
View File
@@ -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");
+1 -1
View File
@@ -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::*;
+287
View File
@@ -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());
}