Skip to main content

chilen_backend/music_lib/indexer/
covers.rs

1use std::{
2    collections::HashSet,
3    fs::{File, create_dir_all},
4    hash::{DefaultHasher, Hash, Hasher},
5    io::{Cursor, Read},
6    path::PathBuf,
7    sync::{Arc, LazyLock, RwLock},
8};
9
10pub use image::ImageFormat;
11use image::{ImageBuffer, ImageReader, Rgb, imageops::resize};
12use lofty::{
13    picture::{Picture, PictureType},
14    tag::Tag,
15};
16use log::{error, warn};
17use serde::{Deserialize, Serialize};
18
19use crate::music_lib::{
20    CACHE_DIR,
21    indexer::{self, CacheMode},
22    state::Track,
23};
24
25pub use image::imageops::FilterType as CoverFilter;
26
27#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28pub enum Quality {
29    Low,
30    #[default]
31    Default,
32    High,
33    Lossless,
34    Custom(u32),
35}
36
37impl Quality {
38    pub(crate) fn resolution(self) -> Option<u32> {
39        match self {
40            Quality::Low => Some(256),
41            Quality::Default => Some(512),
42            Quality::High => Some(1024),
43            Quality::Lossless => None,
44            Quality::Custom(pixels) => Some(pixels),
45        }
46    }
47}
48
49#[cfg_attr(test, derive(Default))]
50#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
51pub struct Cover {
52    pub hires: Option<PathBuf>,
53    pub thumbnail: Option<PathBuf>,
54}
55
56impl Cover {
57    pub(crate) fn none() -> Self {
58        Self {
59            hires: None,
60            thumbnail: None,
61        }
62    }
63}
64
65#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
66pub struct Config {
67    pub format: ImageFormat,
68    pub filter: CoverFilter,
69    pub thumbnail_resolution: u32,
70    pub quality: Quality,
71}
72
73impl Default for Config {
74    fn default() -> Self {
75        Self {
76            format: ImageFormat::Png,
77            filter: CoverFilter::Triangle,
78            thumbnail_resolution: 40,
79            quality: Quality::default(),
80        }
81    }
82}
83
84impl Config {
85    pub(crate) fn extension(&self) -> String {
86        match self.format {
87            ImageFormat::Png => "png",
88            ImageFormat::Jpeg => "jpg",
89            ImageFormat::Gif => "gif",
90            ImageFormat::WebP => "webp",
91            ImageFormat::Pnm => "pnm",
92            ImageFormat::Tiff => "tiff",
93            ImageFormat::Tga => "tga",
94            ImageFormat::Dds => "dds",
95            ImageFormat::Bmp => "bmp",
96            ImageFormat::Ico => "ico",
97            ImageFormat::Hdr => "hdr",
98            ImageFormat::OpenExr => "exr",
99            ImageFormat::Farbfeld => "ff",
100            ImageFormat::Avif => "avif",
101            ImageFormat::Qoi => "qoi",
102            _ => "img",
103        }
104        .to_string()
105    }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
109pub enum CoverError {
110    NoPictures,
111    NoSuitablePictures,
112    CoverWriteError { path: PathBuf, error: String },
113    CacheDirError { path: PathBuf, error: String },
114    UnknownFileFormat,
115    DecodingError,
116    Playlist,
117}
118
119impl std::fmt::Display for CoverError {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        match self {
122            Self::NoPictures => write!(f, "Could not find any pictures in the tag"),
123            Self::NoSuitablePictures => write!(
124                f,
125                "The tag contains pictures, but none of them can be used as cover art replacement"
126            ),
127            Self::CoverWriteError { path, error } => write!(
128                f,
129                "Could not write the cover image to cache at {path:?}: {error}"
130            ),
131            Self::CacheDirError { path, error } => {
132                write!(
133                    f,
134                    "Could not create the cache directory at {path:?}: {error}"
135                )
136            }
137            CoverError::UnknownFileFormat => write!(f, "Could not guess the image format"),
138            CoverError::DecodingError => write!(f, "Could not decode the image data"),
139            Self::Playlist => write!(f, "Could not create a playlist cover image"),
140        }
141    }
142}
143
144const FRONT_COVER_PRIORITY: [PictureType; 21] = [
145    PictureType::CoverFront,
146    PictureType::CoverBack,
147    PictureType::Illustration,
148    PictureType::Leaflet,
149    PictureType::Media,
150    PictureType::BandLogo,
151    PictureType::Other,
152    PictureType::Band,
153    PictureType::ScreenCapture,
154    PictureType::DuringPerformance,
155    PictureType::DuringRecording,
156    PictureType::RecordingLocation,
157    PictureType::BrightFish,
158    PictureType::LeadArtist,
159    PictureType::Artist,
160    PictureType::Composer,
161    PictureType::Lyricist,
162    PictureType::Conductor,
163    PictureType::PublisherLogo,
164    PictureType::Icon,
165    PictureType::OtherIcon,
166];
167
168pub(crate) static TRACK_HIRES_COVER_CACHE_DIR: LazyLock<Result<PathBuf, CoverError>> =
169    LazyLock::new(|| {
170        let mut cache = CACHE_DIR.read().unwrap().clone().unwrap();
171        cache.push("covers");
172        cache.push("tracks");
173        cache.push("hires");
174        if !cache.is_dir()
175            && let Err(e) = create_dir_all(&cache)
176        {
177            error!("Could not create directory at {cache:?}!");
178            Err(CoverError::CoverWriteError {
179                path: cache,
180                error: e.to_string(),
181            })
182        } else {
183            Ok(cache)
184        }
185    });
186
187pub(crate) static TRACK_THUMBNAIL_CACHE_DIR: LazyLock<Result<PathBuf, CoverError>> =
188    LazyLock::new(|| {
189        let mut cache = CACHE_DIR.read().unwrap().clone().unwrap();
190        cache.push("covers");
191        cache.push("tracks");
192        cache.push("thumbnails");
193        if !cache.is_dir()
194            && let Err(e) = create_dir_all(&cache)
195        {
196            error!("Could not create directory at {cache:?}!");
197            Err(CoverError::CoverWriteError {
198                path: cache,
199                error: e.to_string(),
200            })
201        } else {
202            Ok(cache)
203        }
204    });
205
206pub(crate) static PLAYLIST_HIRES_COVER_CACHE_DIR: LazyLock<Result<PathBuf, CoverError>> =
207    LazyLock::new(|| {
208        let mut cache = CACHE_DIR.read().unwrap().clone().unwrap();
209        cache.push("covers");
210        cache.push("playlists");
211        cache.push("hires");
212        if !cache.is_dir()
213            && let Err(e) = create_dir_all(&cache)
214        {
215            error!("Could not create directory at {cache:?}!");
216            Err(CoverError::CoverWriteError {
217                path: cache,
218                error: e.to_string(),
219            })
220        } else {
221            Ok(cache)
222        }
223    });
224
225pub(crate) static PLAYLIST_THUMBNAIL_CACHE_DIR: LazyLock<Result<PathBuf, CoverError>> =
226    LazyLock::new(|| {
227        let mut cache = CACHE_DIR.read().unwrap().clone().unwrap();
228        cache.push("covers");
229        cache.push("playlists");
230        cache.push("thumbnails");
231        if !cache.is_dir()
232            && let Err(e) = create_dir_all(&cache)
233        {
234            error!("Could not create directory at {cache:?}!");
235            Err(CoverError::CoverWriteError {
236                path: cache,
237                error: e.to_string(),
238            })
239        } else {
240            Ok(cache)
241        }
242    });
243
244fn pick_front_cover_or_replacement(pictures: &[Picture]) -> Result<&Picture, CoverError> {
245    if pictures.is_empty() {
246        return Err(CoverError::NoPictures);
247    }
248
249    for pic_type in FRONT_COVER_PRIORITY {
250        for pic in pictures {
251            if pic.pic_type() == pic_type {
252                return Ok(pic);
253            }
254        }
255    }
256
257    Err(CoverError::NoSuitablePictures)
258}
259
260pub(crate) fn get_track_cover(
261    tag: &Tag,
262    config: indexer::Config,
263    covers_lookup_set: &RwLock<HashSet<PathBuf>>,
264) -> Result<Cover, CoverError> {
265    let hires_cache = TRACK_HIRES_COVER_CACHE_DIR.clone()?;
266    let thumbnail_cache = TRACK_THUMBNAIL_CACHE_DIR.clone()?;
267
268    let image_buf = pick_front_cover_or_replacement(tag.pictures())?.data();
269    let mut hasher = DefaultHasher::new();
270    image_buf.hash(&mut hasher);
271    let hash = hasher.finish();
272
273    let extension = config.covers.extension();
274    let mut hires_path = hires_cache.clone();
275    hires_path.push(format!("{hash}.{extension}"));
276    let mut thumbnail_path = thumbnail_cache.clone();
277    thumbnail_path.push(format!("{hash}.{extension}"));
278
279    let image =
280        LazyLock::new(
281            || match ImageReader::new(Cursor::new(image_buf)).with_guessed_format() {
282                Ok(image) => match image.decode() {
283                    Ok(i) => Ok(i),
284                    Err(e) => {
285                        warn!("Couldn't decode image data: {e}");
286                        Err(CoverError::DecodingError)
287                    }
288                },
289                Err(e) => {
290                    warn!("{e}");
291                    Err(CoverError::UnknownFileFormat)
292                }
293            },
294        );
295
296    let hires = if config.cache_mode == CacheMode::UseCache
297        && covers_lookup_set.read().unwrap().contains(&hires_path)
298    {
299        Some(hires_path)
300    } else if config.cache_mode == CacheMode::UseCache && hires_path.is_file() {
301        covers_lookup_set
302            .write()
303            .unwrap()
304            .insert(hires_path.clone());
305        Some(hires_path)
306    } else {
307        let image = match image.as_ref() {
308            Ok(image) => image,
309            Err(e) => {
310                return Err(e.clone());
311            }
312        };
313
314        let cover = match config.covers.quality.resolution() {
315            Some(res) => {
316                if image.height() > res || image.height() > res {
317                    resize(image, res, res, config.covers.filter)
318                } else {
319                    image.clone().into()
320                }
321            }
322            None => image.clone().into(),
323        };
324
325        match File::create(&hires_path) {
326            Ok(mut file) => match cover.write_to(&mut file, config.covers.format) {
327                Ok(_) => {
328                    covers_lookup_set
329                        .write()
330                        .unwrap()
331                        .insert(hires_path.clone());
332                    Some(hires_path)
333                }
334                Err(e) => {
335                    warn!("Couldn't write thumbnail to {hires_path:?}: {e}");
336                    None
337                }
338            },
339            Err(e) => {
340                warn!("Couldn't write thumbnail to {hires_path:?}: {e}");
341                None
342            }
343        }
344    };
345
346    let thumbnail = if config.cache_mode == CacheMode::UseCache
347        && covers_lookup_set.read().unwrap().contains(&thumbnail_path)
348    {
349        Some(thumbnail_path)
350    } else if config.cache_mode == CacheMode::UseCache && thumbnail_path.is_file() {
351        covers_lookup_set
352            .write()
353            .unwrap()
354            .insert(thumbnail_path.clone());
355        Some(thumbnail_path)
356    } else {
357        let image = match &*image {
358            Ok(image) => image,
359            Err(e) => {
360                return Err(e.clone());
361            }
362        };
363
364        let thumbnail = resize(
365            image,
366            config.covers.thumbnail_resolution,
367            config.covers.thumbnail_resolution,
368            config.covers.filter,
369        );
370
371        match File::create(&thumbnail_path) {
372            Ok(mut file) => match thumbnail.write_to(&mut file, config.covers.format) {
373                Ok(_) => {
374                    covers_lookup_set
375                        .write()
376                        .unwrap()
377                        .insert(thumbnail_path.clone());
378                    Some(thumbnail_path)
379                }
380                Err(e) => {
381                    warn!("Couldn't write thumbnail to {thumbnail_path:?}: {e}");
382                    None
383                }
384            },
385            Err(e) => {
386                warn!("Couldn't write thumbnail to {thumbnail_path:?}: {e}");
387                None
388            }
389        }
390    };
391
392    Ok(Cover { hires, thumbnail })
393}
394
395fn get_unique_covers(tracks: &[Arc<Track>]) -> Vec<&Cover> {
396    let mut covers: HashSet<&Cover> = HashSet::new();
397    for track in tracks {
398        covers.insert(&track.cover);
399    }
400    covers.into_iter().collect()
401}
402
403fn tile_2x2(images: [&ImageBuffer<Rgb<u8>, Vec<u8>>; 4]) -> ImageBuffer<Rgb<u8>, Vec<u8>> {
404    let w = images.iter().map(|img| img.width()).max().unwrap();
405    let h = images.iter().map(|img| img.height()).max().unwrap();
406
407    let mut canvas = ImageBuffer::new(w * 2, h * 2);
408
409    for (i, img) in images.iter().enumerate() {
410        let x0 = if i % 2 == 0 { 0 } else { w };
411        let y0 = if i / 2 == 0 { 0 } else { h };
412
413        for y in 0..img.height() {
414            for x in 0..img.width() {
415                canvas.put_pixel(x0 + x, y0 + y, *img.get_pixel(x, y));
416            }
417        }
418    }
419
420    canvas
421}
422
423pub(crate) fn get_playlist_cover(
424    name: &str,
425    config: indexer::Config,
426    tracks: &[Arc<Track>],
427) -> Result<Cover, CoverError> {
428    if tracks.is_empty() {
429        Ok(Cover::none()) // No covers for empty playlists
430    } else if tracks.len() >= 4 {
431        let unique_covers = get_unique_covers(tracks);
432        if unique_covers.len() >= 4 {
433            // And then for playlists with tracks with 4 or more unique covers, a custom cover is used
434
435            let mut hasher = DefaultHasher::new();
436            name.hash(&mut hasher);
437            let hash = hasher.finish();
438
439            let extension = config.covers.extension();
440            let mut hires_path = PLAYLIST_HIRES_COVER_CACHE_DIR.clone()?;
441            hires_path.push(format!("{hash}.{extension}"));
442            let mut thumbnail_path = PLAYLIST_THUMBNAIL_CACHE_DIR.clone()?;
443            thumbnail_path.push(format!("{hash}.{extension}"));
444
445            let image = LazyLock::new(|| {
446                let mut image_buffers = Vec::new();
447                for cover in &unique_covers {
448                    let image_buf = match cover.hires.as_ref() {
449                        Some(path) => match File::open(path) {
450                            Ok(mut handle) => {
451                                let mut buf = Vec::new();
452                                if let Err(e) = handle.read_to_end(&mut buf) {
453                                    warn!("Couldn't read from file {path:?}: {e}")
454                                }
455                                buf
456                            }
457                            Err(e) => {
458                                warn!("Couldn't open cover {path:?}: {e}");
459                                continue;
460                            }
461                        },
462                        None => continue,
463                    };
464
465                    match ImageReader::new(Cursor::new(image_buf)).with_guessed_format() {
466                        Ok(image) => match image.decode() {
467                            Ok(i) => image_buffers.push(i),
468                            Err(e) => {
469                                warn!("Couldn't decode image data: {e}");
470                                continue;
471                            }
472                        },
473                        Err(e) => {
474                            warn!("{e}");
475                            continue;
476                        }
477                    }
478
479                    if image_buffers.len() == 4 {
480                        break;
481                    }
482                }
483
484                let buffers = if let (Some(a), Some(b), Some(c), Some(d)) = (
485                    image_buffers.first(),
486                    image_buffers.get(1),
487                    image_buffers.get(2),
488                    image_buffers.get(3),
489                ) {
490                    Some([&a.to_rgb8(), &b.to_rgb8(), &c.to_rgb8(), &d.to_rgb8()])
491                } else {
492                    None
493                };
494
495                buffers.map(tile_2x2)
496            });
497
498            let hires = if config.cache_mode == CacheMode::UseCache && hires_path.is_file() {
499                Some(hires_path)
500            } else {
501                let image = match image.as_ref() {
502                    Some(image) => image,
503                    None => {
504                        return Err(CoverError::Playlist);
505                    }
506                };
507
508                let cover = match config.covers.quality.resolution() {
509                    Some(res) => {
510                        if image.height() > res && image.height() > res {
511                            resize(image, res, res, config.covers.filter)
512                        } else {
513                            image.clone()
514                        }
515                    }
516                    None => image.clone(),
517                };
518
519                match File::create(&hires_path) {
520                    Ok(mut file) => match cover.write_to(&mut file, config.covers.format) {
521                        Ok(_) => Some(hires_path),
522                        Err(e) => {
523                            warn!("Couldn't write thumbnail to {hires_path:?}: {e}");
524                            None
525                        }
526                    },
527                    Err(e) => {
528                        warn!("Couldn't write thumbnail to {hires_path:?}: {e}");
529                        None
530                    }
531                }
532            };
533
534            let thumbnail = if config.cache_mode == CacheMode::UseCache && thumbnail_path.is_file()
535            {
536                Some(thumbnail_path)
537            } else {
538                let image = match image.as_ref() {
539                    Some(image) => image,
540                    None => {
541                        return Err(CoverError::Playlist);
542                    }
543                };
544
545                let thumbnail = resize(
546                    image,
547                    config.covers.thumbnail_resolution,
548                    config.covers.thumbnail_resolution,
549                    config.covers.filter,
550                );
551
552                match File::create(&thumbnail_path) {
553                    Ok(mut file) => match thumbnail.write_to(&mut file, config.covers.format) {
554                        Ok(_) => Some(thumbnail_path),
555                        Err(e) => {
556                            warn!("Couldn't write thumbnail to {thumbnail_path:?}: {e}");
557                            None
558                        }
559                    },
560                    Err(e) => {
561                        warn!("Couldn't write thumbnail to {thumbnail_path:?}: {e}");
562                        None
563                    }
564                }
565            };
566
567            Ok(Cover { hires, thumbnail })
568        } else {
569            Ok(tracks[0].cover.clone())
570        }
571    } else {
572        Ok(tracks[0].cover.clone())
573    }
574}