Skip to main content

reader/
utils.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4fn detect_image_extension(data: &[u8]) -> &'static str {
5    if data.len() >= 12 && &data[..8] == b"\x89PNG\r\n\x1a\n" {
6        "png"
7    } else if data.len() >= 3 && data[..3] == [0xFF, 0xD8, 0xFF] {
8        "jpg"
9    } else if data.len() >= 12 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10        "webp"
11    } else if data.len() >= 6 && (data[..6] == *b"GIF87a" || data[..6] == *b"GIF89a") {
12        "gif"
13    } else if data.len() >= 2 && data[..2] == [0x42, 0x4D] {
14        "bmp"
15    } else {
16        "jpg"
17    }
18}
19
20fn remove_stale_cover_variants(album_id: &str, cache_dir: &Path, keep_path: &Path) {
21    for extension in ["jpg", "png", "webp", "gif", "bmp", "tif"] {
22        let candidate = cache_dir.join(format!("{album_id}.{extension}"));
23        if candidate != keep_path {
24            let _ = fs::remove_file(candidate);
25        }
26    }
27}
28
29pub fn find_folder_cover(dir: &Path) -> Option<PathBuf> {
30    let candidates = [
31        "cover",
32        "folder",
33        "album",
34        "thumbnail",
35        "default",
36        "hqdefault",
37        "maxresdefault",
38        "preview",
39    ];
40    let extensions = ["jpg", "jpeg", "png", "webp"];
41
42    let entries = std::fs::read_dir(dir).ok()?;
43    let mut fallback_image = None;
44    let mut has_multiple_fallbacks = false;
45    let mut best_idx: Option<usize> = None;
46    let mut best_path = None;
47
48    for entry in entries.flatten() {
49        let path = entry.path();
50        if !path.is_file() {
51            continue;
52        }
53        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
54            continue;
55        };
56        let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
57            continue;
58        };
59
60        if extensions.iter().any(|e| e.eq_ignore_ascii_case(ext)) {
61            if let Some(pos) = candidates.iter().position(|c| c.eq_ignore_ascii_case(stem)) {
62                if best_idx.is_none() || pos < best_idx.unwrap() {
63                    best_idx = Some(pos);
64                    best_path = Some(path);
65                }
66            } else if is_safe_fallback_cover(stem) {
67                if fallback_image.is_some() {
68                    has_multiple_fallbacks = true;
69                    fallback_image = None;
70                } else if !has_multiple_fallbacks {
71                    fallback_image = Some(path);
72                }
73            }
74        }
75    }
76    best_path.or(fallback_image)
77}
78
79fn is_safe_fallback_cover(stem: &str) -> bool {
80    !matches!(
81        stem.to_ascii_lowercase().as_str(),
82        "artist"
83            | "background"
84            | "back"
85            | "banner"
86            | "booklet"
87            | "cd"
88            | "disc"
89            | "label"
90            | "logo"
91            | "spine"
92    )
93}
94
95pub fn is_artist_image_file(path: &Path) -> bool {
96    path.file_name()
97        .and_then(|name| name.to_str())
98        .map(|name| {
99            matches!(
100                name.to_ascii_lowercase().as_str(),
101                "artist.jpg" | "artist.jpeg" | "artist.png" | "artist.webp"
102            )
103        })
104        .unwrap_or(false)
105}
106
107pub fn save_cover(
108    album_id: &str,
109    data: &[u8],
110    extension: Option<&str>,
111    cache_dir: &Path,
112) -> std::io::Result<PathBuf> {
113    fs::create_dir_all(cache_dir)?;
114    let extension = extension.unwrap_or_else(|| detect_image_extension(data));
115    let path = cache_dir.join(format!("{album_id}.{extension}"));
116
117    remove_stale_cover_variants(album_id, cache_dir, &path);
118    fs::write(&path, data)?;
119    Ok(path)
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use std::fs::File;
126
127    #[test]
128    fn test_find_folder_cover() {
129        let nanos = std::time::SystemTime::now()
130            .duration_since(std::time::UNIX_EPOCH)
131            .unwrap()
132            .as_nanos();
133        let dir_path = std::env::temp_dir().join(format!("kopuz_test_dir_{nanos}"));
134        std::fs::create_dir_all(&dir_path).unwrap();
135
136        // 1. Empty directory
137        assert!(find_folder_cover(&dir_path).is_none());
138
139        // 2. Directory with only non-image files
140        File::create(dir_path.join("song.mp3")).unwrap();
141        File::create(dir_path.join("readme.txt")).unwrap();
142        assert!(find_folder_cover(&dir_path).is_none());
143
144        // 3. Directory with generic/fallback image
145        let random_image = dir_path.join("random_picture.jpg");
146        File::create(&random_image).unwrap();
147        assert_eq!(find_folder_cover(&dir_path), Some(random_image.clone()));
148
149        // 4. Directory with high-priority candidate image
150        let cover_image = dir_path.join("cover.png");
151        File::create(&cover_image).unwrap();
152        // Should prefer "cover.png" over "random_picture.jpg"
153        assert_eq!(find_folder_cover(&dir_path), Some(cover_image.clone()));
154
155        // 5. Directory with other candidate (e.g. hqdefault)
156        // Clean up and recreate with hqdefault
157        std::fs::remove_file(cover_image).unwrap();
158        std::fs::remove_file(random_image).unwrap();
159        let hq_image = dir_path.join("hqdefault.webp");
160        File::create(&hq_image).unwrap();
161        assert_eq!(find_folder_cover(&dir_path), Some(hq_image));
162
163        // 6. Ambiguous generic images should not be chosen arbitrarily
164        std::fs::remove_file(dir_path.join("hqdefault.webp")).unwrap();
165        File::create(dir_path.join("random_picture.jpg")).unwrap();
166        File::create(dir_path.join("other_picture.png")).unwrap();
167        assert!(find_folder_cover(&dir_path).is_none());
168
169        // Clean up
170        let _ = std::fs::remove_dir_all(&dir_path);
171    }
172}