use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::time::SystemTime;
use parking_lot::Mutex;
const NAMES: [&str; 3] = ["cover", "folder", "front"];
const EXTENSIONS: [&str; 4] = ["jpg", "jpeg", "png", "webp"];
const REMEMBERED: usize = 16_384;
type Memo = HashMap<PathBuf, (Option<SystemTime>, Option<PathBuf>)>;
static MEMO: LazyLock<Mutex<Memo>> = LazyLock::new(Default::default);
pub fn is_cover_file(path: &Path) -> bool {
rank(path).is_some()
}
pub fn folder_cover(track: &Path) -> Option<PathBuf> {
let dir = track.parent()?;
in_dir(dir).or_else(|| {
dir.file_name()
.is_some_and(is_disc_folder)
.then(|| dir.parent().and_then(in_dir))
.flatten()
})
}
pub fn cover_art(track: &Path) -> Option<Vec<u8>> {
folder_cover(track)
.and_then(|p| std::fs::read(p).ok())
.filter(|bytes| !bytes.is_empty())
.or_else(|| super::metadata::extract_cover_art(track))
}
fn in_dir(dir: &Path) -> Option<PathBuf> {
let mtime = std::fs::metadata(dir).and_then(|m| m.modified()).ok();
if let Some((seen, cover)) = MEMO.lock().get(dir)
&& *seen == mtime
&& mtime.is_some()
{
return cover.clone();
}
let cover = std::fs::read_dir(dir)
.ok()?
.filter_map(Result::ok)
.filter(|e| e.file_type().is_ok_and(|t| t.is_file() || t.is_symlink()))
.filter_map(|e| {
let path = e.path();
rank(&path).map(|r| (r, path))
})
.min()
.map(|(_, path)| path);
let mut memo = MEMO.lock();
if memo.len() >= REMEMBERED {
memo.clear();
}
memo.insert(dir.to_path_buf(), (mtime, cover.clone()));
cover
}
fn rank(path: &Path) -> Option<(usize, usize)> {
let stem = path.file_stem()?.to_str()?.to_ascii_lowercase();
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
Some((
NAMES.iter().position(|n| *n == stem)?,
EXTENSIONS.iter().position(|e| *e == ext)?,
))
}
fn is_disc_folder(name: &std::ffi::OsStr) -> bool {
let name = name.to_string_lossy().to_ascii_lowercase();
let rest = ["disc", "disk", "cd"]
.iter()
.find_map(|p| name.strip_prefix(p))
.map(|r| r.trim_start_matches([' ', '_', '-', '.']));
rest.is_some_and(|r| !r.is_empty() && r.chars().all(|c| c.is_ascii_digit()))
}
#[cfg(test)]
mod tests {
use super::*;
fn touch(path: &Path, bytes: &[u8]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
#[test]
fn names_match_in_navidromes_order_regardless_of_case() {
let tmp = tempfile::tempdir().unwrap();
let album = tmp.path().join("Album");
let track = album.join("01.flac");
touch(&track, b"");
assert_eq!(folder_cover(&track), None);
touch(&album.join("FRONT.PNG"), b"f");
touch(&album.join("Folder.jpg"), b"d");
touch(&album.join("notes.jpg"), b"x");
assert_eq!(folder_cover(&track), Some(album.join("Folder.jpg")));
touch(&album.join("cover.webp"), b"w");
touch(&album.join("Cover.JPEG"), b"c");
assert_eq!(folder_cover(&track), Some(album.join("Cover.JPEG")));
}
#[test]
fn a_disc_folder_falls_back_to_the_albums() {
let tmp = tempfile::tempdir().unwrap();
let album = tmp.path().join("Album");
let track = album.join("CD 2").join("01.flac");
touch(&track, b"");
touch(&album.join("cover.jpg"), b"c");
assert_eq!(folder_cover(&track), Some(album.join("cover.jpg")));
let other = tmp.path().join("Artist").join("Album").join("01.flac");
touch(&other, b"");
touch(&tmp.path().join("Artist").join("cover.jpg"), b"a");
assert_eq!(folder_cover(&other), None);
assert!(is_disc_folder("Disc 1".as_ref()));
assert!(is_disc_folder("cd02".as_ref()));
assert!(is_disc_folder("Disk_3".as_ref()));
assert!(!is_disc_folder("CDs".as_ref()));
assert!(!is_disc_folder("Discography".as_ref()));
}
#[test]
fn a_cover_added_later_is_found() {
let tmp = tempfile::tempdir().unwrap();
let album = tmp.path().join("Album");
let track = album.join("01.flac");
touch(&track, b"");
assert_eq!(folder_cover(&track), None, "a miss, remembered");
std::thread::sleep(std::time::Duration::from_millis(1100));
touch(&album.join("folder.jpg"), b"d");
assert_eq!(folder_cover(&track), Some(album.join("folder.jpg")));
}
#[test]
fn the_image_beside_a_track_wins_over_embedded_art() {
let tmp = tempfile::tempdir().unwrap();
let track = tmp.path().join("Album").join("01.flac");
touch(&track, b"not audio");
assert_eq!(cover_art(&track), None);
touch(
&tmp.path().join("Album").join("folder.jpg"),
b"\xFF\xD8jpeg",
);
assert_eq!(cover_art(&track), Some(b"\xFF\xD8jpeg".to_vec()));
}
#[test]
fn only_cover_names_count() {
assert!(is_cover_file(Path::new("/m/a/Cover.JPG")));
assert!(is_cover_file(Path::new("/m/a/front.webp")));
assert!(!is_cover_file(Path::new("/m/a/back.jpg")));
assert!(!is_cover_file(Path::new("/m/a/cover.txt")));
assert!(!is_cover_file(Path::new("/m/a/cover")));
}
}