medias 0.4.0

TV media player on bare DRM/KMS: Vulkan UI plane over libmpv video, for local files, UPnP/DLNA servers and YouTube, controlled by HDMI-CEC remote, evdev or terminal
//! Content library (milestone 8): scan media roots into browsable entries.
//! Defaults: ~/medias/Videos, ~/medias/Music. Pure filesystem model; the shell
//! turns entries into menus and mpv plays the files.

use std::io;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
    Dir,
    Video,
    Audio,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
    pub name: String,
    pub path: PathBuf,
    pub kind: Kind,
}

const VIDEO_EXT: &[&str] = &["mkv", "mp4", "avi", "mov", "webm", "m4v", "ts", "wmv"];
const AUDIO_EXT: &[&str] = &["mp3", "flac", "m4a", "ogg", "opus", "wav", "aac"];

/// Classify a path by extension; directories are classified separately.
pub fn classify(path: &Path) -> Option<Kind> {
    let ext = path.extension()?.to_str()?.to_ascii_lowercase();
    if VIDEO_EXT.contains(&ext.as_str()) {
        Some(Kind::Video)
    } else if AUDIO_EXT.contains(&ext.as_str()) {
        Some(Kind::Audio)
    } else {
        None
    }
}

/// List a directory: subdirectories and playable media only, directories first,
/// then case-insensitive by name. Hidden entries (leading '.') are skipped.
pub fn list(dir: &Path) -> io::Result<Vec<Entry>> {
    let mut entries = Vec::new();
    for e in std::fs::read_dir(dir)? {
        let e = e?;
        let path = e.path();
        let name = e.file_name().to_string_lossy().into_owned();
        if name.starts_with('.') {
            continue;
        }
        // DirEntry::file_type does not follow symlinks; metadata does. Broken links are skipped.
        let ft = e.file_type()?;
        let is_dir = if ft.is_symlink() {
            match std::fs::metadata(&path) {
                Ok(m) => m.is_dir(),
                Err(_) => continue,
            }
        } else {
            ft.is_dir()
        };
        let kind = if is_dir {
            Kind::Dir
        } else {
            match classify(&path) {
                Some(k) => k,
                None => continue,
            }
        };
        entries.push(Entry { name, path, kind });
    }
    entries.sort_by(|a, b| {
        let dir_first = (a.kind != Kind::Dir).cmp(&(b.kind != Kind::Dir));
        dir_first.then_with(|| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase()))
    });
    Ok(entries)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn tmp() -> PathBuf {
        let p = std::env::temp_dir().join(format!("medias-test-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&p);
        std::fs::create_dir_all(&p).unwrap();
        p
    }

    #[test]
    fn classify_by_ext() {
        assert_eq!(classify(Path::new("a.MKV")), Some(Kind::Video));
        assert_eq!(classify(Path::new("b.flac")), Some(Kind::Audio));
        assert_eq!(classify(Path::new("c.txt")), None);
        assert_eq!(classify(Path::new("noext")), None);
    }

    #[test]
    fn list_sorts_dirs_first_then_name_and_skips_junk() {
        let d = tmp();
        std::fs::create_dir(d.join("Zseason")).unwrap();
        std::fs::write(d.join("beta.mp4"), b"").unwrap();
        std::fs::write(d.join("Alpha.mkv"), b"").unwrap();
        std::fs::write(d.join("notes.txt"), b"").unwrap(); // skipped
        std::fs::write(d.join(".hidden.mp4"), b"").unwrap(); // skipped
        std::os::unix::fs::symlink(d.join("Zseason"), d.join("Linked")).unwrap();
        std::os::unix::fs::symlink(d.join("absent.mkv"), d.join("gone.mkv")).unwrap(); // skipped

        let got: Vec<(String, Kind)> =
            list(&d).unwrap().into_iter().map(|e| (e.name, e.kind)).collect();
        assert_eq!(
            got,
            vec![
                ("Linked".into(), Kind::Dir),
                ("Zseason".into(), Kind::Dir),
                ("Alpha.mkv".into(), Kind::Video),
                ("beta.mp4".into(), Kind::Video),
            ]
        );
        let _ = std::fs::remove_dir_all(&d);
    }

    #[test]
    fn missing_dir_errors() {
        assert!(list(Path::new("/no/such/medias/dir")).is_err());
    }
}