use std::{
fs,
path::{Path, PathBuf},
time::SystemTime,
};
use chrono::{DateTime, Utc};
use natord::compare as natural_compare;
use crate::{
models::{Episode, MediaEntry, Movie, Season, Show, VIDEO_EXTENSIONS},
sidecar::{load_movie_state, load_show_bookmarks},
tmdb::{extract_metadata, extract_metadata_with_episodes, parse_episode},
};
pub fn scan_library(root: &Path) -> Vec<MediaEntry> {
let mut entries = Vec::new();
let dir_iter = match fs::read_dir(root) {
Ok(it) => it,
Err(e) => {
eprintln!("Failed to read library root {:?}: {}", root, e);
return entries;
}
};
for entry in dir_iter.flatten() {
let path = entry.path();
if path.is_file() && is_video(&path) {
if let Some(movie) = movie_from_single_file(root, &path) {
entries.push(MediaEntry::Movie(movie));
}
} else if path.is_dir() {
match classify_subfolder(&path) {
SubfolderKind::Movie(video_path) => {
if let Some(movie) = movie_from_single_file(&path, &video_path) {
entries.push(MediaEntry::Movie(movie));
}
}
SubfolderKind::Show => {
if let Some(show) = show_from_dir(&path) {
entries.push(MediaEntry::Show(show));
}
}
SubfolderKind::Empty => {}
}
}
}
entries
}
enum SubfolderKind {
Movie(PathBuf),
Show,
Empty,
}
const EXTRAS_FOLDER_NAMES: &[&str] = &[
"extras",
"extra",
"featurettes",
"featurette",
"behind the scenes",
"deleted scenes",
"bonus",
"bonus features",
"special features",
"specials",
"interviews",
"trailers",
"trailer",
"samples",
"sample",
"shorts",
"other",
];
fn is_extras_folder(dir_name: &str) -> bool {
let lower = dir_name.to_lowercase();
EXTRAS_FOLDER_NAMES.iter().any(|&name| lower == name)
}
fn classify_subfolder(dir: &Path) -> SubfolderKind {
let videos = collect_videos_recursive(dir);
let main_videos: Vec<&PathBuf> = videos
.iter()
.filter(|v| !is_inside_extras_folder(v, dir))
.collect();
match main_videos.len() {
0 => SubfolderKind::Empty,
1 => SubfolderKind::Movie(main_videos[0].clone()),
_ => {
let has_episodes = main_videos.iter().any(|v| {
let stem = stem_title(v);
let parsed = parse_episode(&stem);
parsed.episode_num > 0
});
if has_episodes {
SubfolderKind::Show
} else {
let largest = main_videos
.iter()
.max_by_key(|v| fs::metadata(v).map(|m| m.len()).unwrap_or(0))
.unwrap();
SubfolderKind::Movie((*largest).clone())
}
}
}
}
fn is_inside_extras_folder(video: &Path, base: &Path) -> bool {
let Ok(relative) = video.strip_prefix(base) else {
return false;
};
for component in relative.parent().iter().flat_map(|p| p.components()) {
let name = component.as_os_str().to_string_lossy();
if is_extras_folder(&name) {
return true;
}
}
false
}
fn collect_videos_recursive(dir: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
if let Ok(iter) = fs::read_dir(dir) {
for entry in iter.flatten() {
let p = entry.path();
if p.is_file() && is_video(&p) {
found.push(p);
} else if p.is_dir() {
found.extend(collect_videos_recursive(&p));
}
}
}
found
}
fn movie_from_single_file(base_dir: &Path, video_path: &Path) -> Option<Movie> {
let raw = stem_title(video_path);
let metadata = extract_metadata(&raw);
let title = raw.clone();
let state = load_movie_state(video_path).unwrap_or_default();
let poster_path = base_dir.join(format!(
"{}.media.poster.jpg",
video_path.file_stem().unwrap_or_default().to_string_lossy()
));
Some(Movie {
title,
base_dir: base_dir.to_path_buf(),
video_mtime: mtime(video_path),
video_path: video_path.to_path_buf(),
state,
poster_path,
metadata,
subtitles: Vec::new(),
external_subs: Vec::new(),
})
}
fn show_from_dir(dir: &Path) -> Option<Show> {
let title = dir_title(dir);
let seasons = build_seasons(dir);
if seasons.is_empty() {
return None;
}
let episode_stems: Vec<String> = seasons
.iter()
.flat_map(|s| s.episodes.iter())
.filter_map(|ep| {
ep.video_path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
})
.collect();
let metadata = extract_metadata_with_episodes(&title, &episode_stems);
let bookmarks = load_show_bookmarks(dir).unwrap_or_default();
let folder_stem = dir.file_name().unwrap_or_default().to_string_lossy();
let poster_path = dir.join(format!("{folder_stem}.media.poster.jpg"));
Some(Show {
title,
base_dir: dir.to_path_buf(),
seasons,
bookmarks,
poster_path,
metadata,
})
}
fn build_seasons(dir: &Path) -> Vec<Season> {
let mut seasons: Vec<Season> = Vec::new();
let Ok(entries) = fs::read_dir(dir) else {
return seasons;
};
let mut root_videos: Vec<PathBuf> = Vec::new();
let mut subdirs: Vec<PathBuf> = Vec::new();
for entry in entries.flatten() {
let p = entry.path();
if p.is_file() && is_video(&p) {
root_videos.push(p);
} else if p.is_dir() {
subdirs.push(p);
}
}
if !root_videos.is_empty() {
root_videos.sort_by(|a, b| {
natural_compare(
a.file_name().unwrap_or_default().to_str().unwrap_or(""),
b.file_name().unwrap_or_default().to_str().unwrap_or(""),
)
});
seasons.push(Season {
label: "Episodes".to_string(),
episodes: root_videos.iter().map(|p| make_episode(dir, p)).collect(),
});
}
subdirs.sort_by(|a, b| {
natural_compare(
a.file_name().unwrap_or_default().to_str().unwrap_or(""),
b.file_name().unwrap_or_default().to_str().unwrap_or(""),
)
});
for subdir in subdirs {
let folder_name = subdir
.file_name()
.unwrap_or_default()
.to_string_lossy();
if is_extras_folder(&folder_name) {
continue;
}
let mut vids = collect_videos_direct(&subdir);
if vids.is_empty() {
continue;
}
vids.sort_by(|a, b| {
natural_compare(
a.file_name().unwrap_or_default().to_str().unwrap_or(""),
b.file_name().unwrap_or_default().to_str().unwrap_or(""),
)
});
let label = dir_title(&subdir);
seasons.push(Season {
label,
episodes: vids.iter().map(|p| make_episode(dir, p)).collect(),
});
}
seasons
}
fn collect_videos_direct(dir: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
if let Ok(iter) = fs::read_dir(dir) {
for entry in iter.flatten() {
let p = entry.path();
if p.is_file() && is_video(&p) {
found.push(p);
}
}
}
found
}
fn make_episode(show_base: &Path, video_path: &Path) -> Episode {
let raw_stem = stem_title(video_path);
let parsed = parse_episode(&raw_stem);
let relative_path = video_path
.strip_prefix(show_base)
.unwrap_or(video_path)
.to_string_lossy()
.replace('\\', "/");
Episode {
title: raw_stem,
season_num: parsed.season_num,
episode_num: parsed.episode_num,
episode_title: parsed.episode_title,
video_mtime: mtime(video_path),
video_path: video_path.to_path_buf(),
relative_path,
subtitles: Vec::new(),
external_subs: Vec::new(),
}
}
pub fn is_video(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| VIDEO_EXTENSIONS.contains(&e.to_lowercase().as_str()))
.unwrap_or(false)
}
fn stem_title(path: &Path) -> String {
path.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string()
}
fn dir_title(path: &Path) -> String {
path.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
}
fn mtime(path: &Path) -> Option<DateTime<Utc>> {
fs::metadata(path)
.ok()?
.modified()
.ok()
.map(|t: SystemTime| t.into())
}