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:
vicrodh committed 2026-06-27 11:20:06 -06:00
1 parent c6b4906a6b
commit 05df99b713
7 files changed
+85 -12

No files matched your search

+11 -3
View File
@@ -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())
{
+48 -4
View File
@@ -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,
}
}