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
+160 -29
View File
@@ -1,20 +1,26 @@
use std::{
collections::BTreeMap,
path::PathBuf,
sync::{Arc, Mutex},
};
use fuser::INodeNo;
use musicfs_core::music::encoder::MusicMetadataEncoderFactory;
use musicfs_proto::{
ClientControl, GetMusicMetadataRequest, GetMusicMetadataResponse,
MusicMetadata as ProtoMusicMetadata, PictureDataRange, UpdateMusicMetadataRequest,
UpdateMusicMetadataResponse,
ClientControl, FileEntry, FileMetadata, GetMusicMetadataRequest, GetMusicMetadataResponse,
ListFilesRequest, ListFilesResponse, MusicMetadata as ProtoMusicMetadata, PictureDataRange,
UpdateMusicMetadataRequest, UpdateMusicMetadataResponse,
};
use sea_orm::DatabaseConnection;
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait};
use tonic::{Request, Response, Status};
use crate::item::Item;
use crate::db::entities;
use crate::item::{FileType, Item};
use crate::music::db::save_music_metadata;
use crate::music::metadata::MusicMetadata;
use crate::virtual_dirs::{
compute_new_layout, ensure_virtual_dirs, find_orphaned_dirs, parent_inode_from_path,
};
pub struct ClientControlServiceImpl {
files: Arc<Mutex<BTreeMap<INodeNo, Item>>>,
@@ -55,44 +61,128 @@ impl ClientControl for ClientControlServiceImpl {
let req = request.into_inner();
let inode = INodeNo(req.inode);
let final_metadata = {
let outcome = {
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 (old_local_path, current_name, source_for_virtual_dirs) = {
let root = files.get(&INodeNo::ROOT);
let item = files
.get(&inode)
.ok_or_else(|| Status::not_found(format!("inode {} not found", inode.0)))?;
let source = root
.map(|r| r.original_path.clone())
.unwrap_or_else(|| PathBuf::from("/"));
(item.local_path.clone(), item.name.clone(), source)
};
let encoder =
MusicMetadataEncoderFactory::for_path(&item.local_path).ok_or_else(|| {
Status::failed_precondition(format!(
"no encoder for file type: {}",
item.local_path.display()
))
// Phase 1: mutate the music metadata and re-encode the header.
// Done in its own scope so the mutable borrow on `files` is
// released before we touch `files` again for layout changes.
let (updated_mm, _encoder_extension_source) = {
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))
})?;
encoder.encode(mm);
apply_update(mm, req);
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(), item.local_path.clone())
};
mm.clone()
// Phase 2: compute new layout, write it onto the item, then
// ensure new virtual dirs and prune orphans. A fresh mutable
// borrow is fine because phase 1's borrow has been released.
let (new_name, new_local_path) = compute_new_layout(&current_name, &updated_mm);
{
let item = files
.get_mut(&inode)
.ok_or_else(|| Status::not_found(format!("inode {} not found", inode.0)))?;
item.name = new_name.clone();
item.local_path = new_local_path.clone();
item.parent_inode = parent_inode_from_path(&new_local_path);
}
ensure_virtual_dirs(&new_local_path, &source_for_virtual_dirs, &mut files);
let orphaned = find_orphaned_dirs(&files, inode, &old_local_path);
for dir_inode in &orphaned {
files.remove(dir_inode);
}
UpdateOutcome {
metadata: updated_mm,
new_name,
new_local_path,
orphaned_dirs: orphaned,
}
};
save_music_metadata(inode.0 as i64, &final_metadata, &self.db)
save_music_metadata(inode.0 as i64, &outcome.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");
persist_layout_changes(
&self.db,
inode.0 as i64,
&outcome.new_name,
&outcome.new_local_path,
&outcome.orphaned_dirs,
)
.await
.map_err(|e| Status::internal(format!("DB layout save failed: {e}")))?;
tracing::debug!(
ino = inode.0,
orphaned_dirs = outcome.orphaned_dirs.len(),
"control: music metadata updated, layout recomputed"
);
Ok(Response::new(UpdateMusicMetadataResponse {
metadata: Some(music_metadata_to_proto(final_metadata)),
metadata: Some(music_metadata_to_proto(outcome.metadata)),
}))
}
async fn list_files(
&self,
_request: Request<ListFilesRequest>,
) -> Result<Response<ListFilesResponse>, Status> {
let entries: Vec<FileEntry> = {
let files = self.files.lock().unwrap();
files
.values()
.filter(|item| item.file_type == FileType::File)
.map(|item| FileEntry {
inode: item.inode.0,
original_path: item.original_path.to_string_lossy().into_owned(),
local_path: item.local_path.to_string_lossy().into_owned(),
name: item.name.clone(),
metadata: item
.music_metadata
.as_ref()
.map(|mm| music_metadata_to_file_metadata(mm)),
})
.collect()
};
Ok(Response::new(ListFilesResponse { files: entries }))
}
}
fn music_metadata_to_file_metadata(mm: &MusicMetadata) -> FileMetadata {
FileMetadata {
artist: mm.artist.clone(),
album_artist: mm.album_artist.clone(),
album: mm.album.clone(),
track_number: mm.track_number,
track_title: mm.track_title.clone(),
other_tags: mm.other_tags.clone(),
}
}
fn music_metadata_to_proto(mm: crate::music::metadata::MusicMetadata) -> ProtoMusicMetadata {
@@ -115,3 +205,44 @@ fn music_metadata_to_proto(mm: crate::music::metadata::MusicMetadata) -> ProtoMu
vorbis_comment_length: mm.vorbis_comment_length,
}
}
struct UpdateOutcome {
metadata: MusicMetadata,
new_name: String,
new_local_path: PathBuf,
orphaned_dirs: Vec<INodeNo>,
}
fn apply_update(mm: &mut MusicMetadata, req: UpdateMusicMetadataRequest) {
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;
}
async fn persist_layout_changes(
db: &DatabaseConnection,
inode: i64,
new_name: &str,
new_local_path: &std::path::Path,
orphaned_dirs: &[INodeNo],
) -> Result<(), sea_orm::DbErr> {
use sea_orm::ActiveValue::Set;
let new_local_path_str = new_local_path.to_string_lossy().into_owned();
let update = entities::ActiveModel {
inode: Set(inode),
name: Set(new_name.to_string()),
local_path: Set(new_local_path_str),
..Default::default()
};
update.update(db).await?;
for dir_inode in orphaned_dirs {
let _ = entities::Entity::delete_by_id(dir_inode.0 as i64)
.exec(db)
.await;
}
Ok(())
}
+255
View File
@@ -10,6 +10,7 @@ use twox_hash::XxHash64;
use crate::db::entities::Model;
use crate::item::{FileType, Item};
use crate::music::metadata::MusicMetadata;
use crate::origins::attrs::FileAttrs;
pub fn virtual_inode(path: &str) -> INodeNo {
@@ -121,11 +122,265 @@ pub fn restore_virtual_paths(
}
}
/// Computes the new (name, local_path) for an Item based on its updated
/// `MusicMetadata`. Mirrors the layout logic in
/// `origins::local::snapshot::read_into_map`:
/// - artist dir = `album_artist` if set, else `artists.join("-")`
/// - if `track_title` is non-empty, the filename becomes
/// `{track_title}.{ext}` where ext is preserved from `current_name`
/// - otherwise the filename is unchanged
/// Returns `(new_name, new_local_path)`.
pub fn compute_new_layout(current_name: &str, mm: &MusicMetadata) -> (String, PathBuf) {
let artist_dir = artist_dir(mm);
let filename = match &mm.track_title {
title if !title.is_empty() => rename_with_extension(current_name, title),
_ => current_name.to_string(),
};
let mut local_path = PathBuf::new();
local_path.push(&artist_dir);
local_path.push(&mm.album);
local_path.push(&filename);
(filename, local_path)
}
fn artist_dir(mm: &MusicMetadata) -> String {
match &mm.album_artist {
Some(a) if !a.is_empty() => a.clone(),
_ => mm.artist.join("-"),
}
}
fn rename_with_extension(current_name: &str, new_stem: &str) -> String {
let current_path = Path::new(current_name);
match current_path.extension() {
Some(ext) => format!("{new_stem}.{}", ext.to_string_lossy()),
None => new_stem.to_string(),
}
}
/// Returns the virtual inodes of directories in the `old_local_path`'s
/// parent chain that will have no remaining files after this item moves
/// away. Walks the chain from the deepest directory upward; stops at the
/// first directory that still has other files living in it.
///
/// `moving_inode` is the actual inode of the file being relocated (the
/// request's `inode`), used to exclude it from the "any other files here?"
/// check. `old_local_path` is the LOCAL (virtual FUSE) path of the file
/// before its metadata changed.
pub fn find_orphaned_dirs(
files: &BTreeMap<INodeNo, Item>,
moving_inode: INodeNo,
old_local_path: &Path,
) -> Vec<INodeNo> {
let components: Vec<_> = old_local_path
.components()
.filter(|c| matches!(c, std::path::Component::Normal(_)))
.collect();
if components.len() <= 1 {
return vec![];
}
let mut orphaned = vec![];
for depth in (1..components.len()).rev() {
let dir_path: PathBuf = components[..depth].iter().map(|c| c.as_os_str()).collect();
let dir_path_str = dir_path.to_string_lossy().into_owned();
let dir_inode = virtual_inode(&dir_path_str);
// Path-prefix match: a file at "Artist/Album2/b.flac" still keeps
// "Artist" non-orphan even though its direct parent is "Artist/Album2".
let prefix = format!("{dir_path_str}/");
let has_other_files = files.values().any(|item| {
item.inode != moving_inode
&& item.file_type == FileType::File
&& item.local_path.starts_with(&prefix)
});
if has_other_files {
break;
}
orphaned.push(dir_inode);
}
orphaned
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
// ---- compute_new_layout ----
fn mm(artist: &str, album: &str, title: &str) -> MusicMetadata {
MusicMetadata {
artist: vec![artist.to_string()],
album_artist: None,
album: album.to_string(),
track_number: 1,
track_title: title.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,
}
}
fn file_item(inode: u64, name: &str, local_path: &str) -> Item {
Item {
inode: INodeNo(inode),
parent_inode: parent_inode_from_path(Path::new(local_path)),
name: name.to_string(),
original_path: format!("/torrent/{name}").into(),
local_path: local_path.into(),
file_type: FileType::File,
attrs: FileAttrs {
size: 1,
blocks: 1,
atime: std::time::SystemTime::UNIX_EPOCH,
mtime: std::time::SystemTime::UNIX_EPOCH,
ctime: std::time::SystemTime::UNIX_EPOCH,
crtime: std::time::SystemTime::UNIX_EPOCH,
perm: 0o644,
nlink: 1,
uid: 0,
gid: 0,
rdev: 0,
blksize: 4096,
},
music_metadata: None,
hash: inode,
}
}
#[test]
fn compute_new_layout_renames_file_when_track_title_provided() {
let mm = mm(
"Pink Floyd",
"Wish You Were Here",
"Shine On You Crazy Diamond",
);
let (name, path) = compute_new_layout("01-track.flac", &mm);
assert_eq!(name, "Shine On You Crazy Diamond.flac");
assert_eq!(
path,
PathBuf::from("Pink Floyd/Wish You Were Here/Shine On You Crazy Diamond.flac")
);
}
#[test]
fn compute_new_layout_preserves_extension_through_rename() {
let mm = mm("A", "B", "New Title");
let (name, _) = compute_new_layout("old.mp3", &mm);
assert_eq!(name, "New Title.mp3");
}
#[test]
fn compute_new_layout_handles_multi_dot_extensions() {
let mm = mm("A", "B", "New");
let (name, _) = compute_new_layout("old.tar.gz", &mm);
assert_eq!(name, "New.gz", "only the final extension is preserved");
}
#[test]
fn compute_new_layout_keeps_original_name_when_track_title_empty() {
let mut metadata = mm("A", "B", "ignored");
metadata.track_title = String::new();
let (name, path) = compute_new_layout("original-name.flac", &metadata);
assert_eq!(name, "original-name.flac");
assert_eq!(path, PathBuf::from("A/B/original-name.flac"));
}
#[test]
fn compute_new_layout_uses_album_artist_when_present() {
let mut metadata = mm("Secondary", "Album", "Title");
metadata.album_artist = Some("Primary Artist".to_string());
let (_, path) = compute_new_layout("track.flac", &metadata);
assert_eq!(path, PathBuf::from("Primary Artist/Album/Title.flac"));
}
#[test]
fn compute_new_layout_joins_multiple_artists_with_dash_when_no_album_artist() {
let mut metadata = mm("Foo", "Album", "Title");
metadata.artist = vec!["Foo".to_string(), "Bar".to_string()];
let (_, path) = compute_new_layout("track.flac", &metadata);
assert_eq!(path, PathBuf::from("Foo-Bar/Album/Title.flac"));
}
// ---- find_orphaned_dirs ----
#[test]
fn find_orphaned_dirs_returns_both_album_and_artist_when_last_file_moves() {
let mut files = BTreeMap::new();
files.insert(
INodeNo(100),
file_item(100, "track.flac", "Artist/Album/track.flac"),
);
let orphaned =
find_orphaned_dirs(&files, INodeNo(100), Path::new("Artist/Album/track.flac"));
assert_eq!(orphaned.len(), 2);
assert!(orphaned.contains(&virtual_inode("Artist/Album")));
assert!(orphaned.contains(&virtual_inode("Artist")));
}
#[test]
fn find_orphaned_dirs_returns_album_only_when_other_album_keeps_artist() {
let mut files = BTreeMap::new();
files.insert(
INodeNo(100),
file_item(100, "a.flac", "Artist/Album1/a.flac"),
);
files.insert(
INodeNo(101),
file_item(101, "b.flac", "Artist/Album2/b.flac"),
);
let orphaned = find_orphaned_dirs(&files, INodeNo(100), Path::new("Artist/Album1/a.flac"));
assert_eq!(orphaned, vec![virtual_inode("Artist/Album1")]);
}
#[test]
fn find_orphaned_dirs_returns_nothing_when_other_files_remain_in_same_album() {
let mut files = BTreeMap::new();
files.insert(
INodeNo(100),
file_item(100, "a.flac", "Artist/Album/a.flac"),
);
files.insert(
INodeNo(101),
file_item(101, "b.flac", "Artist/Album/b.flac"),
);
let orphaned = find_orphaned_dirs(&files, INodeNo(100), Path::new("Artist/Album/a.flac"));
assert!(orphaned.is_empty());
}
#[test]
fn find_orphaned_dirs_returns_nothing_for_file_at_root() {
let mut files = BTreeMap::new();
files.insert(INodeNo(100), file_item(100, "track.flac", "track.flac"));
let orphaned = find_orphaned_dirs(&files, INodeNo(100), Path::new("track.flac"));
assert!(orphaned.is_empty());
}
#[test]
fn find_orphaned_dirs_excludes_moving_file_from_other_file_check() {
let mut files = BTreeMap::new();
files.insert(
INodeNo(100),
file_item(100, "only.flac", "Artist/Album/only.flac"),
);
// If we forgot to exclude the moving inode, this test would return
// empty (thinking the dir is still populated). Must return both dirs.
let orphaned =
find_orphaned_dirs(&files, INodeNo(100), Path::new("Artist/Album/only.flac"));
assert_eq!(orphaned.len(), 2);
}
#[test]
fn virtual_inode_deterministic() {
let ino1 = virtual_inode("foo");
+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]