aede_core/
fanarttv.rs

1//! Fanart.tv music artwork: artist portraits, logos, banners and backgrounds,
2//! album covers and disc art, plus record-label logos.
3//!
4//! Deliberately **no network**, like [`crate::acoustid`] and
5//! [`crate::musicbrainz`]: this module builds the address to ask and reads
6//! the answer somebody else fetched.
7//!
8//! # Second for a portrait, only, for a logo
9//!
10//! [`crate::wikipedia::portrait_file`] is asked before this for a portrait —
11//! a Commons image is reached through the identifier MusicBrainz already
12//! gave, and Commons hosts nothing whose licence is not stated. Fanart.tv is
13//! user-submitted: `fetch --portraits` uses it only where Wikidata has
14//! nothing, and the record it leaves says which service actually answered, so
15//! a reader who cares about that distinction can tell — see [`SOURCE`].
16//!
17//! A logo has no such alternative to try first: Wikidata states no logo claim
18//! for an artist, so `fetch --logos` asks Fanart.tv directly, under its own
19//! record name — see [`LOGO_SOURCE`]. A banner rides the very same request:
20//! `fetch --logos --banners` reads [`banner_url`] out of the answer already
21//! fetched for the logo, rather than asking twice for one artist.
22//!
23//! # Indexed by the identifier that never needed a search
24//!
25//! Unlike AcoustID, which is shown a fingerprint and has to guess which
26//! recording it is, Fanart.tv is asked about an artist **by MusicBrainz
27//! identifier** — the same one every artist record in `sources.json` already
28//! carries. There is nothing to score and nothing to arbitrate: the answer is
29//! either this artist's images or none.
30
31use crate::json::Json;
32use crate::url::encode_component;
33
34/// The name records from this service carry in `sources.json`.
35pub const SOURCE: &str = "fanarttv";
36
37/// Base address of the service.
38pub const WEB_SERVICE: &str = "https://webservice.fanart.tv/v3.2/music";
39
40/// The name a logo is stored under — not [`SOURCE`], though the same request
41/// answers both.
42///
43/// A portrait and a logo are two different claims about the same artist,
44/// fetched by `fetch --portraits` and `fetch --logos` on their own schedules,
45/// and a record that carried both could not be re-fetched for one without
46/// silently dropping the other — `Sources::set` replaces a record whole, it
47/// does not merge fields into it. Keeping them as two records under two names
48/// is what lets each pass redo its own work without undoing the other's — the
49/// same reasoning, and the same fix, as [`crate::wikipedia::PORTRAIT_SOURCE`].
50pub const LOGO_SOURCE: &str = "fanarttv-logo";
51
52/// The separate source record for a record label's logo.
53pub const LABEL_LOGO_SOURCE: &str = "fanarttv-label-logo";
54
55/// The legacy record left after the complete Fanart.tv artwork pass inspected
56/// an artist or album before artwork families became independently selectable.
57/// Kept so those completed passes are not needlessly repeated.
58pub const ARTWORK_SOURCE: &str = "fanarttv-artwork";
59
60/// The completion record for Fanart.tv artist portraits.
61pub const PORTRAIT_ARTWORK_SOURCE: &str = "fanarttv-artwork-portrait";
62
63/// The completion record for Fanart.tv artist backgrounds.
64pub const BACKGROUND_SOURCE: &str = "fanarttv-artwork-background";
65
66/// The completion record for Fanart.tv artist banners.
67pub const BANNER_SOURCE: &str = "fanarttv-artwork-banner";
68
69/// The completion record for Fanart.tv album covers.
70pub const ALBUM_COVER_SOURCE: &str = "fanarttv-artwork-album-cover";
71
72/// The completion record for Fanart.tv disc artwork.
73pub const CDART_SOURCE: &str = "fanarttv-artwork-cdart";
74
75/// The environment variable holding the application key.
76///
77/// Free to register, and not shipped with Aède for the reason
78/// [`crate::acoustid::KEY_VARIABLE`] gives: a key baked into an open-source
79/// binary is a key every copy shares, and the quota with it.
80pub const KEY_VARIABLE: &str = "AEDE_FANARTTV_KEY";
81
82/// The key this machine holds, when it holds one.
83///
84/// Read once, at the edge, and carried in — the same discipline
85/// [`crate::acoustid::key`] follows, for the same reason: a value read from
86/// the process environment inside the pass that needs it cannot be handed a
87/// different answer by a test.
88pub fn key() -> Option<String> {
89    std::env::var(KEY_VARIABLE)
90        .ok()
91        .map(|value| value.trim().to_string())
92        .filter(|value| !value.is_empty())
93}
94
95/// How long to wait between requests.
96///
97/// Fanart.tv states no published limit; this keeps to the same one request a
98/// second the rest of Aède treats as the polite default rather than assume a
99/// service that has not asked for restraint wants none.
100pub const REQUEST_INTERVAL: std::time::Duration = std::time::Duration::from_millis(1000);
101
102/// Where to ask about one artist.
103pub fn lookup_url(mbid: &str, key: &str) -> String {
104    format!(
105        "{WEB_SERVICE}/{}?api_key={}",
106        encode_component(mbid),
107        encode_component(key)
108    )
109}
110
111/// Where to ask about one record label, by its MusicBrainz label identifier.
112pub fn label_lookup_url(mbid: &str, key: &str) -> String {
113    format!(
114        "{WEB_SERVICE}/labels/{}?api_key={}",
115        encode_component(mbid),
116        encode_component(key)
117    )
118}
119
120/// Refuses a response whose identity does not match the artist requested.
121///
122/// Fanart.tv v3.2 guarantees `mbid_id` on artist answers. Without this guard,
123/// an error object or a changed response shape reads indistinguishably from
124/// "this artist has no logo".
125pub fn artist_response(response: &Json, mbid: &str) -> Result<(), String> {
126    match response.field_str("mbid_id") {
127        Some(found) if found == mbid => Ok(()),
128        Some(found) => Err(format!(
129            "Fanart.tv answered for MusicBrainz id {found}, not {mbid}"
130        )),
131        None => Err(
132            "Fanart.tv returned no artist identifier; the response was not an artist answer"
133                .to_string(),
134        ),
135    }?;
136    validate_arrays(
137        response,
138        &[
139            "hdmusiclogo",
140            "musiclogo",
141            "artistthumb",
142            "musicbanner",
143            "artist4kbackground",
144            "artistbackground",
145            "albums",
146        ],
147    )?;
148    if let Some(albums) = response.get("albums").and_then(Json::as_arr) {
149        for album in albums {
150            validate_arrays(album, &["albumcover", "cdart"])?;
151        }
152    }
153    Ok(())
154}
155
156/// Refuses a response whose identity does not match the label requested.
157pub fn label_response(response: &Json, mbid: &str) -> Result<(), String> {
158    match response.field_str("id") {
159        Some(found) if found == mbid => Ok(()),
160        Some(found) => Err(format!(
161            "Fanart.tv answered for MusicBrainz label id {found}, not {mbid}"
162        )),
163        None => Err(
164            "Fanart.tv returned no label identifier; the response was not a label answer"
165                .to_string(),
166        ),
167    }?;
168    validate_arrays(response, &["musiclabel"])
169}
170
171/// Missing and empty pools are valid absences; unreadable entries are not.
172fn validate_arrays(response: &Json, fields: &[&str]) -> Result<(), String> {
173    for field in fields {
174        let Some(value) = response.get(field) else {
175            continue;
176        };
177        let Json::Arr(rows) = value else {
178            return Err(format!("Fanart.tv returned no valid {field} array"));
179        };
180        // Album entries carry their release-group selector; image entries
181        // carry an address. Discarding either would cache a false absence.
182        let required = if *field == "albums" {
183            "release_group_id"
184        } else {
185            "url"
186        };
187        if rows.iter().any(|row| {
188            row.get(required)
189                .and_then(Json::as_str)
190                .is_none_or(|text| text.trim().is_empty())
191        }) {
192            return Err(format!("Fanart.tv returned an unreadable {field} entry"));
193        }
194    }
195    Ok(())
196}
197
198/// One image the service knows of, and how well liked it is.
199#[derive(Debug, Clone, PartialEq, Eq)]
200struct Thumb {
201    url: String,
202    likes: u32,
203}
204
205/// The most liked image under `field`, or `None` when it is empty or absent.
206///
207/// Ranked by `likes`, which is the only signal the service gives about which
208/// of several submissions is the one people chose — the same role a score
209/// plays for a search result, and unlike a search result these are not scored
210/// against the question at all, only against each other.
211fn best(response: &Json, field: &str) -> Option<Thumb> {
212    response
213        .get(field)
214        .and_then(Json::as_arr)
215        .unwrap_or(&[])
216        .iter()
217        .filter_map(|row| {
218            let url = row.field_str("url").filter(|u| !u.is_empty())?;
219            let likes = row
220                .field_str("likes")
221                .and_then(|l| l.parse::<u32>().ok())
222                .unwrap_or(0);
223            Some(Thumb { url, likes })
224        })
225        .min_by(|a, b| b.likes.cmp(&a.likes).then_with(|| a.url.cmp(&b.url)))
226}
227
228/// The most liked portrait the service holds for this artist, or `None`.
229///
230/// **`artistthumb` only.** Fanart.tv also serves banners, logos and backdrops
231/// for the same artist, and those answer a different question — the wallpaper
232/// behind a "now playing" screen, not "who is this". Bringing them in under
233/// one function would ask a caller wanting a portrait to filter what should
234/// never have been mixed together.
235fn best_thumb(response: &Json) -> Option<Thumb> {
236    best(response, "artistthumb")
237}
238
239/// The image address to download, or `None` when the service has nothing.
240pub fn portrait_url(response: &Json) -> Option<String> {
241    best_thumb(response).map(|thumb| thumb.url)
242}
243
244/// The artist's logo address to download, or `None` when the service has
245/// none.
246///
247/// **`hdmusiclogo`, then `musiclogo`.** The two are the same submissions at
248/// two resolutions, not two different pools to rank against each other — an
249/// HD entry always wins over a standard one, however many likes the standard
250/// one has, and `musiclogo` is only consulted when `hdmusiclogo` is empty.
251pub fn logo_url(response: &Json) -> Option<String> {
252    best(response, "hdmusiclogo")
253        .or_else(|| best(response, "musiclogo"))
254        .map(|thumb| thumb.url)
255}
256
257/// The artist's banner address to download, or `None` when the service has
258/// none.
259///
260/// **`musicbanner`.** Unlike the logo, the service serves this at one
261/// resolution only — there is no `hdmusicbanner` to prefer first.
262pub fn banner_url(response: &Json) -> Option<String> {
263    best(response, "musicbanner").map(|thumb| thumb.url)
264}
265
266/// The best artist background, preferring the native 4K pool.
267///
268/// Fanart.tv exposes 3840×2160 images separately from its 1920×1080
269/// backgrounds.  They are alternative resolutions for the same use, so any
270/// 4K image wins; likes only choose within one resolution.
271pub fn background_url(response: &Json) -> Option<String> {
272    best(response, "artist4kbackground")
273        .or_else(|| best(response, "artistbackground"))
274        .map(|thumb| thumb.url)
275}
276
277/// Fanart.tv artwork belonging to one MusicBrainz release group.
278#[derive(Debug, Clone, PartialEq, Eq)]
279pub struct AlbumArtwork {
280    /// The most liked album cover, when present.
281    pub cover: Option<String>,
282    /// The best disc image for each disc number, in disc order.
283    pub discs: Vec<String>,
284}
285
286/// Reads the album artwork embedded in an artist response from API v3.2.
287///
288/// Covers are interchangeable alternatives, hence one most-liked image.
289/// Disc art is not: disc one and disc two depict different objects, so the
290/// most-liked image for every distinct `disc` value is retained.
291pub fn album_artwork(response: &Json, release_group: &str) -> Option<AlbumArtwork> {
292    let album = response
293        .get("albums")?
294        .as_arr()?
295        .iter()
296        .find(|album| album.field_str("release_group_id").as_deref() == Some(release_group))?;
297    let cover = best(album, "albumcover").map(|thumb| thumb.url);
298
299    let mut discs = std::collections::BTreeMap::<String, Thumb>::new();
300    for row in album.get("cdart").and_then(Json::as_arr).unwrap_or(&[]) {
301        let Some(url) = row.field_str("url").filter(|url| !url.is_empty()) else {
302            continue;
303        };
304        let disc = row.field_str("disc").unwrap_or_else(|| "1".to_string());
305        let likes = row
306            .field_str("likes")
307            .and_then(|likes| likes.parse::<u32>().ok())
308            .unwrap_or(0);
309        let candidate = Thumb { url, likes };
310        match discs.get_mut(&disc) {
311            Some(current)
312                if candidate.likes > current.likes
313                    || (candidate.likes == current.likes && candidate.url < current.url) =>
314            {
315                *current = candidate;
316            }
317            Some(_) => {}
318            None => {
319                discs.insert(disc, candidate);
320            }
321        }
322    }
323    let mut discs: Vec<_> = discs.into_iter().collect();
324    discs.sort_by(|(a, _), (b, _)| {
325        a.parse::<u32>()
326            .unwrap_or(u32::MAX)
327            .cmp(&b.parse::<u32>().unwrap_or(u32::MAX))
328            .then_with(|| a.cmp(b))
329    });
330    Some(AlbumArtwork {
331        cover,
332        discs: discs.into_iter().map(|(_, image)| image.url).collect(),
333    })
334}
335
336/// The record label's logo address, or `None` when it has none.
337pub fn label_logo_url(response: &Json) -> Option<String> {
338    best(response, "musiclabel").map(|thumb| thumb.url)
339}
340
341/// What to tell somebody who has no application key.
342///
343/// Named separately from [`crate::acoustid::no_key`] rather than shared: the
344/// two services, the two registration pages and the two variables are not the
345/// same fact stated twice, and a reader following one must not be sent to
346/// register at the other's address by an error message that forgot which it
347/// was talking about.
348///
349/// States only the key itself, never who is affected: `--portraits` can fall
350/// back to Wikidata and `--logos` cannot, so which artists a missing key
351/// leaves untouched is a different sentence for each — one this function does
352/// not know and must not guess at — and each pass says it in the line before
353/// this one is printed.
354pub fn no_key() -> String {
355    format!(
356        "\
357Fanart.tv images need an application key, and none is set.
358
359  1. Register at https://fanart.tv/get-an-api-key/
360  2. export {KEY_VARIABLE}=<the key it gives you>"
361    )
362}
363
364#[cfg(test)]
365#[path = "fanarttv_tests.rs"]
366mod tests;