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;