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()) } else if tracks.len() >= 4 {
431 let unique_covers = get_unique_covers(tracks);
432 if unique_covers.len() >= 4 {
433 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}