mod common;
use common::make_flac;
use common::{streaminfo_body, vorbis_comment_body};
use musefs_core::{CoreError, MountConfig, Musefs, VirtualTree, scan_directory};
use std::collections::BTreeMap;
fn config() -> MountConfig {
MountConfig {
template: "$artist/$title".to_string(),
fallbacks: BTreeMap::new(),
default_fallback: "Unknown".to_string(),
mode: musefs_core::Mode::Synthesis,
poll_interval: std::time::Duration::ZERO,
case_insensitive: false,
read_ahead_budget: 64 * 1024 * 1024,
read_ahead_prefetch: false,
skip_on_missing: false,
}
}
fn scanned_db(dir: &std::path::Path) -> musefs_db::Db {
let a = make_flac(
&[
(0, streaminfo_body()),
(4, vorbis_comment_body("v", &["ARTIST=Alice", "TITLE=Song"])),
],
&[0xAB; 64],
);
std::fs::write(dir.join("a.flac"), &a).unwrap();
let db = musefs_db::Db::open_in_memory().unwrap();
scan_directory(&db, dir).unwrap();
db
}
#[test]
fn lookup_getattr_readdir_and_read_through_the_facade() {
let dir = tempfile::tempdir().unwrap();
let db = scanned_db(dir.path());
let fs = Musefs::open(db, config()).unwrap();
let artist = fs.lookup(VirtualTree::ROOT, "Alice").expect("artist dir");
let dattr = fs.getattr(artist).unwrap();
assert!(dattr.is_dir);
let entries = fs.readdir(artist).unwrap();
assert_eq!(entries.len(), 1);
let (name, file_inode, is_dir) = entries.into_iter().next().unwrap();
assert_eq!(name, "Song.flac");
assert!(!is_dir);
let fattr = fs.getattr(file_inode).unwrap();
assert!(!fattr.is_dir);
assert!(fattr.size > 0);
let bytes = fs.read(file_inode, None, 0, fattr.size).unwrap();
assert_eq!(bytes.len() as u64, fattr.size);
let tag = metaflac::Tag::read_from(&mut std::io::Cursor::new(&bytes)).unwrap();
assert_eq!(
tag.vorbis_comments()
.unwrap()
.get("TITLE")
.map(std::vec::Vec::as_slice),
Some(["Song".to_string()].as_slice())
);
}
#[test]
fn parent_exposes_the_tree_hierarchy() {
let dir = tempfile::tempdir().unwrap();
let db = scanned_db(dir.path());
let fs = Musefs::open(db, config()).unwrap();
let artist = fs.lookup(VirtualTree::ROOT, "Alice").unwrap();
assert_eq!(fs.parent(artist), Some(VirtualTree::ROOT));
assert_eq!(fs.parent(VirtualTree::ROOT), Some(VirtualTree::ROOT));
assert_eq!(fs.parent(424_242), None);
}
#[test]
fn refresh_rebuilds_tree_after_new_tracks() {
let dir = tempfile::tempdir().unwrap();
let db = scanned_db(dir.path());
let fs = Musefs::open(db, config()).unwrap();
assert!(fs.lookup(VirtualTree::ROOT, "Alice").is_some());
assert!(fs.lookup(VirtualTree::ROOT, "Bob").is_none());
fs.refresh_for_test().unwrap();
assert!(fs.lookup(VirtualTree::ROOT, "Alice").is_some());
}
#[test]
fn readdir_distinguishes_a_file_from_an_unknown_inode() {
let dir = tempfile::tempdir().unwrap();
let db = scanned_db(dir.path());
let fs = Musefs::open(db, config()).unwrap();
let artist = fs.lookup(VirtualTree::ROOT, "Alice").unwrap();
let file = fs.readdir(artist).unwrap()[0].1;
match fs.readdir(file) {
Err(CoreError::NotADir(i)) => assert_eq!(i, file),
other => panic!("expected NotADir, got {other:?}"),
}
match fs.readdir(987_654) {
Err(CoreError::NoEntry(i)) => assert_eq!(i, 987_654),
other => panic!("expected NoEntry, got {other:?}"),
}
}
#[test]
fn reads_a_synthesized_mp3_through_the_facade() {
use id3::TagLike;
use std::io::Cursor;
let dir = tempfile::tempdir().unwrap();
let mut tag = id3::Tag::new();
tag.set_artist("Zoe");
tag.set_title("Old");
let mut bytes = Vec::new();
tag.write_to(&mut bytes, id3::Version::Id3v24).unwrap();
let audio = [0xFFu8, 0xFB, 7, 7, 7, 7];
bytes.extend_from_slice(&audio);
std::fs::write(dir.path().join("song.mp3"), &bytes).unwrap();
let db = musefs_db::Db::open_in_memory().unwrap();
scan_directory(&db, dir.path()).unwrap();
let fs = Musefs::open(db, config()).unwrap();
let artist = fs.lookup(VirtualTree::ROOT, "Zoe").expect("artist dir");
let entries = fs.readdir(artist).unwrap();
let (name, file_inode, _) = entries.into_iter().next().unwrap();
assert_eq!(name, "Old.mp3");
let attr = fs.getattr(file_inode).unwrap();
let whole = fs.read(file_inode, None, 0, attr.size).unwrap();
assert_eq!(whole.len() as u64, attr.size);
let parsed = id3::Tag::read_from2(Cursor::new(&whole)).unwrap();
assert_eq!(parsed.artist(), Some("Zoe"));
assert_eq!(parsed.title(), Some("Old"));
assert_eq!(&whole[whole.len() - audio.len()..], &audio);
}
#[test]
fn reads_a_synthesized_m4a_through_the_facade() {
let dir = tempfile::tempdir().unwrap();
let audio = b"AUDIODATA";
std::fs::write(dir.path().join("song.m4a"), common::minimal_m4a(audio)).unwrap();
let db = musefs_db::Db::open_in_memory().unwrap();
scan_directory(&db, dir.path()).unwrap();
let fs = Musefs::open(db, config()).unwrap();
let artist = fs
.lookup(VirtualTree::ROOT, "Orig Artist")
.expect("artist dir");
let entries = fs.readdir(artist).unwrap();
let (name, file_inode, _) = entries.into_iter().next().unwrap();
assert_eq!(name, "Orig M4A.m4a");
let attr = fs.getattr(file_inode).unwrap();
let whole = fs.read(file_inode, None, 0, attr.size).unwrap();
assert_eq!(whole.len() as u64, attr.size);
assert!(
whole.windows(audio.len()).any(|w| w == audio),
"synthesized m4a should contain the verbatim audio payload"
);
assert_eq!(&whole[whole.len() - audio.len()..], audio);
}
#[test]
fn serves_flac_with_embedded_art_through_the_facade() {
let dir = tempfile::tempdir().unwrap();
let img = vec![0xC3u8; 120];
fn picture_body(mime: &str, data: &[u8]) -> Vec<u8> {
let mut b = Vec::new();
b.extend_from_slice(&3u32.to_be_bytes()); b.extend_from_slice(&u32::try_from(mime.len()).unwrap().to_be_bytes());
b.extend_from_slice(mime.as_bytes());
b.extend_from_slice(&0u32.to_be_bytes()); b.extend_from_slice(&0u32.to_be_bytes()); b.extend_from_slice(&0u32.to_be_bytes()); b.extend_from_slice(&0u32.to_be_bytes()); b.extend_from_slice(&0u32.to_be_bytes()); b.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes());
b.extend_from_slice(data);
b
}
let mut flac = Vec::new();
flac.extend_from_slice(b"fLaC");
flac.extend_from_slice(&common::flac_block(0, &common::streaminfo_body(), false));
flac.extend_from_slice(&common::flac_block(
4,
&common::vorbis_comment_body("v", &["ARTIST=Art", "TITLE=Cover"]),
false,
));
flac.extend_from_slice(&common::flac_block(
6,
&picture_body("image/png", &img),
true,
));
flac.extend_from_slice(&[0x5Au8; 40]);
std::fs::write(dir.path().join("c.flac"), &flac).unwrap();
let db = musefs_db::Db::open_in_memory().unwrap();
scan_directory(&db, dir.path()).unwrap();
let fs = Musefs::open(db, config()).unwrap();
let artist = fs.lookup(VirtualTree::ROOT, "Art").unwrap();
let (_name, file_inode, _) = fs.readdir(artist).unwrap().into_iter().next().unwrap();
let attr = fs.getattr(file_inode).unwrap();
let whole = fs.read(file_inode, None, 0, attr.size).unwrap();
assert_eq!(whole.len() as u64, attr.size);
let tag = metaflac::Tag::read_from(&mut std::io::Cursor::new(&whole)).unwrap();
let pic = tag.pictures().next().expect("a picture");
assert_eq!(pic.data, img);
assert_eq!(pic.mime_type, "image/png");
}
#[test]
fn serves_mp3_with_embedded_art_through_the_facade() {
use id3::TagLike;
let dir = tempfile::tempdir().unwrap();
let img = vec![0xD4u8; 90];
let mut tag = id3::Tag::new();
tag.set_artist("Pix");
tag.set_title("Song");
tag.add_frame(id3::frame::Picture {
mime_type: "image/jpeg".to_string(),
picture_type: id3::frame::PictureType::CoverFront,
description: String::new(),
data: img.clone(),
});
let mut bytes = Vec::new();
tag.write_to(&mut bytes, id3::Version::Id3v24).unwrap();
bytes.extend_from_slice(&[0xFF, 0xFB, 1, 2, 3, 4]);
std::fs::write(dir.path().join("s.mp3"), &bytes).unwrap();
let db = musefs_db::Db::open_in_memory().unwrap();
scan_directory(&db, dir.path()).unwrap();
let fs = Musefs::open(db, config()).unwrap();
let artist = fs.lookup(VirtualTree::ROOT, "Pix").unwrap();
let (_name, file_inode, _) = fs.readdir(artist).unwrap().into_iter().next().unwrap();
let attr = fs.getattr(file_inode).unwrap();
let whole = fs.read(file_inode, None, 0, attr.size).unwrap();
assert_eq!(whole.len() as u64, attr.size);
let parsed = id3::Tag::read_from2(std::io::Cursor::new(&whole)).unwrap();
let pic = parsed.pictures().next().expect("a picture");
assert_eq!(pic.data, img);
assert_eq!(pic.mime_type, "image/jpeg");
}
#[test]
fn poll_refresh_picks_up_external_db_edits() {
use musefs_db::{Format, NewTrack, Tag};
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("m.db");
{
let db = musefs_db::Db::open(&db_path).unwrap();
let id = db
.upsert_track(&NewTrack {
backing_path: "/x/a.flac".to_string(),
format: Format::Flac,
audio_offset: 0,
audio_length: 0,
backing_size: 0,
backing_mtime_ns: 0,
backing_ctime_ns: 0,
})
.unwrap();
db.replace_tags(
id,
&[Tag::new("artist", "Alice", 0), Tag::new("title", "A", 0)],
)
.unwrap();
}
let db = musefs_db::Db::open(&db_path).unwrap();
let fs = Musefs::open(db, config()).unwrap();
assert!(fs.lookup(VirtualTree::ROOT, "Alice").is_some());
assert!(fs.lookup(VirtualTree::ROOT, "Bob").is_none());
{
let db2 = musefs_db::Db::open(&db_path).unwrap();
let id = db2
.upsert_track(&NewTrack {
backing_path: "/x/b.flac".to_string(),
format: Format::Flac,
audio_offset: 0,
audio_length: 0,
backing_size: 0,
backing_mtime_ns: 0,
backing_ctime_ns: 0,
})
.unwrap();
db2.replace_tags(
id,
&[Tag::new("artist", "Bob", 0), Tag::new("title", "B", 0)],
)
.unwrap();
}
assert!(fs.poll_refresh().unwrap());
assert!(fs.lookup(VirtualTree::ROOT, "Bob").is_some());
assert!(fs.lookup(VirtualTree::ROOT, "Alice").is_some());
assert!(!fs.poll_refresh().unwrap());
}
#[test]
fn open_handle_read_and_release_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let db = scanned_db(dir.path());
let fs = Musefs::open(db, config()).unwrap();
let artist = fs.lookup(VirtualTree::ROOT, "Alice").unwrap();
let (_, file_inode, _) = fs.readdir(artist).unwrap().into_iter().next().unwrap();
let size = fs.getattr(file_inode).unwrap().size;
let fh = fs.open_handle(file_inode).unwrap();
let via_handle = fs.read(file_inode, Some(fh), 0, size).unwrap();
let via_fallback = fs.read(file_inode, None, 0, size).unwrap();
assert_eq!(via_handle, via_fallback);
assert_eq!(via_handle.len() as u64, size);
fs.release_handle(fh);
let after = fs.read(file_inode, Some(fh), 0, size).unwrap(); assert_eq!(after, via_fallback);
}
#[test]
fn stale_fh_after_release_and_reopen_falls_back() {
let dir = tempfile::tempdir().unwrap();
let db = scanned_db(dir.path());
let fs = Musefs::open(db, config()).unwrap();
let artist = fs.lookup(VirtualTree::ROOT, "Alice").unwrap();
let (_, file_inode, _) = fs.readdir(artist).unwrap().into_iter().next().unwrap();
let size = fs.getattr(file_inode).unwrap().size;
let canonical = fs.read(file_inode, None, 0, size).unwrap();
let fh_a = fs.open_handle(file_inode).unwrap();
fs.release_handle(fh_a);
let fh_b = fs.open_handle(file_inode).unwrap();
assert_ne!(fh_a, fh_b);
let via_stale = fs.read(file_inode, Some(fh_a), 0, size).unwrap();
assert_eq!(via_stale, canonical);
let via_live = fs.read(file_inode, Some(fh_b), 0, size).unwrap();
assert_eq!(via_live, canonical);
fs.release_handle(fh_b);
}
#[test]
fn poll_refresh_keeps_unchanged_entries_and_prunes_vanished() {
use musefs_db::{Format, NewTrack, Tag};
let dir = tempfile::tempdir().unwrap();
let backing = dir.path().join("a.flac");
let bytes = make_flac(
&[
(0, streaminfo_body()),
(4, vorbis_comment_body("v", &["ARTIST=Alice", "TITLE=Song"])),
],
&[0xAB; 64],
);
std::fs::write(&backing, &bytes).unwrap();
let db_path = dir.path().join("m.db");
{
let db = musefs_db::Db::open(&db_path).unwrap();
scan_directory(&db, dir.path()).unwrap();
}
let fs = Musefs::open(musefs_db::Db::open(&db_path).unwrap(), config()).unwrap();
let artist = fs.lookup(VirtualTree::ROOT, "Alice").unwrap();
let (_, inode, _) = fs.readdir(artist).unwrap().into_iter().next().unwrap();
let size_before = fs.getattr(inode).unwrap().size;
{
let db2 = musefs_db::Db::open(&db_path).unwrap();
let id = db2
.upsert_track(&NewTrack {
backing_path: "/x/ghost.mp3".to_string(),
format: Format::Mp3,
audio_offset: 0,
audio_length: 0,
backing_size: 0,
backing_mtime_ns: 0,
backing_ctime_ns: 0,
})
.unwrap();
db2.replace_tags(
id,
&[Tag::new("artist", "Ghost", 0), Tag::new("title", "G", 0)],
)
.unwrap();
}
assert!(fs.poll_refresh().unwrap());
let size_after = fs.getattr(inode).unwrap().size;
assert_eq!(size_before, size_after);
assert!(fs.lookup(VirtualTree::ROOT, "Ghost").is_some());
}
#[test]
fn poll_refresh_debounces_within_interval() {
use musefs_db::{Format, NewTrack, Tag};
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("m.db");
{
let db = musefs_db::Db::open(&db_path).unwrap();
let id = db
.upsert_track(&NewTrack {
backing_path: "/x/a.flac".to_string(),
format: Format::Flac,
audio_offset: 0,
audio_length: 0,
backing_size: 0,
backing_mtime_ns: 0,
backing_ctime_ns: 0,
})
.unwrap();
db.replace_tags(
id,
&[Tag::new("artist", "Alice", 0), Tag::new("title", "A", 0)],
)
.unwrap();
}
let cfg = MountConfig {
poll_interval: std::time::Duration::from_hours(1),
..config()
};
let fs = Musefs::open(musefs_db::Db::open(&db_path).unwrap(), cfg).unwrap();
{
let db2 = musefs_db::Db::open(&db_path).unwrap();
let id = db2
.upsert_track(&NewTrack {
backing_path: "/x/b.flac".to_string(),
format: Format::Flac,
audio_offset: 0,
audio_length: 0,
backing_size: 0,
backing_mtime_ns: 0,
backing_ctime_ns: 0,
})
.unwrap();
db2.replace_tags(
id,
&[Tag::new("artist", "Bob", 0), Tag::new("title", "B", 0)],
)
.unwrap();
}
assert!(!fs.poll_refresh().unwrap()); assert!(fs.lookup(VirtualTree::ROOT, "Bob").is_none());
}
#[test]
fn unchanged_refresh_poll_consumes_debounce_window() {
use musefs_db::{Format, NewTrack, Tag};
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("m.db");
{
let db = musefs_db::Db::open(&db_path).unwrap();
let id = db
.upsert_track(&NewTrack {
backing_path: "/x/a.flac".to_string(),
format: Format::Flac,
audio_offset: 0,
audio_length: 0,
backing_size: 0,
backing_mtime_ns: 0,
backing_ctime_ns: 0,
})
.unwrap();
db.replace_tags(
id,
&[Tag::new("artist", "Alice", 0), Tag::new("title", "A", 0)],
)
.unwrap();
}
let cfg = MountConfig {
poll_interval: std::time::Duration::from_secs(30),
..config()
};
let fs = Musefs::open(musefs_db::Db::open(&db_path).unwrap(), cfg).unwrap();
fs.expire_poll_debounce_for_test();
assert!(!fs.poll_refresh().unwrap());
{
let db2 = musefs_db::Db::open(&db_path).unwrap();
let id = db2
.upsert_track(&NewTrack {
backing_path: "/x/b.flac".to_string(),
format: Format::Flac,
audio_offset: 0,
audio_length: 0,
backing_size: 0,
backing_mtime_ns: 0,
backing_ctime_ns: 0,
})
.unwrap();
db2.replace_tags(
id,
&[Tag::new("artist", "Bob", 0), Tag::new("title", "B", 0)],
)
.unwrap();
}
assert!(
!fs.poll_refresh().unwrap(),
"unchanged poll should have reset the debounce window"
);
fs.expire_poll_debounce_for_test();
assert!(fs.poll_refresh().unwrap());
}
#[test]
fn failed_refresh_retries_after_backoff_not_every_call() {
use musefs_db::{Format, NewTrack, Tag};
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("m.db");
{
let db = musefs_db::Db::open(&db_path).unwrap();
let id = db
.upsert_track(&NewTrack {
backing_path: "/x/a.flac".to_string(),
format: Format::Flac,
audio_offset: 0,
audio_length: 0,
backing_size: 0,
backing_mtime_ns: 0,
backing_ctime_ns: 0,
})
.unwrap();
db.replace_tags(
id,
&[Tag::new("artist", "Alice", 0), Tag::new("title", "A", 0)],
)
.unwrap();
}
let cfg = MountConfig {
poll_interval: std::time::Duration::from_millis(20),
..config()
};
let fs = Musefs::open(musefs_db::Db::open(&db_path).unwrap(), cfg).unwrap();
std::thread::sleep(std::time::Duration::from_millis(25));
{
let db2 = musefs_db::Db::open(&db_path).unwrap();
let id = db2
.upsert_track(&NewTrack {
backing_path: "/x/b.flac".to_string(),
format: Format::Flac,
audio_offset: 0,
audio_length: 0,
backing_size: 0,
backing_mtime_ns: 0,
backing_ctime_ns: 0,
})
.unwrap();
db2.replace_tags(
id,
&[Tag::new("artist", "Bob", 0), Tag::new("title", "B", 0)],
)
.unwrap();
}
fs.force_rebuild_errors_for_test(true);
assert!(fs.poll_refresh().is_err());
assert!(
!fs.poll_refresh().unwrap(),
"immediate retry should be suppressed by refresh failure backoff"
);
std::thread::sleep(std::time::Duration::from_millis(110));
assert!(fs.poll_refresh().is_err());
}
#[test]
fn poll_refresh_single_flights_concurrent_callers() {
use musefs_db::{Format, NewTrack, Tag};
use std::sync::Arc;
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("m.db");
{
let db = musefs_db::Db::open(&db_path).unwrap();
let id = db
.upsert_track(&NewTrack {
backing_path: "/x/a.flac".into(),
format: Format::Flac,
audio_offset: 0,
audio_length: 0,
backing_size: 0,
backing_mtime_ns: 0,
backing_ctime_ns: 0,
})
.unwrap();
db.replace_tags(
id,
&[Tag::new("artist", "Alice", 0), Tag::new("title", "A", 0)],
)
.unwrap();
}
let cfg = MountConfig {
poll_interval: std::time::Duration::ZERO,
..config()
};
let fs = Arc::new(Musefs::open(musefs_db::Db::open(&db_path).unwrap(), cfg).unwrap());
{
let db2 = musefs_db::Db::open(&db_path).unwrap();
let id = db2
.upsert_track(&NewTrack {
backing_path: "/x/b.flac".into(),
format: Format::Flac,
audio_offset: 0,
audio_length: 0,
backing_size: 0,
backing_mtime_ns: 0,
backing_ctime_ns: 0,
})
.unwrap();
db2.replace_tags(
id,
&[Tag::new("artist", "Bob", 0), Tag::new("title", "B", 0)],
)
.unwrap();
}
let trues: usize = std::thread::scope(|s| {
let handles: Vec<_> = (0..8)
.map(|_| {
let fs = Arc::clone(&fs);
s.spawn(move || usize::from(fs.poll_refresh().unwrap()))
})
.collect();
handles.into_iter().map(|h| h.join().unwrap()).sum()
});
assert_eq!(trues, 1, "single-flight: exactly one caller rebuilds");
assert!(fs.lookup(VirtualTree::ROOT, "Bob").is_some());
}
#[test]
fn inode_is_stable_across_refresh() {
use musefs_db::{Format, NewTrack, Tag};
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("m.db");
{
let db = musefs_db::Db::open(&db_path).unwrap();
let id = db
.upsert_track(&NewTrack {
backing_path: "/x/a.flac".into(),
format: Format::Flac,
audio_offset: 0,
audio_length: 0,
backing_size: 0,
backing_mtime_ns: 0,
backing_ctime_ns: 0,
})
.unwrap();
db.replace_tags(
id,
&[Tag::new("artist", "Alice", 0), Tag::new("title", "A", 0)],
)
.unwrap();
}
let cfg = MountConfig {
poll_interval: std::time::Duration::ZERO,
..config()
};
let fs = Musefs::open(musefs_db::Db::open(&db_path).unwrap(), cfg).unwrap();
let alice = fs.lookup(VirtualTree::ROOT, "Alice").unwrap();
let (_, song_before, _) = fs.readdir(alice).unwrap().into_iter().next().unwrap();
{
let db2 = musefs_db::Db::open(&db_path).unwrap();
let id = db2
.upsert_track(&NewTrack {
backing_path: "/x/b.flac".into(),
format: Format::Flac,
audio_offset: 0,
audio_length: 0,
backing_size: 0,
backing_mtime_ns: 0,
backing_ctime_ns: 0,
})
.unwrap();
db2.replace_tags(
id,
&[Tag::new("artist", "Bob", 0), Tag::new("title", "B", 0)],
)
.unwrap();
}
assert!(fs.poll_refresh().unwrap());
let alice_after = fs.lookup(VirtualTree::ROOT, "Alice").unwrap();
let (_, song_after, _) = fs.readdir(alice_after).unwrap().into_iter().next().unwrap();
assert_eq!(alice, alice_after);
assert_eq!(song_before, song_after);
}
#[test]
fn poll_refresh_notify_reports_changed_track_inode() {
use musefs_db::Tag;
let dir = tempfile::tempdir().unwrap();
for (name, artist, title) in [("a.flac", "Alice", "Song"), ("b.flac", "Bob", "Tune")] {
let bytes = make_flac(
&[
(0, streaminfo_body()),
(
4,
vorbis_comment_body(
"v",
&[&format!("ARTIST={artist}"), &format!("TITLE={title}")],
),
),
],
&[0xAB; 64],
);
std::fs::write(dir.path().join(name), &bytes).unwrap();
}
let db_path = dir.path().join("m.db");
{
let db = musefs_db::Db::open(&db_path).unwrap();
scan_directory(&db, dir.path()).unwrap();
}
let fs = Musefs::open(musefs_db::Db::open(&db_path).unwrap(), config()).unwrap();
let alice = fs.lookup(VirtualTree::ROOT, "Alice").unwrap();
let alice_song = fs.lookup(alice, "Song.flac").unwrap();
let alice_id = musefs_db::Db::open(&db_path)
.unwrap()
.list_tracks()
.unwrap()
.into_iter()
.find(|t| t.backing_path.ends_with("a.flac"))
.unwrap()
.id;
{
let db2 = musefs_db::Db::open(&db_path).unwrap();
db2.replace_tags(
alice_id,
&[
Tag::new("artist", "Alice", 0),
Tag::new("title", "Song", 0),
Tag::new("album", "New", 0),
],
)
.unwrap();
}
let mut changed = Vec::new();
assert!(fs.poll_refresh_notify(|ino| changed.push(ino)).unwrap());
assert_eq!(changed, vec![alice_song], "only Alice's inode changed");
assert_eq!(
fs.lookup(fs.lookup(VirtualTree::ROOT, "Alice").unwrap(), "Song.flac")
.unwrap(),
alice_song
);
}
#[test]
fn poll_refresh_notify_reports_changed_inode_on_full_rebuild_fallback() {
use musefs_db::Tag;
let dir = tempfile::tempdir().unwrap();
for (name, artist, title) in [("a.flac", "Alice", "Song"), ("b.flac", "Bob", "Tune")] {
let bytes = make_flac(
&[
(0, streaminfo_body()),
(
4,
vorbis_comment_body(
"v",
&[&format!("ARTIST={artist}"), &format!("TITLE={title}")],
),
),
],
&[0xAB; 64],
);
std::fs::write(dir.path().join(name), &bytes).unwrap();
}
let db_path = dir.path().join("m.db");
{
let db = musefs_db::Db::open(&db_path).unwrap();
scan_directory(&db, dir.path()).unwrap();
}
let fs = Musefs::open(musefs_db::Db::open(&db_path).unwrap(), config()).unwrap();
let alice = fs.lookup(VirtualTree::ROOT, "Alice").unwrap();
let alice_song = fs.lookup(alice, "Song.flac").unwrap();
let alice_id = musefs_db::Db::open(&db_path)
.unwrap()
.list_tracks()
.unwrap()
.into_iter()
.find(|t| t.backing_path.ends_with("a.flac"))
.unwrap()
.id;
{
let db2 = musefs_db::Db::open(&db_path).unwrap();
db2.replace_tags(
alice_id,
&[
Tag::new("artist", "Alice", 0),
Tag::new("title", "Song", 0),
Tag::new("album", "New", 0),
],
)
.unwrap();
}
{
let writer = musefs_db::Db::open(&db_path).unwrap();
let max_seq = writer.changelog_since(0).unwrap().max_seq;
writer.delete_changelog_through_for_test(max_seq).unwrap();
}
let mut changed = Vec::new();
assert!(fs.poll_refresh_notify(|ino| changed.push(ino)).unwrap());
assert_eq!(
changed,
vec![alice_song],
"full-rebuild path must invalidate exactly the changed track's stable inode"
);
}
#[test]
fn poll_refresh_notify_reports_old_inode_for_path_changing_retag() {
use musefs_db::Tag;
let dir = tempfile::tempdir().unwrap();
let bytes = make_flac(
&[
(0, streaminfo_body()),
(4, vorbis_comment_body("v", &["ARTIST=Alice", "TITLE=Song"])),
],
&[0xAB; 64],
);
std::fs::write(dir.path().join("a.flac"), &bytes).unwrap();
let db_path = dir.path().join("m.db");
{
let db = musefs_db::Db::open(&db_path).unwrap();
scan_directory(&db, dir.path()).unwrap();
}
let fs = Musefs::open(musefs_db::Db::open(&db_path).unwrap(), config()).unwrap();
let alice = fs.lookup(VirtualTree::ROOT, "Alice").unwrap();
let old_inode = fs.lookup(alice, "Song.flac").unwrap();
let track_id = musefs_db::Db::open(&db_path)
.unwrap()
.list_tracks()
.unwrap()
.into_iter()
.next()
.unwrap()
.id;
{
let db2 = musefs_db::Db::open(&db_path).unwrap();
db2.replace_tags(
track_id,
&[
Tag::new("artist", "Alice", 0),
Tag::new("title", "Moved", 0),
],
)
.unwrap();
}
let mut changed = Vec::new();
assert!(fs.poll_refresh_notify(|ino| changed.push(ino)).unwrap());
let alice_after = fs.lookup(VirtualTree::ROOT, "Alice").unwrap();
let new_inode = fs.lookup(alice_after, "Moved.flac").unwrap();
assert!(
changed.contains(&old_inode),
"old inode should be invalidated"
);
assert!(
!changed.contains(&new_inode),
"new (freshly allocated) inode has no cache and must not be reported"
);
assert_ne!(old_inode, new_inode);
}
#[test]
fn poll_refresh_notify_invalidates_old_inode_for_removed_track() {
let dir = tempfile::tempdir().unwrap();
let bytes = make_flac(
&[
(0, streaminfo_body()),
(4, vorbis_comment_body("v", &["ARTIST=Alice", "TITLE=Song"])),
],
&[0xAB; 64],
);
std::fs::write(dir.path().join("a.flac"), &bytes).unwrap();
let db_path = dir.path().join("m.db");
{
let db = musefs_db::Db::open(&db_path).unwrap();
scan_directory(&db, dir.path()).unwrap();
}
let fs = Musefs::open(musefs_db::Db::open(&db_path).unwrap(), config()).unwrap();
let alice = fs.lookup(VirtualTree::ROOT, "Alice").unwrap();
let old_inode = fs.lookup(alice, "Song.flac").unwrap();
let track_id = musefs_db::Db::open(&db_path)
.unwrap()
.list_tracks()
.unwrap()
.into_iter()
.next()
.unwrap()
.id;
{
let db2 = musefs_db::Db::open(&db_path).unwrap();
db2.delete_track(track_id).unwrap();
}
let mut changed = Vec::new();
assert!(fs.poll_refresh_notify(|ino| changed.push(ino)).unwrap());
assert!(
changed.contains(&old_inode),
"old inode should be invalidated after track removal"
);
}
#[test]
fn reads_m4b_alias() {
let dir = tempfile::tempdir().unwrap();
let audio = b"AUDIODATA";
let bytes = common::minimal_m4a(audio);
std::fs::write(dir.path().join("book.m4b"), &bytes).unwrap();
let db = musefs_db::Db::open_in_memory().unwrap();
scan_directory(&db, dir.path()).unwrap();
let track = db.list_tracks().unwrap().into_iter().next().unwrap();
assert_eq!(track.format, musefs_db::Format::M4a);
let fs = Musefs::open(db, config()).unwrap();
let artist = fs.lookup(VirtualTree::ROOT, "Orig Artist").unwrap();
let entries = fs.readdir(artist).unwrap();
let (name, file_inode, _) = entries.into_iter().next().unwrap();
assert_eq!(name, "Orig M4A.m4a");
let attr = fs.getattr(file_inode).unwrap();
assert!(!attr.is_dir);
assert!(attr.size > 0);
}
#[test]
fn refresh_picks_up_externally_added_track() {
use musefs_db::{Format, NewTrack, Tag};
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("m.db");
{
let db = musefs_db::Db::open(&db_path).unwrap();
let id = db
.upsert_track(&NewTrack {
backing_path: "/x/a.flac".into(),
format: Format::Flac,
audio_offset: 0,
audio_length: 0,
backing_size: 0,
backing_mtime_ns: 0,
backing_ctime_ns: 0,
})
.unwrap();
db.replace_tags(
id,
&[Tag::new("artist", "Alice", 0), Tag::new("title", "A", 0)],
)
.unwrap();
}
let fs = Musefs::open(musefs_db::Db::open(&db_path).unwrap(), config()).unwrap();
assert!(fs.lookup(VirtualTree::ROOT, "Bob").is_none());
{
let db2 = musefs_db::Db::open(&db_path).unwrap();
let id = db2
.upsert_track(&NewTrack {
backing_path: "/x/b.flac".into(),
format: Format::Flac,
audio_offset: 0,
audio_length: 0,
backing_size: 0,
backing_mtime_ns: 0,
backing_ctime_ns: 0,
})
.unwrap();
db2.replace_tags(
id,
&[Tag::new("artist", "Bob", 0), Tag::new("title", "B", 0)],
)
.unwrap();
}
fs.refresh_for_test().unwrap();
assert!(
fs.lookup(VirtualTree::ROOT, "Bob").is_some(),
"refresh must rebuild the tree"
);
}
#[test]
fn open_handle_returns_distinct_ids_and_rejects_dirs() {
let dir = tempfile::tempdir().unwrap();
let db = scanned_db(dir.path());
let fs = Musefs::open(db, config()).unwrap();
let artist = fs.lookup(VirtualTree::ROOT, "Alice").unwrap();
let (_, file_inode, _) = fs.readdir(artist).unwrap().into_iter().next().unwrap();
let fh1 = fs.open_handle(file_inode).unwrap();
let fh2 = fs.open_handle(file_inode).unwrap();
assert_ne!(fh1, fh2, "each open must yield a fresh handle id");
assert!(matches!(fs.open_handle(artist), Err(CoreError::IsDir(_))));
}
fn assert_backing_change_detected_through_handle(mutate: impl FnOnce(&std::path::Path)) {
let dir = tempfile::tempdir().unwrap();
let db = scanned_db(dir.path());
let fs = Musefs::open(db, config()).unwrap();
let artist = fs.lookup(VirtualTree::ROOT, "Alice").unwrap();
let (_, file_inode, _) = fs.readdir(artist).unwrap().into_iter().next().unwrap();
let size = fs.getattr(file_inode).unwrap().size;
let fh = fs.open_handle(file_inode).unwrap();
let warm = fs.read(file_inode, Some(fh), 0, size).unwrap();
assert_eq!(warm.len() as u64, size);
mutate(&dir.path().join("a.flac"));
let err = fs.read(file_inode, Some(fh), 0, size).unwrap_err();
assert!(matches!(err, CoreError::BackingChanged(_)), "got {err:?}");
}
#[test]
fn read_through_handle_errors_after_backing_grows_in_place() {
use std::io::Write;
assert_backing_change_detected_through_handle(|path| {
let mut f = std::fs::OpenOptions::new().append(true).open(path).unwrap();
f.write_all(&[0u8; 64]).unwrap();
});
}
#[test]
fn read_through_handle_errors_after_backing_truncated_in_place() {
assert_backing_change_detected_through_handle(|path| {
std::fs::write(path, [0xCDu8; 8]).unwrap();
});
}
#[test]
fn read_through_handle_errors_after_same_length_rewrite_with_new_mtime() {
assert_backing_change_detected_through_handle(|path| {
let original_len = std::fs::read(path).unwrap().len();
std::fs::write(path, vec![0xEEu8; original_len]).unwrap();
let distinct = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000_000);
let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
f.set_times(std::fs::FileTimes::new().set_modified(distinct))
.unwrap();
});
}
#[test]
fn read_through_handle_keeps_succeeding_when_backing_unchanged() {
let dir = tempfile::tempdir().unwrap();
let db = scanned_db(dir.path());
let fs = Musefs::open(db, config()).unwrap();
let artist = fs.lookup(VirtualTree::ROOT, "Alice").unwrap();
let (_, file_inode, _) = fs.readdir(artist).unwrap().into_iter().next().unwrap();
let size = fs.getattr(file_inode).unwrap().size;
let fh = fs.open_handle(file_inode).unwrap();
let first = fs.read(file_inode, Some(fh), 0, size).unwrap();
let second = fs.read(file_inode, Some(fh), 0, size).unwrap();
assert_eq!(first, second);
assert_eq!(first.len() as u64, size);
}
#[test]
fn release_handle_forces_fallback_on_next_read() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let db = scanned_db(dir.path());
let fs = Musefs::open(db, config()).unwrap();
let artist = fs.lookup(VirtualTree::ROOT, "Alice").unwrap();
let (_, file_inode, _) = fs.readdir(artist).unwrap().into_iter().next().unwrap();
let size = fs.getattr(file_inode).unwrap().size;
let fh = fs.open_handle(file_inode).unwrap();
fs.release_handle(fh);
{
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(dir.path().join("a.flac"))
.unwrap();
f.write_all(&[0u8; 64]).unwrap();
}
assert!(matches!(
fs.read(file_inode, Some(fh), 0, size),
Err(CoreError::BackingChanged(_))
));
}
#[test]
fn getattr_reresolves_size_after_content_version_bump() {
use common::{make_flac, streaminfo_body, vorbis_comment_body};
use musefs_db::Tag;
let dir = tempfile::tempdir().unwrap();
let backing = dir.path().join("a.flac");
let bytes = make_flac(
&[
(0, streaminfo_body()),
(4, vorbis_comment_body("v", &["ARTIST=Alice", "TITLE=Song"])),
],
&[0xAB; 64],
);
std::fs::write(&backing, &bytes).unwrap();
let db_path = dir.path().join("m.db");
{
let db = musefs_db::Db::open(&db_path).unwrap();
scan_directory(&db, dir.path()).unwrap();
}
let fs = Musefs::open(musefs_db::Db::open(&db_path).unwrap(), config()).unwrap();
let alice = fs.lookup(VirtualTree::ROOT, "Alice").unwrap();
let inode = fs.lookup(alice, "Song.flac").unwrap();
let size_before = fs.getattr(inode).unwrap().size;
let track_id = musefs_db::Db::open(&db_path)
.unwrap()
.list_tracks()
.unwrap()[0]
.id;
{
let db2 = musefs_db::Db::open(&db_path).unwrap();
db2.replace_tags(
track_id,
&[
Tag::new("artist", "Alice", 0),
Tag::new("title", "Song", 0),
Tag::new("album", &"X".repeat(500), 0),
],
)
.unwrap();
}
let size_after = fs.getattr(inode).unwrap().size;
assert!(
size_after > size_before,
"size must reflect the larger retagged header"
);
}
#[test]
fn forced_refresh_and_poll_refresh_never_publish_stale_tree() {
use musefs_db::{Format, NewTrack, Tag};
use std::sync::atomic::{AtomicBool, Ordering};
fn insert(db: &musefs_db::Db, n: usize) {
let id = db
.upsert_track(&NewTrack {
backing_path: format!("/x/track{n}.flac"),
format: Format::Flac,
audio_offset: 0,
audio_length: 0,
backing_size: 0,
backing_mtime_ns: 0,
backing_ctime_ns: 0,
})
.unwrap();
db.replace_tags(
id,
&[
Tag::new("artist", &format!("A{n}"), 0),
Tag::new("title", &format!("T{n}"), 0),
],
)
.unwrap();
}
const N: usize = 80;
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("m.db");
{
let db = musefs_db::Db::open(&db_path).unwrap();
insert(&db, 0);
}
let fs = Musefs::open(musefs_db::Db::open(&db_path).unwrap(), config()).unwrap();
let done = AtomicBool::new(false);
std::thread::scope(|s| {
s.spawn(|| {
let db = musefs_db::Db::open(&db_path).unwrap();
for n in 1..=N {
insert(&db, n);
}
done.store(true, Ordering::Release);
});
s.spawn(|| {
while !done.load(Ordering::Acquire) {
fs.refresh_for_test().unwrap();
}
});
s.spawn(|| {
while !done.load(Ordering::Acquire) {
let _ = fs.poll_refresh().unwrap();
}
});
});
fs.refresh_for_test().unwrap();
for n in 0..=N {
assert!(
fs.lookup(VirtualTree::ROOT, &format!("A{n}")).is_some(),
"track A{n} missing — a stale rebuild published an outdated tree"
);
}
}
fn first_file_inode(fs: &Musefs) -> u64 {
fn walk(fs: &Musefs, inode: u64) -> Option<u64> {
for (name, child, is_dir) in fs.readdir(inode).unwrap() {
if name == "." || name == ".." {
continue;
}
if is_dir {
if let Some(f) = walk(fs, child) {
return Some(f);
}
} else {
return Some(child);
}
}
None
}
walk(fs, VirtualTree::ROOT).expect("a file inode under root")
}
#[test]
fn getattr_size_cache_rejects_subsecond_rewrite() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("a.flac");
common::write_flac(&src, &["TITLE=T", "ARTIST=A"], &[0xAB; 4096]);
let db = musefs_db::Db::open_in_memory().unwrap();
scan_directory(&db, dir.path()).unwrap();
let fs = Musefs::open(db, config()).unwrap();
let inode = first_file_inode(&fs);
fs.getattr(inode).unwrap();
let mut v = std::fs::read(&src).unwrap();
*v.last_mut().unwrap() ^= 0xFF; std::fs::write(&src, v).unwrap();
let err = fs.getattr(inode).unwrap_err();
assert!(matches!(err, CoreError::BackingChanged(_)), "got {err:?}");
}