playlists + player: every service's list through one door with its names matched for real, and shuffle that readies exactly what it promises
This commit is contained in:
1 parent
30eb6fe025
commit
50414dae5c
16 files changed
+4157
-154
No files matched your search
@@ -4450,6 +4450,126 @@ impl MetadataClient {
|
||||
}
|
||||
|
||||
/// Catalog search: artists, albums and tracks for the query, in parallel.
|
||||
/// Tracks only, for identity lookups that do not want an artist or album
|
||||
/// panel. `catalog_search` fires three Deezer calls per query because it
|
||||
/// feeds a search PAGE; verifying an import's rows one at a time made
|
||||
/// that three times the traffic for two answers nobody read — a 258-row
|
||||
/// playlist cost 774 calls. This is the one call that matters.
|
||||
///
|
||||
/// The query is passed through verbatim, so callers may use Deezer's
|
||||
/// field syntax (`artist:"…" track:"…"`), which finds records its plain
|
||||
/// search returns nothing at all for.
|
||||
/// Every track the ARTIST PAGE would show for `artist_name`, for a
|
||||
/// lookup that struck out against Deezer's search index. The index is
|
||||
/// known to omit records Deezer itself sells, and the artist page is
|
||||
/// richer still: `catalog_artist` supplements the Deezer shelf with
|
||||
/// MusicBrainz/Apple releases (synthetic ids), which is why Mock
|
||||
/// Orange's "Disguised As Ghosts" shows on the page while no Deezer
|
||||
/// query can surface it. This walk reads that same shelf, so anything
|
||||
/// the page can show, an import can match.
|
||||
///
|
||||
/// `title` steers the album order only: releases whose names share a
|
||||
/// word with the wanted title are opened first (a single is usually
|
||||
/// named after its track), and the walk stops early once any opened
|
||||
/// tracklist carries the base name.
|
||||
///
|
||||
/// Deliberately heavier than a search (the artist page build is disk-
|
||||
/// cached for a day, tracklists likewise): it runs per missing ARTIST,
|
||||
/// not per row, and the caller caches what it hands back.
|
||||
pub async fn catalog_artist_track_candidates(
|
||||
self: &std::sync::Arc<Self>,
|
||||
artist_name: &str,
|
||||
title: &str,
|
||||
) -> Vec<CatalogTrack> {
|
||||
const MAX_ALBUM_FETCHES: usize = 30;
|
||||
const MAX_TRACKS: usize = 600;
|
||||
|
||||
// The artist, by name — ranked exactly the way the search page ranks
|
||||
// them, so this walk lands on the same artist a click would.
|
||||
let key = normalize(artist_name);
|
||||
if key.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let url = format!(
|
||||
"https://api.deezer.com/search/artist?q={}&limit=5",
|
||||
urlencode(artist_name)
|
||||
);
|
||||
let mut artists: Vec<CatalogArtist> = self
|
||||
.deezer(&url)
|
||||
.await
|
||||
.as_ref()
|
||||
.and_then(|b| b.get("data"))
|
||||
.and_then(|d| d.as_array())
|
||||
.map(|a| a.iter().filter_map(parse_artist).collect())
|
||||
.unwrap_or_default();
|
||||
artists.sort_by(|a, b| {
|
||||
artist_name_relevance(&b.name, &key)
|
||||
.cmp(&artist_name_relevance(&a.name, &key))
|
||||
.then(b.fans.unwrap_or(0).cmp(&a.fans.unwrap_or(0)))
|
||||
});
|
||||
let Some(artist) = artists
|
||||
.into_iter()
|
||||
.find(|a| artist_name_relevance(&a.name, &key) > 0)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let Some(page) = self.catalog_artist(artist.id).await else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out: Vec<CatalogTrack> = Vec::new();
|
||||
// The curated top tracks are already here — free, and for a small
|
||||
// artist they cover most of the catalog.
|
||||
out.extend(page.popular_tracks.iter().cloned());
|
||||
|
||||
let wanted_words: std::collections::HashSet<String> = normalize_words(title);
|
||||
let mut releases: Vec<&CatalogAlbum> = page.albums.iter().collect();
|
||||
releases.sort_by_key(|album| {
|
||||
let shared = normalize_words(&album.title)
|
||||
.intersection(&wanted_words)
|
||||
.count();
|
||||
std::cmp::Reverse(shared)
|
||||
});
|
||||
|
||||
let base = normalize(title);
|
||||
let mut found_by_name = false;
|
||||
for album in releases.into_iter().take(MAX_ALBUM_FETCHES) {
|
||||
if found_by_name || out.len() >= MAX_TRACKS {
|
||||
break;
|
||||
}
|
||||
let Some(album_page) = self.catalog_album(album.id).await else {
|
||||
continue;
|
||||
};
|
||||
for track in &album_page.tracks {
|
||||
if !base.is_empty() {
|
||||
let candidate = normalize(&track.title);
|
||||
if candidate.contains(&base) || (!candidate.is_empty() && base.contains(&candidate))
|
||||
{
|
||||
found_by_name = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend(album_page.tracks.iter().cloned());
|
||||
}
|
||||
out.truncate(MAX_TRACKS);
|
||||
out
|
||||
}
|
||||
|
||||
pub async fn catalog_search_tracks(&self, query: &str, limit: u32) -> Vec<CatalogTrack> {
|
||||
let url = format!(
|
||||
"https://api.deezer.com/search/track?q={}&limit={}",
|
||||
urlencode(query),
|
||||
limit.clamp(1, 25)
|
||||
);
|
||||
self.deezer(&url)
|
||||
.await
|
||||
.as_ref()
|
||||
.and_then(|b| b.get("data"))
|
||||
.and_then(|d| d.as_array())
|
||||
.map(|a| a.iter().filter_map(parse_track).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub async fn catalog_search(&self, query: &str) -> CatalogSearch {
|
||||
let q = urlencode(query);
|
||||
let artist_url = format!("https://api.deezer.com/search/artist?q={q}&limit=8");
|
||||
@@ -12710,6 +12830,15 @@ fn escape(s: &str) -> String {
|
||||
}
|
||||
|
||||
/// Loose artist comparison: lowercase, alphanumerics only.
|
||||
/// The normalized words of a display title, for cheap overlap tests.
|
||||
fn normalize_words(s: &str) -> std::collections::HashSet<String> {
|
||||
s.to_lowercase()
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|w| w.len() >= 3)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize(s: &str) -> String {
|
||||
s.to_lowercase()
|
||||
.chars()
|
||||
|
||||
Reference in new issue
Block a user