use anyhow::Result;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::iso;
const NETWORK_FS_TYPES: &[&str] = &[
"nfs",
"nfs4",
"smb",
"smb2",
"smb3",
"smbfs",
"cifs",
"fuse.sshfs",
"afs",
"ceph",
"9p",
"glusterfs",
];
pub fn detect_network_mount(path: &Path) -> Option<String> {
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().ok()?.join(path)
};
let abs_str = abs.to_str()?.to_string();
let mounts = std::fs::read_to_string("/proc/mounts").ok()?;
let mut best: Option<(usize, String)> = None;
for line in mounts.lines() {
let mut parts = line.split_whitespace();
let (_device, mount_point, fs_type) = match (parts.next(), parts.next(), parts.next()) {
(Some(d), Some(m), Some(f)) => (d, m, f),
_ => continue,
};
let is_prefix = mount_point == "/"
|| abs_str == mount_point
|| abs_str.starts_with(&format!("{}/", mount_point));
if !is_prefix {
continue;
}
let len = mount_point.len();
let replace = match &best {
Some((b, _)) => len > *b,
None => true,
};
if replace {
best = Some((len, fs_type.to_string()));
}
}
let (_, fs_type) = best?;
if NETWORK_FS_TYPES
.iter()
.any(|n| fs_type == *n || fs_type.starts_with(&format!("{}.", n)))
{
Some(fs_type)
} else {
None
}
}
fn walk_files(dir: &Path, out: &mut Vec<PathBuf>, visited_dirs: &mut HashSet<PathBuf>) {
let canonical = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
if !visited_dirs.insert(canonical) {
return;
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
match std::fs::metadata(&path) {
Ok(m) if m.is_dir() => walk_files(&path, out, visited_dirs),
Ok(m) if m.is_file() => out.push(path),
_ => {}
}
}
}
pub fn scan(root: &Path, extensions: &[String]) -> Result<Vec<PathBuf>> {
let ext_lower: Vec<String> = extensions.iter().map(|e| e.to_lowercase()).collect();
if root.is_file() {
let passes = iso::is_disc_image(root)
|| root
.extension()
.and_then(|e| e.to_str())
.map(|e| ext_lower.contains(&e.to_lowercase()))
.unwrap_or(false);
if passes {
return Ok(vec![root.to_path_buf()]);
}
return Ok(vec![]);
}
let mut all_files = Vec::new();
walk_files(root, &mut all_files, &mut HashSet::new());
let mut seen = HashSet::new();
let mut files: Vec<PathBuf> = all_files
.into_iter()
.filter(|path| {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with(".hvac_tmp_") {
return false;
}
if name.contains(".transcoded.") {
return false;
}
}
if iso::is_disc_image(path) {
return true;
}
path.extension()
.and_then(|e| e.to_str())
.map(|e| ext_lower.contains(&e.to_lowercase()))
.unwrap_or(false)
})
.filter(|path| {
let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
seen.insert(canonical)
})
.collect();
files.sort();
log::debug!("Scanned {:?}: found {} media files", root, files.len());
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_scan_finds_files() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("video.mkv"), "fake").unwrap();
fs::write(dir.path().join("video.mp4"), "fake").unwrap();
fs::write(dir.path().join("readme.txt"), "fake").unwrap();
let exts = vec!["mkv".to_string(), "mp4".to_string()];
let files = scan(dir.path(), &exts).unwrap();
assert_eq!(files.len(), 2);
}
#[test]
fn test_scan_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let exts = vec!["mkv".to_string()];
let files = scan(dir.path(), &exts).unwrap();
assert_eq!(files.len(), 0);
}
#[test]
fn test_scan_nested_dirs() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("Season 1");
fs::create_dir(&sub).unwrap();
fs::write(sub.join("ep01.mkv"), "fake").unwrap();
fs::write(sub.join("ep02.mkv"), "fake").unwrap();
let exts = vec!["mkv".to_string()];
let files = scan(dir.path(), &exts).unwrap();
assert_eq!(files.len(), 2);
}
#[test]
fn test_scan_case_insensitive() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("video.MKV"), "fake").unwrap();
let exts = vec!["mkv".to_string()];
let files = scan(dir.path(), &exts).unwrap();
assert_eq!(files.len(), 1);
}
#[test]
fn test_scan_skips_transcoded_files() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("episode.mkv"), "fake").unwrap();
fs::write(dir.path().join("episode.transcoded.mkv"), "fake").unwrap();
let exts = vec!["mkv".to_string()];
let files = scan(dir.path(), &exts).unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].file_name().unwrap().to_str().unwrap() == "episode.mkv");
}
#[test]
fn test_scan_skips_tmp_files() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("episode.mkv"), "fake").unwrap();
fs::write(dir.path().join(".hvac_tmp_episode.mkv"), "fake").unwrap();
let exts = vec!["mkv".to_string()];
let files = scan(dir.path(), &exts).unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].file_name().unwrap().to_str().unwrap() == "episode.mkv");
}
#[test]
fn test_scan_single_file() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("movie.mkv");
fs::write(&file, "fake").unwrap();
let exts = vec!["mkv".to_string()];
let files = scan(&file, &exts).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0], file);
}
#[test]
fn test_scan_single_file_wrong_ext() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("document.txt");
fs::write(&file, "fake").unwrap();
let exts = vec!["mkv".to_string()];
let files = scan(&file, &exts).unwrap();
assert_eq!(files.len(), 0);
}
#[test]
fn test_detect_network_mount_local_paths_are_not_network() {
let dir = tempfile::tempdir().unwrap();
match detect_network_mount(dir.path()) {
None => {}
Some(fs) => assert!(
NETWORK_FS_TYPES
.iter()
.any(|n| fs == *n || fs.starts_with(&format!("{}.", n))),
"detect_network_mount returned non-network fs type: {:?}",
fs
),
}
}
#[test]
fn test_detect_network_mount_handles_nonexistent_path() {
let result = detect_network_mount(Path::new("/this/path/does/not/exist/anywhere"));
if let Some(ref fs) = result {
assert!(
NETWORK_FS_TYPES
.iter()
.any(|n| fs == n || fs.starts_with(&format!("{}.", n))),
"got non-network fs type: {:?}",
fs
);
}
}
#[test]
fn test_scan_dedup_symlinks() {
let dir = tempfile::tempdir().unwrap();
let season1 = dir.path().join("Season 1");
fs::create_dir(&season1).unwrap();
fs::write(season1.join("ep01.mkv"), "fake").unwrap();
#[cfg(unix)]
{
std::os::unix::fs::symlink(&season1, dir.path().join("Season 01")).unwrap();
let exts = vec!["mkv".to_string()];
let files = scan(dir.path(), &exts).unwrap();
assert_eq!(files.len(), 1, "symlinked duplicate should be deduplicated");
}
}
}