fix(reco): full-albums-only + slop filter + taller Recommendation rows
Owner-directed polish after testing the Recommendations tab.
- Full albums only: drop singles/EPs/boxsets/compilations from every album row
(Recommended Albums, Fresh Releases, Deep cuts). Qobuz's `release_type` is the
source of truth ("album" | "single" | "boxset" | "compilation"); when absent,
fall back to tracks_count >= 5. Kills the karaoke single that showed up under
Fresh Releases.
- Slop second layer: reject karaoke / tribute / "made famous by" / "in the style
of" / instrumental-version releases by artist-or-title keyword, applied both
pre-resolution (candidates) and post-resolution (the Qobuz album), so AI-slop
that still wears a full-album shape gets filtered.
- Taller rows in this view only: AlbumCard/ArtistCard/Carousel/ArtistCarousel
grow an overridable `card-height` (defaults unchanged, so Home/Search are
untouched); ExternalRecoView passes 266 to album rows and 256 to artist rows,
whose cards carry the "Similar to…" subtitle (~250px) and were clipping the
Follow chip at the old fixed 220.
cargo check -p qbz green.
cc
This commit is contained in:
7 files changed
+85
-12
No files matched your search
@@ -16,7 +16,9 @@ use crate::types::{
|
||||
AlbumCandidate, AlbumReco, ArtistCandidate, ArtistReco, ExtHistory, RecoSource, TrackCandidate,
|
||||
TrackReco,
|
||||
};
|
||||
use crate::validate::{build_album_reco, validate_album, validate_artist, validate_track};
|
||||
use crate::validate::{
|
||||
build_album_reco, is_full_album, is_slop, validate_album, validate_artist, validate_track,
|
||||
};
|
||||
use crate::{RecoCatalog, RecoInputs};
|
||||
|
||||
const DISPLAY_CAP: usize = 20;
|
||||
@@ -287,7 +289,7 @@ pub async fn build_rec_albums(inputs: &RecoInputs<'_>, history: &ExtHistory) ->
|
||||
for (artist, albums) in per_artist {
|
||||
for al in albums {
|
||||
let k = album_key(&al.artist, &al.name);
|
||||
if history.album_keys.contains(&k) || !seen.insert(k) {
|
||||
if history.album_keys.contains(&k) || is_slop(&al.artist, &al.name) || !seen.insert(k) {
|
||||
continue;
|
||||
}
|
||||
candidates.push(AlbumCandidate {
|
||||
@@ -314,7 +316,11 @@ pub async fn build_fresh_releases(inputs: &RecoInputs<'_>) -> Vec<AlbumReco> {
|
||||
let releases = lb.client.get_fresh_releases(&lb.username, 30).await.unwrap_or_default();
|
||||
let candidates: Vec<AlbumCandidate> = releases
|
||||
.into_iter()
|
||||
.filter(|r| !r.release_name.is_empty() && !r.artist_credit_name.is_empty())
|
||||
.filter(|r| {
|
||||
!r.release_name.is_empty()
|
||||
&& !r.artist_credit_name.is_empty()
|
||||
&& !is_slop(&r.artist_credit_name, &r.release_name)
|
||||
})
|
||||
.take(50)
|
||||
.map(|r| AlbumCandidate {
|
||||
artist: r.artist_credit_name,
|
||||
@@ -398,6 +404,8 @@ pub async fn build_deep_cut_albums(inputs: &RecoInputs<'_>) -> Vec<AlbumReco> {
|
||||
for albums in per_artist {
|
||||
for album in albums.into_iter().skip(2) {
|
||||
if album.id.is_empty()
|
||||
|| !is_full_album(&album)
|
||||
|| is_slop(&album.artist.name, &album.title)
|
||||
|| inputs.local.played_album_ids.contains(&album.id)
|
||||
|| !seen.insert(album.id.clone())
|
||||
{
|
||||
|
||||
@@ -21,6 +21,50 @@ type Cache<'a> = Option<&'a Mutex<RecoCache>>;
|
||||
|
||||
const ALBUM_MIN_SCORE: f32 = 0.6;
|
||||
|
||||
/// Minimum track count to treat a release as a full album when Qobuz did not
|
||||
/// label its `release_type` (singles/EPs are short).
|
||||
const MIN_ALBUM_TRACKS: u32 = 5;
|
||||
|
||||
/// Keep only proper full albums — drop singles, EPs, boxsets, compilations.
|
||||
/// Qobuz's `release_type` is the source of truth ("album" | "single" |
|
||||
/// "boxset" | "compilation"); when it is absent, fall back to the track count.
|
||||
pub fn is_full_album(album: &Album) -> bool {
|
||||
match album.release_type.as_deref() {
|
||||
Some(rt) => rt.eq_ignore_ascii_case("album"),
|
||||
None => album.tracks_count.or(album.track_count).unwrap_or(0) >= MIN_ALBUM_TRACKS,
|
||||
}
|
||||
}
|
||||
|
||||
/// Second layer against karaoke / tribute / "made famous by" AI-slop that can
|
||||
/// still wear a full-album shape. Matched on the artist OR title, case-folded.
|
||||
pub fn is_slop(artist: &str, title: &str) -> bool {
|
||||
const NEEDLES: &[&str] = &[
|
||||
"karaoke",
|
||||
"tribute to",
|
||||
"tribute band",
|
||||
"made famous by",
|
||||
"made popular by",
|
||||
"as made famous",
|
||||
"as made popular",
|
||||
"originally performed",
|
||||
"in the style of",
|
||||
"instrumental version",
|
||||
];
|
||||
let a = artist.to_lowercase();
|
||||
let t = title.to_lowercase();
|
||||
NEEDLES.iter().any(|n| a.contains(n) || t.contains(n))
|
||||
}
|
||||
|
||||
/// Build the reco only if the resolved Qobuz album is a real full album and not
|
||||
/// karaoke/tribute slop; otherwise discard the candidate (cached as negative).
|
||||
fn album_if_full(a: &Album, cand: &AlbumCandidate) -> Option<AlbumReco> {
|
||||
if is_full_album(a) && !is_slop(&a.artist.name, &a.title) {
|
||||
Some(build_album_reco(a, cand.subtitle.clone(), cand.source))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tracks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn track_cache_key(c: &TrackCandidate) -> String {
|
||||
@@ -226,7 +270,9 @@ async fn resolve_album_live(catalog: &dyn RecoCatalog, cand: &AlbumCandidate) ->
|
||||
.iter()
|
||||
.find(|a| a.upc.as_deref().map(|u| u.eq_ignore_ascii_case(upc)).unwrap_or(false))
|
||||
{
|
||||
return Some(build_album_reco(a, cand.subtitle.clone(), cand.source));
|
||||
// The UPC pins the exact release; if it's a single/slop, discard the
|
||||
// candidate rather than fuzzy-hunting for a different album.
|
||||
return album_if_full(a, cand);
|
||||
}
|
||||
}
|
||||
let query = format!("{} {}", cand.artist, cand.title);
|
||||
@@ -243,9 +289,7 @@ async fn resolve_album_live(catalog: &dyn RecoCatalog, cand: &AlbumCandidate) ->
|
||||
}
|
||||
}
|
||||
match best {
|
||||
Some(a) if best_score >= ALBUM_MIN_SCORE => {
|
||||
Some(build_album_reco(a, cand.subtitle.clone(), cand.source))
|
||||
}
|
||||
Some(a) if best_score >= ALBUM_MIN_SCORE => album_if_full(a, cand),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user