mediavault-core 0.1.5

Core library for MediaVault — scanning, sidecar I/O, TMDB integration, and metadata parsing
Documentation
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},
};

// ── Public entry point ────────────────────────────────────────────────────────

/// Scan `root` and return all recognised media entries.
///
/// Detection rules:
/// - A video file directly in `root`                           → Movie
/// - A direct subfolder containing exactly **one** video file
///   (ignoring known extras folders like Featurettes/)          → Movie
/// - A direct subfolder with **multiple** non-extras video files
///   where at least one has an episode pattern (S01E01)         → Show
/// - A direct subfolder with **multiple** non-extras video files
///   but **no** episode patterns → Movie (largest file is the feature)
///
/// Entries are *not* sorted here; the GUI/CLI layer decides ordering.
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) {
            // A bare video file at the root level is a movie whose base_dir is
            // the root itself. We use the filename (sans extension) as title.
            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
}

// ── Classification ────────────────────────────────────────────────────────────

enum SubfolderKind {
    Movie(PathBuf),
    Show,
    Empty,
}

/// Subfolder names that typically hold bonus content rather than episodes.
/// Matched case-insensitively against directory names.
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)
}

/// Determine whether a direct child directory represents a movie or a show.
///
/// Classification rules:
/// 1. Videos inside known extras folders (Featurettes/, Extras/, etc.) are
///    excluded from the count — they're bonus content, not episodes.
/// 2. If only one non-extras video remains, it's a Movie.
/// 3. If multiple non-extras videos remain, check whether any have episode
///    patterns (S01E01). If at least one does, it's a Show. Otherwise it's
///    a Movie (the largest file), since the extras just weren't in a
///    subfolder.
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 {
                // No episode patterns — treat as movie with extras.
                // Pick the largest file as the main feature.
                let largest = main_videos
                    .iter()
                    .max_by_key(|v| fs::metadata(v).map(|m| m.len()).unwrap_or(0))
                    .unwrap();
                SubfolderKind::Movie((*largest).clone())
            }
        }
    }
}

/// Check whether a video path sits inside a known extras subfolder relative
/// to the entry's base directory.
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
}

/// Recursively collect all video file paths under `dir`.
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
}

// ── Movie construction ────────────────────────────────────────────────────────

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(),
    })
}

// ── Show construction ─────────────────────────────────────────────────────────

fn show_from_dir(dir: &Path) -> Option<Show> {
    let title = dir_title(dir);
    let seasons = build_seasons(dir);
    if seasons.is_empty() {
        return None;
    }
    // Collect bare filename stems from all episodes so extract_metadata_with_episodes
    // can find tags common to all files when the folder name alone has 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,
    })
}

/// Build the season/episode tree for a show directory.
///
/// Strategy:
/// 1. Any video files directly in `dir` → synthetic "Episodes" season.
/// 2. Any subdirectories of `dir` that contain videos → one season each,
///    labelled by subfolder name and sorted naturally.
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);
        }
    }

    // Root-level videos form an unnamed / flat season.
    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(),
        });
    }

    // Each subdirectory that contains videos becomes a season.
    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 {
        // Skip known extras/bonus-content folders.
        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
}

/// Collect video files directly inside `dir` (non-recursive).
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(),
    }
}

// ── Helpers ───────────────────────────────────────────────────────────────────

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)
}

/// Best-effort title from a file path: filename without extension.
fn stem_title(path: &Path) -> String {
    path.file_stem()
        .unwrap_or_default()
        .to_string_lossy()
        .to_string()
}

/// Best-effort title from a directory: the directory name.
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())
}