Implement list files rpc
This commit is contained in:
@@ -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(¤t_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(())
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user