diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index 0d47164..0fd88ca 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -378,6 +378,11 @@ const MB_RECORDING_PAGES: usize = 2; /// [`POPULAR_KEEP`] rows are shown, but a track can fail to resolve to anything /// playable, so the list needs headroom to fill from underneath. const LASTFM_RANKED_KEEP: usize = 40; +/// How many distinct records one page will look up previews for. Popular is +/// twenty rows, so a gap spanning more than a few albums means the artist is +/// barely on Apple either and the next lookup is unlikely to pay. Bounded +/// because iTunes Search allows roughly 20 calls a minute. +const APPLE_PREVIEW_ALBUMS: usize = 4; /// A resolved artist banner rarely changes and costs two external calls, so it /// is cached far longer than ordinary catalog pages. @@ -4299,6 +4304,9 @@ impl MetadataClient { &albums, &supplements.first_released, ); + // After the albums are settled, so the lookup asks Apple for the record + // the row actually ends up on. + self.apply_apple_previews(&mut popular_tracks, &apple).await; stage_trace( "page.attribution", attribution_started, @@ -5313,6 +5321,88 @@ impl MetadataClient { Some(CatalogAlbumPage { album, tracks, album_plays: None, album_listeners: None, totals_summed: false }) } + /// Fill in the 30-second previews Deezer has no pressing for, from Apple. + /// + /// Popular is Last.fm's list now, so it routinely contains songs Deezer does + /// not sell — and a row built from MusicBrainz has no clip at all. Measured + /// over Last.fm's top twenty for six artists, Deezer covers 76 of 120 rows; + /// Apple takes that to 104. It is decisive where Deezer's catalogue is + /// thin — Neutral Milk Hotel 1 → 19, Slowdive 13 → 20 — and costs nothing + /// where it is not, since a page with no gaps makes no requests at all. + /// + /// BY ALBUM, not by track. iTunes Search is documented at roughly 20 calls + /// a minute, and Neutral Milk Hotel alone would want 19 track lookups; one + /// collection lookup returns the whole tracklist with a preview on every + /// entry instead. The album is found in the Apple catalogue the page has + /// ALREADY fetched for release ownership, so the search half costs nothing + /// either and this is one request per record with a gap in it. + /// + /// Apple is the only provider worth adding here. Spotify needs OAuth and + /// has been returning a null preview for much of its catalogue since 2024; + /// SoundCloud closed app registration years ago; Jamendo is + /// Creative-Commons only. Probed against the residual gap, Audius, the + /// Internet Archive and Bandcamp returned nothing for any of it. + async fn apply_apple_previews( + &self, + tracks: &mut [CatalogTrack], + apple: &AppleArtistCatalog, + ) -> usize { + // Which records actually have a hole in them, most-wanted first. + let mut wanted: Vec = Vec::new(); + for track in tracks.iter() { + if track.preview.is_some() || track.album.is_empty() { + continue; + } + let key = release_title_identity(&track.album); + if !key.is_empty() && !wanted.contains(&key) { + wanted.push(key); + } + } + if wanted.is_empty() { + return 0; + } + let started = Instant::now(); + let mut previews: HashMap = HashMap::new(); + for album_key in wanted.into_iter().take(APPLE_PREVIEW_ALBUMS) { + let Some(collection_id) = apple + .releases + .iter() + .find(|release| release.title_norm == album_key) + .and_then(|release| release.collection_id) + else { + continue; + }; + for song in self.apple_collection_songs(collection_id).await { + let (Some(name), Some(preview)) = ( + song.get("trackName").and_then(|v| v.as_str()), + song.get("previewUrl").and_then(|v| v.as_str()), + ) else { + continue; + }; + let key = track_title_identity(name); + if !key.is_empty() { + previews.entry(key).or_insert_with(|| preview.to_string()); + } + } + } + let mut filled = 0; + for track in tracks.iter_mut() { + if track.preview.is_some() { + continue; + } + if let Some(preview) = previews.get(&track_title_identity(&track.title)) { + track.preview = Some(preview.clone()); + filled += 1; + } + } + stage_trace( + "page.apple_previews", + started, + &format!("{filled} filled"), + ); + filled + } + /// The iTunes song lookup for a collection — the album's tracklist. Shared /// by `apple_album` (full track records for an opened card) and /// `apple_album_track_titles` (edition scoring). Returns the song entries in