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}