Files
qbz/crates/qbz-ui/ui/discover/ExternalRecoView.slint
T
vicrodh 0347e333a8 feat(reco): redesign Recommendations tab — artist/album-centric + LB playlists
Reworks the 4th Discover tab per owner feedback (the track-heavy v1 surfaced
already-heard artists). Verified against three sources that Last.fm has NO
recommendations API (getRecommendedArtists removed; web is PJAX HTML behind the
website cookie, which the API session key cannot reach) — so recommendations are
replicated from the documented similarity recipe, and ListenBrainz provides the
genuinely clean personalized feeds.

New lineup (each row paints progressively as its builder resolves):
- Recommended Artists (Last.fm artist.getSimilar seeded by last-month top, minus
  the heard set; "Similar to X, Y" subtitle on the artist card).
- Recommended Albums (Last.fm artist.getTopAlbums minus your scrobbled albums).
- Fresh Releases (ListenBrainz, from artists you follow).
- Weekly Exploration + Weekly Jams (ListenBrainz curated playlists, by source_patch).
- Deep-cut albums from artists you know (Qobuz catalog, not heard in-app).
- Cold-start fallback: Qobuz editorial top albums + artists.

The "not heard" filter is now ARTIST-level (the v1 bug derived artists from
unheard TRACKS) and compares against the LOCAL reco history + a light Last.fm/LB
top-set, not a full history sweep. Album validation (UPC -> fuzzy text) added.
qbz-integrations gains Last.fm get_artist_top_albums/get_user_top_albums and
ListenBrainz get_created_for_playlists/get_playlist_tracks/get_fresh_releases.

NOTE: the Cloudflare proxy was switched to allow ALL Last.fm read methods
(anything with ".get"), so future reads need NO proxy redeploy — but the live
proxy must be redeployed ONCE with this change or all Last.fm reads keep 400ing.

cargo check -p qbz green.

cc
2026-06-27 08:34:24 -06:00

140 lines
4.9 KiB
Plaintext

// Discover > Recommendations — the 4th tab.
//
// External-recommendations engine (Last.fm + ListenBrainz -> Qobuz). Artist /
// album / track carousels, each with a `length > 0` self-hide gate; the
// controller paints each row the moment its builder resolves (progressive).
// Data lives in ExternalRecoState (crate::external_reco). Reuses the shared
// Carousel / ArtistCarousel / SlimCarousel components.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { ExternalRecoState } from "../state.slint";
import { Carousel } from "Carousel.slint";
import { SlimCarousel } from "SlimCarousel.slint";
import { ArtistCarousel } from "ArtistCarousel.slint";
import { HomeSkeleton } from "HomeSkeleton.slint";
export component ExternalRecoView inherits VerticalLayout {
callback open-album(string);
callback open-artist(string);
callback media-action(string, string, string);
callback play-track(string);
property <bool> any-content:
ExternalRecoState.rec-artists.length > 0
|| ExternalRecoState.rec-albums.albums.length > 0
|| ExternalRecoState.fresh-releases.albums.length > 0
|| ExternalRecoState.weekly-exploration.length > 0
|| ExternalRecoState.weekly-jams.length > 0
|| ExternalRecoState.deep-cut-albums.albums.length > 0
|| ExternalRecoState.top-albums.albums.length > 0
|| ExternalRecoState.top-artists.length > 0;
spacing: 40px;
alignment: start;
if ExternalRecoState.loading && !root.any-content: HomeSkeleton { }
// Recommended Artists (Last.fm similar, not heard) — with "Similar to…".
if ExternalRecoState.rec-artists.length > 0: ArtistCarousel {
title: @tr("Recommended Artists");
artists: ExternalRecoState.rec-artists;
artist-clicked(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
// Recommended Albums (Last.fm artist top-albums, not scrobbled).
if ExternalRecoState.rec-albums.albums.length > 0: Carousel {
section: ExternalRecoState.rec-albums;
album-clicked(id) => {
root.open-album(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
// Fresh Releases (ListenBrainz, from artists you follow).
if ExternalRecoState.fresh-releases.albums.length > 0: Carousel {
section: ExternalRecoState.fresh-releases;
album-clicked(id) => {
root.open-album(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
// Weekly Exploration (ListenBrainz discovery playlist).
if ExternalRecoState.weekly-exploration.length > 0: SlimCarousel {
title: @tr("Weekly Exploration");
items: ExternalRecoState.weekly-exploration;
item-clicked(id) => {
root.play-track(id);
}
}
// Weekly Jams (ListenBrainz familiar playlist).
if ExternalRecoState.weekly-jams.length > 0: SlimCarousel {
title: @tr("Weekly Jams");
items: ExternalRecoState.weekly-jams;
item-clicked(id) => {
root.play-track(id);
}
}
// Deep cuts from artists you know (Qobuz catalog albums).
if ExternalRecoState.deep-cut-albums.albums.length > 0: Carousel {
section: ExternalRecoState.deep-cut-albums;
album-clicked(id) => {
root.open-album(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
// Cold-start fallback — Qobuz editorial top albums.
if ExternalRecoState.top-albums.albums.length > 0: Carousel {
section: ExternalRecoState.top-albums;
album-clicked(id) => {
root.open-album(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
// Cold-start fallback — Qobuz editorial top artists.
if ExternalRecoState.top-artists.length > 0: ArtistCarousel {
title: @tr("Popular artists on Qobuz");
artists: ExternalRecoState.top-artists;
artist-clicked(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
// Empty state.
if !root.any-content && !ExternalRecoState.loading: VerticalLayout {
spacing: 8px;
Text {
text: @tr("No recommendations yet");
color: Theme.text-primary;
font-size: 18px;
font-weight: Typography.semibold;
}
Text {
text: @tr("Connect Last.fm or ListenBrainz in Settings, or play more music, to get personalized recommendations.");
color: Theme.text-muted;
font-size: 13px;
}
}
}