aede_core/coverart.rs
1//! The Cover Art Archive: the front image of a record, when it has one.
2//!
3//! Deliberately **no network**, like [`crate::musicbrainz`]: this module turns
4//! an index somebody else fetched into an address to download, and decides
5//! which image is the front one. Reaching the address is the client's job.
6//!
7//! # The index says what exists, so nothing here guesses
8//!
9//! Asking `coverartarchive.org` about a release answers with a small JSON
10//! document — not the image — listing every image it holds:
11//!
12//! ```text
13//! { "images": [ { "front": true, "types": ["Front"], "approved": true,
14//! "image": "…/12345.jpg",
15//! "thumbnails": { "250": "…", "500": "…", "1200": "…" } } ],
16//! "release": "https://musicbrainz.org/release/…" }
17//! ```
18//!
19//! The sizes are **named in the answer**, so a caller asking for 1200 px is
20//! told whether one exists rather than being sent to an address that might
21//! `404`. That is why the index is fetched first: one small request decides
22//! everything, including whether there is anything to download at all.
23
24use crate::json::Json;
25
26/// The name records from this service carry in `sources.json`.
27pub const SOURCE: &str = "coverartarchive";
28
29/// Base address of the service.
30pub const WEB_SERVICE: &str = "https://coverartarchive.org";
31
32/// Which image to take, of the ones the archive offers.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Size {
35 /// The file as it was uploaded: often 1500–3000 px and several megabytes.
36 Original,
37 /// A thumbnail of this width, when the archive has generated one.
38 Thumbnail(u32),
39}
40
41impl Size {
42 /// Reads what a reader typed after `--size`.
43 ///
44 /// Only the widths the archive actually generates are accepted. A number it
45 /// has never heard of would be asked for, not found, and silently fall back
46 /// to something else — so it is refused here, where the message can list
47 /// what there is.
48 pub fn parse(text: &str) -> Option<Size> {
49 match text.trim().to_ascii_lowercase().as_str() {
50 "original" | "full" => Some(Size::Original),
51 "250" => Some(Size::Thumbnail(250)),
52 "500" => Some(Size::Thumbnail(500)),
53 "1200" => Some(Size::Thumbnail(1200)),
54 _ => None,
55 }
56 }
57
58 /// How this size reads in a message.
59 pub fn as_str(self) -> String {
60 match self {
61 Size::Original => "original".to_string(),
62 Size::Thumbnail(px) => format!("{px} px"),
63 }
64 }
65}
66
67/// Where to ask what images a record has.
68///
69/// A **release group** rather than a release: the catalog's own identifier for
70/// an album is the group's, and the archive answers a group by redirecting to
71/// whichever release its editors chose to represent it — which is the right
72/// answer for "the cover of this album" and saves picking a pressing here.
73pub fn index_url(release_group: &str) -> String {
74 format!("{WEB_SERVICE}/release-group/{release_group}")
75}
76
77/// Where to ask about one precise edition, when the tags name one.
78///
79/// Preferred over the group when it is available: the cover of the pressing on
80/// the shelf is a better answer than the cover of the pressing somebody else
81/// thought representative — a reissue often has different artwork.
82pub fn release_index_url(release: &str) -> String {
83 format!("{WEB_SERVICE}/release/{release}")
84}
85
86/// Refuses a malformed index before a caller records it as having no artwork.
87///
88/// An empty `images` array is a valid absence. Missing or wrongly typed arrays,
89/// and image entries with no original or supported thumbnail address, are
90/// failed responses and must remain eligible for a later fetch.
91pub fn validate_index(response: &Json) -> Result<(), String> {
92 let rows = response
93 .get("images")
94 .and_then(Json::as_arr)
95 .ok_or_else(|| "the Cover Art Archive returned no valid images array".to_string())?;
96 for row in rows {
97 let has_original = row
98 .get("image")
99 .and_then(Json::as_str)
100 .is_some_and(|url| !url.trim().is_empty());
101 let has_thumbnail = ["250", "500", "1200"].iter().any(|width| {
102 row.get("thumbnails")
103 .and_then(|thumbs| thumbs.get(width))
104 .and_then(Json::as_str)
105 .is_some_and(|url| !url.trim().is_empty())
106 });
107 if !has_original && !has_thumbnail {
108 return Err("the Cover Art Archive returned an image without an address".to_string());
109 }
110 }
111 Ok(())
112}
113
114/// The front image of a record, at the size asked for.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct Front {
117 /// Where to download it.
118 pub url: String,
119 /// The size actually served, which may not be the one asked for.
120 pub size: Size,
121}
122
123/// Picks the front image out of an index, at the size asked for.
124///
125/// "Front" is decided by the `front` flag first and by the `types` list after:
126/// both are filled by the archive's editors, and an image marked in one and not
127/// the other is common enough that reading only one leaves records looking as
128/// though they have no cover.
129///
130/// An **approved** image wins over one that is not, when there is a choice.
131/// Anyone may upload; approval is an editor having looked. Where nothing is
132/// approved the unapproved image is still taken, because a cover somebody
133/// uploaded is a better answer than an empty folder.
134///
135/// The size falls back to the original when the thumbnail asked for is not in
136/// the answer. The archive generates them for everything it holds today, but a
137/// caller that got nothing back because a width was missing would look exactly
138/// like a record with no artwork, which is a different thing entirely.
139/// Entries without a usable address are skipped before approval is compared.
140pub fn front(response: &Json, size: Size) -> Option<Front> {
141 let images = response.get("images")?.as_arr()?;
142 let mut fallback = None;
143 for image in images.iter().filter(|image| is_front(image)) {
144 let Some(found) = at_size(image, size) else {
145 continue;
146 };
147 if image.field_bool("approved") {
148 return Some(found);
149 }
150 if fallback.is_none() {
151 fallback = Some(found);
152 }
153 }
154 fallback
155}
156
157fn is_front(image: &Json) -> bool {
158 image.field_bool("front")
159 || image
160 .get("types")
161 .and_then(Json::as_arr)
162 .is_some_and(|types| {
163 types
164 .iter()
165 .filter_map(Json::as_str)
166 .any(|t| t.eq_ignore_ascii_case("front"))
167 })
168}
169
170/// Which supported format signature these bytes carry.
171///
172/// **Sniffed, never taken from the address.** A download that went wrong — a
173/// redirect to a login page or an error document — arrives
174/// as bytes like any other, and writing those into a music folder under the
175/// name `cover.jpg` is silent corruption that only surfaces months later when
176/// something tries to display it. An extension read off a URL would not catch
177/// any of it.
178///
179/// This checks the signature only. [`write_image`] also decodes the image and
180/// checks its complete container before publishing any bytes.
181///
182/// `None` means: do not write this.
183pub fn image_kind(bytes: &[u8]) -> Option<&'static str> {
184 match bytes {
185 // JPEG: SOI marker, then the start of the first segment.
186 [0xFF, 0xD8, 0xFF, ..] => Some("jpg"),
187 [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, ..] => Some("png"),
188 // The archive serves neither today, and accepting a format nothing in
189 // this program can read would only move the failure somewhere quieter.
190 _ => None,
191 }
192}
193
194/// A validated original or a derived PNG thumbnail kept only in memory.
195///
196/// Rendering never changes the source bytes or writes a file. Thumbnails keep
197/// the aspect ratio, never enlarge the original and retain PNG sample depth.
198#[derive(Debug)]
199pub struct RenderedImage {
200 /// Original JPEG/PNG bytes, or a losslessly encoded PNG thumbnail.
201 pub bytes: Vec<u8>,
202 /// Validated container, either `jpg` or `png`.
203 pub format: &'static str,
204}
205
206/// Validate an image and optionally produce a thumbnail for a client response.
207///
208/// `size` bounds the longest axis, from 1 through 2048 pixels. An image already
209/// within that bound is returned byte-for-byte. A larger image is resampled in
210/// memory and encoded as PNG; the original remains untouched. The same input,
211/// decoded-memory, dimension and cooperative time limits as [`write_image`]
212/// apply. This is response rendering, not an artwork download/conversion pass.
213pub fn render_image(bytes: &[u8], size: Option<u32>) -> Result<RenderedImage, String> {
214 crate::image::render(bytes, size)
215}
216
217/// What a picture is of, for the few kinds worth telling apart.
218///
219/// The archive and the tag formats both classify their images, and the
220/// classification is the only thing that makes a second image worth keeping: a
221/// folder of `image-1.jpg` … `image-9.jpg` is not more useful than none.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
223pub enum Kind {
224 /// The front of the sleeve: the album cover.
225 Front,
226 /// The back of the sleeve, usually the track listing.
227 Back,
228 /// A page of the booklet or insert.
229 Booklet,
230 /// The disc itself — the label printed on a CD or a record.
231 Media,
232 /// A photograph of the artist.
233 Artist,
234 /// An artist's logo — not a photograph of them, a mark that stands for
235 /// them. See [`crate::fanarttv::logo_url`], its only source today.
236 Logo,
237 /// A wide background image for the artist — a header or hero image, not
238 /// the small mark [`Kind::Logo`] is. See [`crate::fanarttv::banner_url`],
239 /// its only source today.
240 Banner,
241 /// A full-screen artist background. Fanart.tv's 4K pool is preferred.
242 Background,
243 /// Anything else the source classifies, or nothing at all.
244 Other,
245}
246
247impl Kind {
248 /// How this program spells the kind, in a name and in a message.
249 ///
250 /// `cover` for the front, because that is the name every player and file
251 /// manager looks for and this program's own scanner looks for first — see
252 /// [`file_name`]. The rest are named for what they are.
253 pub fn stem(self) -> &'static str {
254 match self {
255 Kind::Front => "cover",
256 Kind::Back => "back",
257 Kind::Booklet => "booklet",
258 Kind::Media => "media",
259 Kind::Artist => "artist",
260 Kind::Logo => "logo",
261 Kind::Banner => "banner",
262 Kind::Background => "background",
263 Kind::Other => "image",
264 }
265 }
266
267 /// Reads the word a source uses for a kind.
268 ///
269 /// Unknown words become [`Kind::Other`] rather than being dropped: an image
270 /// nobody classified is still an image, and losing it because the label was
271 /// unfamiliar would be a strange way to fail.
272 pub fn parse(text: &str) -> Kind {
273 match text.trim().to_ascii_lowercase().as_str() {
274 "front" => Kind::Front,
275 "back" => Kind::Back,
276 "booklet" => Kind::Booklet,
277 "medium" | "media" => Kind::Media,
278 "artist" => Kind::Artist,
279 _ => Kind::Other,
280 }
281 }
282}
283
284/// What to call the file written beside the music.
285///
286/// `cover` is the first name [`crate::scan`] looks for, so the next scan finds
287/// it without being told — the file is not registered anywhere, it is simply
288/// discovered like one the user put there themselves.
289pub fn file_name(kind: &str) -> String {
290 format!("cover.{kind}")
291}
292
293/// What to call one image of a given kind, when several may share it.
294///
295/// `back.jpg` for the only back, `booklet-01.jpg` and `booklet-02.jpg` for the
296/// pages of an insert. The number starts at one and is only added when there is
297/// more than one of a kind, because `booklet-01.jpg` alone reads as a page torn
298/// out of something.
299pub fn image_name(kind: Kind, format: &str, index: usize, of: usize) -> String {
300 match of {
301 0 | 1 => format!("{}.{format}", kind.stem()),
302 _ => format!("{}-{:02}.{format}", kind.stem(), index + 1),
303 }
304}
305
306/// The subfolder everything that is not the cover is written into.
307///
308/// Not beside the music, and the reason is [`crate::scan::cover_in`]: the
309/// scanner takes *any* image in a folder as a candidate cover when none is
310/// named `cover`. A `back.jpg` sitting next to the tracks would therefore
311/// become the album's artwork in this program and in a good many players —
312/// which is precisely the wrong picture, and one nothing would ever look at
313/// again. So the front stays beside the music, under the name everything looks
314/// for, and the rest go one level down.
315///
316/// `artwork/` alongside the `spectrograms/` that `aede spectrum` already
317/// writes: a folder named for what is in it.
318pub const EXTRAS: &str = "artwork";
319
320/// Where the images that are not the cover belong, for one album folder.
321pub fn extras_in(folder: &std::path::Path) -> std::path::PathBuf {
322 folder.join(EXTRAS)
323}
324
325/// Where each image sits among the others of its own kind.
326///
327/// Answers, for every image in the order given, `(index, of)` — its place
328/// among the images of that same kind, and how many there are. That pair is
329/// what [`image_name`] needs, and computing it here rather than in each caller
330/// is what keeps a downloaded booklet and an extracted one named alike.
331pub fn positions(kinds: &[Kind]) -> Vec<(usize, usize)> {
332 let mut totals = std::collections::HashMap::<Kind, usize>::new();
333 for kind in kinds {
334 *totals.entry(*kind).or_default() += 1;
335 }
336 let mut seen = std::collections::HashMap::<Kind, usize>::new();
337 let mut out = Vec::with_capacity(kinds.len());
338 for kind in kinds {
339 let index = seen.entry(*kind).or_default();
340 out.push((*index, totals.get(kind).copied().unwrap_or_default()));
341 *index += 1;
342 }
343 out
344}
345
346/// Writes an image beside music, or says why it did not.
347///
348/// **The one place anything in this program puts a picture into somebody's
349/// library**, whether it was downloaded from the archive or pulled out of their
350/// own files. One function rather than two, because it carries two guards that
351/// would otherwise be written twice and eventually differ:
352///
353/// - the format is sniffed and the complete JPEG or static PNG is validated
354/// before publication — see [`write_image`];
355/// - the target is checked **immediately before writing**, not when the caller
356/// chose it. A catalog is a snapshot and a disk is not, and this is the one
357/// moment in the program where being out of date destroys something the user
358/// made.
359///
360/// Never overwrites. There is no flag to make it.
361///
362/// **And never adds a second cover to a folder that has one**, whatever it is
363/// called. Checking only the name about to be written is not enough: an
364/// extracted `cover.png` and a downloaded `cover.jpg` are two different names
365/// and both rank as the album's cover, so a folder can end up holding two, with
366/// no way to say which a player will show — and the one that wins may be the
367/// 1200 px download rather than the full-size picture that was in the files.
368/// [`crate::scan::cover_in`] asks the question the scanner itself asks, at the
369/// moment of writing, which is the only moment that can answer it.
370pub fn write_beside(folder: &std::path::Path, bytes: &[u8]) -> Result<std::path::PathBuf, String> {
371 if let Some(already) = crate::scan::cover_in(folder) {
372 return Err(format!(
373 "{} is already the cover of this folder",
374 already.display()
375 ));
376 }
377 match write_image(folder, Kind::Front, (0, 1), bytes)? {
378 Written::New(path) => Ok(path),
379 Written::Already(path) => Err(format!("{} already exists", path.display())),
380 }
381}
382
383/// What became of one image.
384///
385/// Two outcomes rather than one, because a file that is already there is not a
386/// failure and must not be counted or reported as one: running `--images` twice
387/// over a library would otherwise print a page of errors for having done its
388/// job the first time.
389#[derive(Debug, Clone, PartialEq, Eq)]
390pub enum Written {
391 /// Written, at this path.
392 New(std::path::PathBuf),
393 /// A file of that name was already there, and nothing overwrites.
394 Already(std::path::PathBuf),
395}
396
397/// Whether an image of this kind is already sitting beside the music, under
398/// either extension [`write_image`] can produce.
399///
400/// Fanart.tv's auxiliary artist images are tracked by disk presence, so a pass
401/// needs to ask "is there one already" before downloading it. Checks both extensions
402/// because the writer picks jpg or png from the bytes it was given, never
403/// from what the caller asked for, so a caller checking ahead of the
404/// download cannot know which one a previous run wrote.
405pub fn exists_beside(folder: &std::path::Path, kind: Kind) -> bool {
406 exists_image(folder, kind, (0, 1))
407}
408
409/// Whether this image already exists at the name [`write_image`] would use.
410///
411/// Checks JPEG and PNG because the extension follows the downloaded bytes.
412/// Numbered images retain the `(index, count)` returned by [`positions`], so
413/// a repeated pass can resume a partial booklet without downloading its saved
414/// pages again.
415pub fn exists_image(folder: &std::path::Path, kind: Kind, where_: (usize, usize)) -> bool {
416 let (index, of) = where_;
417 ["jpg", "png"]
418 .iter()
419 .any(|format| folder.join(image_name(kind, format, index, of)).exists())
420}
421
422/// Writes one image of a known kind, under the name that says what it is.
423///
424/// The general form of [`write_beside`], carrying the same two guards — the
425/// bytes are validated, and nothing is ever written over. `where_` is the pair
426/// [`positions`] gives for this image.
427///
428/// Validation decodes every pixel and checks the container's end. PNG checks
429/// every chunk CRC and IDAT Adler-32; animated PNG is refused. JPEG uses strict
430/// decoding, but has no checksum to detect every alteration that leaves a
431/// decodable stream, including some streams repaired with an early EOI marker.
432///
433/// Input is limited to 32 MiB, 8192 pixels per axis and 16 million pixels, with
434/// at most 64 JPEG scans or 16384 container parts. The decoded-output budget is
435/// 128 MiB, separate from the PNG decoder's best-effort 16 MiB internal budget;
436/// neither is a total process-memory cap. A five-second cooperative deadline
437/// is checked during reads and PNG rows and again before publication, without
438/// forcibly interrupting a codec's computation between those checks. No
439/// external image helper is required.
440///
441/// The folder is created if it is not there, because the images that are not
442/// the cover go into a subfolder that will not exist the first time.
443///
444/// `Err` means an unsupported, invalid or excessive image, or a failed write.
445pub fn write_image(
446 folder: &std::path::Path,
447 kind: Kind,
448 where_: (usize, usize),
449 bytes: &[u8],
450) -> Result<Written, String> {
451 let Some(format) = image_kind(bytes) else {
452 return Err(format!(
453 "what came back is not a JPEG or a PNG ({} bytes), so nothing was written",
454 bytes.len()
455 ));
456 };
457 crate::image::validate(bytes, format)
458 .map_err(|error| format!("{error}, so nothing was written"))?;
459 let (index, of) = where_;
460 if !folder.exists() {
461 std::fs::create_dir_all(folder).map_err(|e| format!("{}: {e}", folder.display()))?;
462 }
463 let path = folder.join(image_name(kind, format, index, of));
464 if path.exists() {
465 return Ok(Written::Already(path));
466 }
467 match crate::store::write_new_atomic(&path, bytes) {
468 Ok(true) => Ok(Written::New(path)),
469 Ok(false) => Ok(Written::Already(path)),
470 Err(error) => Err(format!("{}: {error}", path.display())),
471 }
472}
473
474/// Every image an index holds, with what each one is of.
475///
476/// The front, when there is one, keeps its place as the album's cover; the rest
477/// are what `--images` is for. Ordered front first and then as the archive
478/// lists them, so a booklet's pages keep the order somebody scanned them in.
479pub fn images(response: &Json, size: Size) -> Vec<(Kind, Front)> {
480 let Some(rows) = response.get("images").and_then(Json::as_arr) else {
481 return Vec::new();
482 };
483 let mut out: Vec<(Kind, Front)> = Vec::new();
484 for row in rows {
485 let kind = match is_front(row) {
486 true => Kind::Front,
487 false => row
488 .get("types")
489 .and_then(Json::as_arr)
490 .and_then(|types| types.iter().find_map(Json::as_str).map(Kind::parse))
491 .unwrap_or(Kind::Other),
492 };
493 if let Some(found) = at_size(row, size) {
494 out.push((kind, found));
495 }
496 }
497 out.sort_by_key(|(kind, _)| u8::from(*kind != Kind::Front));
498 out
499}
500
501/// One image's address at the size asked for, falling back to the original.
502fn at_size(image: &Json, size: Size) -> Option<Front> {
503 if let Size::Thumbnail(px) = size
504 && let Some(url) = image
505 .get("thumbnails")
506 .and_then(|t| t.field_str(&px.to_string()))
507 && !url.is_empty()
508 {
509 return Some(Front { url, size });
510 }
511 Some(Front {
512 url: image.field_str("image").filter(|u| !u.is_empty())?,
513 size: Size::Original,
514 })
515}
516
517#[cfg(test)]
518#[path = "coverart_tests.rs"]
519mod tests;
520
521/// Parent shared by every local edition, absent when editions span folders.
522pub fn shared_artist_folder(releases: &[&crate::model::Release]) -> Option<std::path::PathBuf> {
523 let mut shared = None;
524 for release in releases {
525 let parent = std::path::Path::new(&release.folder)
526 .parent()?
527 .to_path_buf();
528 shared = Some(match shared {
529 None => parent,
530 Some(previous) if previous == parent => previous,
531 Some(_) => return None,
532 });
533 }
534 shared
535}
536
537/// Confined cache directory for an artist or label's opaque identifier.
538///
539/// A source identifier is data, never a path. Existing directories below the
540/// configured data folder must be ordinary directories; these static checks
541/// do not prevent another process replacing them concurrently.
542pub fn asset_destination(
543 data_dir: &std::path::Path,
544 kind: crate::model::EntityKind,
545 identifier: &str,
546) -> Option<std::path::PathBuf> {
547 use std::path::Component;
548 if identifier.is_empty()
549 || identifier.contains(['/', '\\', '\0'])
550 || !matches!(
551 std::path::Path::new(identifier).components().next(),
552 Some(Component::Normal(_))
553 )
554 {
555 return None;
556 }
557 let family = match kind {
558 crate::model::EntityKind::Artist => "artists",
559 crate::model::EntityKind::Label => "labels",
560 _ => return None,
561 };
562 let assets = crate::store::assets_dir(data_dir);
563 let family = assets.join(family);
564 let target = family.join(identifier);
565 for directory in [&assets, &family, &target] {
566 match std::fs::symlink_metadata(directory) {
567 Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {}
568 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
569 _ => return None,
570 }
571 }
572 Some(target)
573}
574
575/// Existing artist artwork location; watched roots are never artist folders.
576/// An unavailable or path-shaped fallback identifier has no cache destination.
577pub fn artist_destination(
578 catalog: &crate::model::Catalog,
579 releases: &[&crate::model::Release],
580 data_dir: &std::path::Path,
581 mbid: &str,
582) -> Option<std::path::PathBuf> {
583 shared_artist_folder(releases)
584 .filter(|folder| {
585 !catalog
586 .roots
587 .iter()
588 .any(|root| root == folder.to_string_lossy().as_ref())
589 })
590 .or_else(|| asset_destination(data_dir, crate::model::EntityKind::Artist, mbid))
591}