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:
KeelrLeek committed 2026-08-24 18:10:34 -05:00
1 parent 30eb6fe025
commit 50414dae5c
16 files changed
+4157 -154

No files matched your search

+129
View File
@@ -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()