Implement list files rpc

This commit is contained in:
Alexander
2026-07-24 20:17:38 +02:00
parent 5f79b3c2b0
commit 82be15b960
7 changed files with 1343 additions and 415 deletions
+277 -2
View File
@@ -6,11 +6,14 @@ use std::{
use fuser::INodeNo;
use musicfs::control::ClientControlServiceImpl;
use musicfs::db::entities;
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 musicfs_proto::{
ClientControl, GetMusicMetadataRequest, ListFilesRequest, UpdateMusicMetadataRequest,
};
use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
use tonic::{Code, Request};
@@ -71,6 +74,8 @@ fn mock_db_readonly() -> sea_orm::DatabaseConnection {
/// Mock DB for update with one artist.
/// save_music_metadata does: DELETE, INSERT music_metadata, INSERT artists.
/// persist_layout_changes does: UPDATE items (RETURNING), then one DELETE
/// per orphaned virtual directory (worst case = 2: artist + album dirs).
fn mock_db_update_with_artist(inode: i64) -> sea_orm::DatabaseConnection {
MockDatabase::new(DatabaseBackend::Postgres)
.append_exec_results([MockExecResult {
@@ -89,11 +94,29 @@ fn mock_db_update_with_artist(inode: i64) -> sea_orm::DatabaseConnection {
inode,
artist: String::new(),
}]])
.append_query_results([vec![entities::Model {
inode,
name: String::new(),
original_path: String::new(),
local_path: String::new(),
file_type: "file".to_string(),
hash: 0,
}]])
.append_exec_results([MockExecResult {
rows_affected: 1,
..Default::default()
}])
.append_exec_results([MockExecResult {
rows_affected: 1,
..Default::default()
}])
.into_connection()
}
/// Mock DB for update with no artists.
/// save_music_metadata does: DELETE, INSERT music_metadata only.
/// persist_layout_changes does: UPDATE items (RETURNING), then up to two
/// DELETEs for orphaned virtual directories.
fn mock_db_update_no_artist(inode: i64) -> sea_orm::DatabaseConnection {
MockDatabase::new(DatabaseBackend::Postgres)
.append_exec_results([MockExecResult {
@@ -108,10 +131,255 @@ fn mock_db_update_no_artist(inode: i64) -> sea_orm::DatabaseConnection {
header: vec![],
real_audio_start: 0,
}]])
.append_query_results([vec![entities::Model {
inode,
name: String::new(),
original_path: String::new(),
local_path: String::new(),
file_type: "file".to_string(),
hash: 0,
}]])
.append_exec_results([MockExecResult {
rows_affected: 1,
..Default::default()
}])
.append_exec_results([MockExecResult {
rows_affected: 1,
..Default::default()
}])
.into_connection()
}
// ── GetMusicMetadata ──────────────────────────────────────────────────
fn make_dir_item(inode: u64, name: &str) -> Item {
Item {
inode: INodeNo(inode),
parent_inode: INodeNo(1),
name: name.to_string(),
original_path: format!("/{name}").into(),
local_path: name.into(),
file_type: FileType::Directory,
attrs: make_attrs(),
music_metadata: None,
hash: inode,
}
}
#[tokio::test]
async fn update_removes_orphaned_virtual_dirs_when_last_file_moves_out() {
let source_dir = tempfile::tempdir().unwrap();
let source = source_dir.path();
let mut files = BTreeMap::new();
files.insert(
INodeNo(0),
Item {
inode: INodeNo(0),
parent_inode: INodeNo::ROOT,
name: "/".to_string(),
original_path: source.to_path_buf(),
local_path: "/".into(),
file_type: FileType::Directory,
attrs: make_attrs(),
music_metadata: None,
hash: 0,
},
);
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));
// Pre-create the virtual dirs for the file's current layout so we can
// assert they get pruned after the metadata update moves the file.
{
use musicfs::virtual_dirs::{ensure_virtual_dirs, virtual_inode};
let mut guard = files.lock().unwrap();
ensure_virtual_dirs(
std::path::Path::new("Artist/Album/song.flac"),
source,
&mut guard,
);
assert!(guard.contains_key(&virtual_inode("Artist")));
assert!(guard.contains_key(&virtual_inode("Artist/Album")));
}
svc.update_music_metadata(Request::new(UpdateMusicMetadataRequest {
inode: 100,
artist: vec!["New Artist".to_string()],
album_artist: Some("New Artist".to_string()),
album: "New Album".to_string(),
track_number: 1,
track_title: "New Title".to_string(),
other_tags: vec![],
}))
.await
.expect("update should succeed");
let guard = files.lock().unwrap();
use musicfs::virtual_dirs::virtual_inode;
assert!(
!guard.contains_key(&virtual_inode("Artist")),
"old Artist dir must be removed when no files remain under it"
);
assert!(
!guard.contains_key(&virtual_inode("Artist/Album")),
"old Album dir must be removed"
);
assert!(guard.contains_key(&virtual_inode("New Artist")));
assert!(guard.contains_key(&virtual_inode("New Artist/New Album")));
let item = guard.get(&INodeNo(100)).expect("file still present");
assert_eq!(
item.local_path,
std::path::PathBuf::from("New Artist/New Album/New Title.flac")
);
}
#[tokio::test]
async fn update_keeps_shared_artist_dir_when_other_album_remains() {
let source_dir = tempfile::tempdir().unwrap();
let source = source_dir.path();
let mut files = BTreeMap::new();
files.insert(
INodeNo(0),
Item {
inode: INodeNo(0),
parent_inode: INodeNo::ROOT,
name: "/".to_string(),
original_path: source.to_path_buf(),
local_path: "/".into(),
file_type: FileType::Directory,
attrs: make_attrs(),
music_metadata: None,
hash: 0,
},
);
files.insert(
INodeNo(100),
make_flac_item(100, "a.flac", Some(make_flac_metadata())),
);
files.insert(
INodeNo(101),
make_flac_item(101, "b.flac", Some(make_flac_metadata())),
);
files.get_mut(&INodeNo(101)).unwrap().local_path = "Artist/Other Album/b.flac".into();
// Pre-create virtual dirs for both files' layouts: pruning after the
// update should remove Album/ (file 100's old parent) but keep Artist/
// (still has Other Album/ with file 101 in it).
{
use musicfs::virtual_dirs::ensure_virtual_dirs;
ensure_virtual_dirs(
std::path::Path::new("Artist/Album/a.flac"),
source,
&mut files,
);
ensure_virtual_dirs(
std::path::Path::new("Artist/Other Album/b.flac"),
source,
&mut files,
);
}
let files = Arc::new(Mutex::new(files));
let svc = ClientControlServiceImpl::new(files.clone(), mock_db_update_with_artist(100));
svc.update_music_metadata(Request::new(UpdateMusicMetadataRequest {
inode: 100,
artist: vec!["New Artist".to_string()],
album_artist: Some("New Artist".to_string()),
album: "New Album".to_string(),
track_number: 1,
track_title: "New Title".to_string(),
other_tags: vec![],
}))
.await
.expect("update should succeed");
let guard = files.lock().unwrap();
use musicfs::virtual_dirs::virtual_inode;
assert!(
guard.contains_key(&virtual_inode("Artist")),
"Artist dir must remain while Other Album still has files"
);
assert!(
!guard.contains_key(&virtual_inode("Artist/Album")),
"Artist/Album dir must be removed"
);
}
// ── ListFiles ─────────────────────────────────────────────────────────
#[tokio::test]
async fn list_files_returns_all_files_with_full_field_projection() {
let mut files = BTreeMap::new();
files.insert(
INodeNo(100),
make_flac_item(100, "a.flac", Some(make_flac_metadata())),
);
files.insert(INodeNo(101), make_flac_item(101, "b.flac", None));
// Directory entries must be filtered out.
files.insert(INodeNo(2), make_dir_item(2, "Artist"));
let files = Arc::new(Mutex::new(files));
let svc = ClientControlServiceImpl::new(files, mock_db_readonly());
let resp = svc
.list_files(Request::new(ListFilesRequest {}))
.await
.expect("list should succeed");
let mut entries = resp.into_inner().files;
assert_eq!(entries.len(), 2, "directories must be filtered out");
entries.sort_by_key(|e| e.inode);
assert_eq!(entries[0].inode, 100);
assert_eq!(entries[0].name, "a.flac");
assert_eq!(entries[0].original_path, "/a.flac");
assert_eq!(entries[0].local_path, "Artist/Album/a.flac");
assert!(entries[0].metadata.is_some());
assert_eq!(entries[1].inode, 101);
assert_eq!(entries[1].name, "b.flac");
assert!(entries[1].metadata.is_none(), "no metadata on this file");
}
#[tokio::test]
async fn list_files_empty_map_returns_empty_list() {
let files = Arc::new(Mutex::new(BTreeMap::new()));
let svc = ClientControlServiceImpl::new(files, mock_db_readonly());
let resp = svc
.list_files(Request::new(ListFilesRequest {}))
.await
.expect("list should succeed");
assert!(resp.into_inner().files.is_empty());
}
#[tokio::test]
async fn list_files_skips_directories_and_root() {
let mut files = BTreeMap::new();
files.insert(INodeNo(0), make_dir_item(0, "/")); // root
files.insert(INodeNo(1), make_dir_item(1, "Artist"));
files.insert(INodeNo(2), make_dir_item(2, "Album"));
files.insert(INodeNo(100), make_flac_item(100, "track.flac", None));
let files = Arc::new(Mutex::new(files));
let svc = ClientControlServiceImpl::new(files, mock_db_readonly());
let resp = svc
.list_files(Request::new(ListFilesRequest {}))
.await
.expect("list should succeed");
let inodes: Vec<u64> = resp
.into_inner()
.files
.into_iter()
.map(|e| e.inode)
.collect();
assert_eq!(inodes, vec![100]);
}
#[tokio::test]
async fn get_returns_metadata_for_valid_inode() {
@@ -201,6 +469,13 @@ async fn update_changes_tags_and_response_reflects_new_values() {
assert_eq!(mm.album, "Updated Album");
assert_eq!(mm.artist, vec!["Updated Artist".to_string()]);
assert_eq!(mm.track_number, 7);
// Layout was recomputed from the new metadata.
assert_eq!(item.name, "Updated Title.flac");
assert_eq!(
item.local_path,
std::path::PathBuf::from("Updated Artist/Updated Album/Updated Title.flac")
);
}
#[tokio::test]