use std::path::{Path, PathBuf};
use notify::event::{CreateKind, EventKind, ModifyKind, RemoveKind};
use super::metadata::is_audio_file;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WatchedRoot {
pub path: PathBuf,
pub real: PathBuf,
pub identity: Option<(u64, u64)>,
}
impl WatchedRoot {
pub fn resolve(path: &Path) -> Option<Self> {
let identity = identity(path)?;
Some(Self {
path: path.to_path_buf(),
real: path.canonicalize().unwrap_or_else(|_| path.to_path_buf()),
identity: Some(identity),
})
}
}
#[cfg(unix)]
fn identity(path: &Path) -> Option<(u64, u64)> {
use std::os::unix::fs::MetadataExt;
let meta = std::fs::metadata(path).ok()?;
meta.is_dir().then(|| (meta.dev(), meta.ino()))
}
#[cfg(not(unix))]
fn identity(path: &Path) -> Option<(u64, u64)> {
std::fs::metadata(path).ok()?.is_dir().then_some((0, 0))
}
pub fn scan_target(kind: &EventKind, path: &Path, roots: &[WatchedRoot]) -> Option<PathBuf> {
if !can_change_index(kind) {
return None;
}
let (root, rel) = roots.iter().find_map(|root| {
path.strip_prefix(&root.path)
.or_else(|_| path.strip_prefix(&root.real))
.ok()
.map(|rel| (root, rel))
})?;
if rel.as_os_str().is_empty() || rel.components().any(|c| is_ignored(c.as_os_str())) {
return None;
}
let path = root.path.join(rel);
if is_audio_file(&path) {
return path.parent().map(Path::to_path_buf);
}
match std::fs::metadata(&path) {
Ok(meta) if meta.is_dir() => Some(path),
Ok(_) => None,
Err(_) if is_file_event(kind) => None,
Err(_) => Some(path),
}
}
fn can_change_index(kind: &EventKind) -> bool {
!matches!(
kind,
EventKind::Access(_) | EventKind::Modify(ModifyKind::Metadata(_)) | EventKind::Other
)
}
fn is_file_event(kind: &EventKind) -> bool {
matches!(
kind,
EventKind::Create(CreateKind::File)
| EventKind::Remove(RemoveKind::File)
| EventKind::Modify(ModifyKind::Data(_))
)
}
fn is_ignored(name: &std::ffi::OsStr) -> bool {
let name = name.to_string_lossy();
name.starts_with('.')
|| name.starts_with("~syncthing~")
|| name.ends_with(".part")
|| name.ends_with(".tmp")
}
#[cfg(test)]
mod tests {
use super::*;
use notify::event::{AccessKind, DataChange, MetadataKind, RenameMode};
fn root(dir: &Path) -> Vec<WatchedRoot> {
vec![WatchedRoot::resolve(dir).unwrap()]
}
#[test]
fn an_audio_file_names_its_directory() {
let tmp = tempfile::tempdir().unwrap();
let album = tmp.path().join("Artist/Album");
std::fs::create_dir_all(&album).unwrap();
let track = album.join("01.flac");
std::fs::write(&track, b"").unwrap();
let roots = root(tmp.path());
for kind in [
EventKind::Create(CreateKind::File),
EventKind::Modify(ModifyKind::Data(DataChange::Content)),
EventKind::Modify(ModifyKind::Name(RenameMode::Any)),
EventKind::Remove(RemoveKind::File),
] {
assert_eq!(scan_target(&kind, &track, &roots), Some(album.clone()));
}
assert_eq!(
scan_target(
&EventKind::Remove(RemoveKind::File),
&album.join("02.flac"),
&roots
),
Some(album.clone())
);
}
#[test]
fn access_and_metadata_change_nothing() {
let tmp = tempfile::tempdir().unwrap();
let track = tmp.path().join("a.flac");
std::fs::write(&track, b"").unwrap();
let roots = root(tmp.path());
for kind in [
EventKind::Access(AccessKind::Any),
EventKind::Modify(ModifyKind::Metadata(MetadataKind::Any)),
EventKind::Modify(ModifyKind::Metadata(MetadataKind::Extended)),
EventKind::Other,
] {
assert_eq!(scan_target(&kind, &track, &roots), None, "{kind:?}");
}
}
#[test]
fn syncthing_hidden_and_partial_files_are_ignored() {
let tmp = tempfile::tempdir().unwrap();
let roots = root(tmp.path());
let create = EventKind::Create(CreateKind::Any);
for rel in [
".stfolder",
".stversions/Artist/Album/01~20260101-000000.flac",
"Artist/Album/.syncthing.01.flac.tmp",
"Artist/Album/~syncthing~01.flac.tmp",
"Artist/Album/01.flac.part",
"Artist/.DS_Store",
".hidden/Album/01.flac",
] {
assert_eq!(
scan_target(&create, &tmp.path().join(rel), &roots),
None,
"{rel}"
);
}
}
#[test]
fn non_audio_files_are_ignored() {
let tmp = tempfile::tempdir().unwrap();
let album = tmp.path().join("Album");
std::fs::create_dir_all(&album).unwrap();
std::fs::write(album.join("cover.jpg"), b"").unwrap();
let roots = root(tmp.path());
let any = EventKind::Modify(ModifyKind::Any);
assert_eq!(scan_target(&any, &album.join("cover.jpg"), &roots), None);
assert_eq!(
scan_target(
&EventKind::Remove(RemoveKind::File),
&album.join("folder.jpg"),
&roots
),
None
);
}
#[test]
fn a_directory_names_itself_and_a_vanished_one_too() {
let tmp = tempfile::tempdir().unwrap();
let album = tmp.path().join("Artist/Album (Vol. 2)");
std::fs::create_dir_all(&album).unwrap();
let roots = root(tmp.path());
assert_eq!(
scan_target(&EventKind::Create(CreateKind::Folder), &album, &roots),
Some(album.clone())
);
let gone = tmp.path().join("Artist/Old Album");
for kind in [
EventKind::Remove(RemoveKind::Folder),
EventKind::Modify(ModifyKind::Name(RenameMode::Any)),
] {
assert_eq!(scan_target(&kind, &gone, &roots), Some(gone.clone()));
}
}
#[test]
fn the_root_itself_and_paths_outside_it_are_ignored() {
let tmp = tempfile::tempdir().unwrap();
let music = tmp.path().join("music");
std::fs::create_dir_all(&music).unwrap();
let roots = root(&music);
let any = EventKind::Any;
assert_eq!(scan_target(&any, &music, &roots), None);
assert_eq!(scan_target(&any, &tmp.path().join("x.flac"), &roots), None);
}
#[cfg(unix)]
#[test]
fn events_under_the_real_path_come_back_under_the_configured_one() {
let tmp = tempfile::tempdir().unwrap();
let real = tmp.path().join("disk/music");
std::fs::create_dir_all(real.join("Album")).unwrap();
let link = tmp.path().join("music");
std::os::unix::fs::symlink(&real, &link).unwrap();
let roots = root(&link);
let event = roots[0].real.join("Album/01.flac");
assert_eq!(
scan_target(&EventKind::Create(CreateKind::File), &event, &roots),
Some(link.join("Album"))
);
}
}