feat(reco): wire the external-recommendations 4th Discover tab (Slint)

Adds "Recommendations" as a 4th Discover tab (alongside Home / Editor's Picks /
For You), rendering the qbz-external-reco engine output. NOT part of For You.

- ui: new ExternalRecoState global + ExternalRecoView (4 personalized carousels
  C1-C4 with length>0 self-hide gates + the editorial cold-start rows), 4th tab
  pill button in HomeView, mount on active-tab == "recommendations", skeleton
  gate. Export the global from app.slint.
- bin: external_reco controller — RecoCatalog over QbzCore, per-user resolution
  cache lifecycle, LocalHistory from reco_store (known artists + recent plays),
  scrobbler-username gate (is_authed), build + apply into ExternalRecoState
  (models built on the UI thread; slint::Image is !Send) + artwork jobs.
- bin: 6 ArtworkTarget variants + resolution arms; reco::recent_track_ids
  wrapper; home::select_tab + discover_prefs::push_descriptors treat the new tab
  like For You (own view, Home/Editor pushed empty); lazy-load hooks at the tab
  click + both Discover nav-restore sites.

cargo check -p qbz green. Last.fm reads still need the proxy allowedMethods
deploy (separate qbz-api-proxy repo) before they return data.

cc
This commit is contained in:
vicrodh committed 2026-06-27 03:12:05 -06:00
1 parent 408b3c8308
commit ec98245364
12 files changed
+588 -7

No files matched your search

+1
View File
@@ -5420,6 +5420,7 @@ dependencies = [
"qbz-core",
"qbz-credentials",
"qbz-dac-wizard",
"qbz-external-reco",
"qbz-i18n",
"qbz-integrations",
"qbz-library",
+1 -1
View File
@@ -26,7 +26,7 @@ export { Theme, ThemeColors } from "foundation/semantic-colors.slint";
export { ThemeState } from "foundation/theme-state.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, AwardState, AwardActions, AwardEntry, ArtistReleasesState, ArtistReleasesActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, BlacklistState, BlacklistActions, BlacklistedArtistItem, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, CastState, CastDevice, CastActions, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, DiscordState, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, DacWizardState, DacWizardActions, DacCandidateRow, RemediationRow, DacConfigRow, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, BookletState, BookletActions, SuggestionsState, SuggestionsActions, SuggestionCard, PlaylistSuggestionsState, PlaylistSuggestionsActions, PlaylistSuggestionRow, VisualizerState, ImmersiveState, ImmersiveSearchActions, ImmersiveActions, MiniPlayerState, WindowControlActions, PurchasesState, PurchasesActions, PurchaseAlbumItem, PurchaseTrackItem, PurchaseAlbumGroup, PurchaseTrackGroup, PurchaseFormatItem, PurchaseDetailState, PurchaseDetailActions, PurchaseDetailTrack, KeybindingRow, KeybindingCategoryGroup, KeybindingsState, KeybindingsActions, KeyboardShortcutsState, LinkResolverState, LinkResolverActions, UiFocusState } from "state.slint";
export { HomeState, HomeActions, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, ExternalRecoState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, AwardState, AwardActions, AwardEntry, ArtistReleasesState, ArtistReleasesActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, BlacklistState, BlacklistActions, BlacklistedArtistItem, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, CastState, CastDevice, CastActions, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, DiscordState, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, DacWizardState, DacWizardActions, DacCandidateRow, RemediationRow, DacConfigRow, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, BookletState, BookletActions, SuggestionsState, SuggestionsActions, SuggestionCard, PlaylistSuggestionsState, PlaylistSuggestionsActions, PlaylistSuggestionRow, VisualizerState, ImmersiveState, ImmersiveSearchActions, ImmersiveActions, MiniPlayerState, WindowControlActions, PurchasesState, PurchasesActions, PurchaseAlbumItem, PurchaseTrackItem, PurchaseAlbumGroup, PurchaseTrackGroup, PurchaseFormatItem, PurchaseDetailState, PurchaseDetailActions, PurchaseDetailTrack, KeybindingRow, KeybindingCategoryGroup, KeybindingsState, KeybindingsActions, KeyboardShortcutsState, LinkResolverState, LinkResolverActions, UiFocusState } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`.
@@ -0,0 +1,115 @@
// Discover > Recommendations — the 4th tab.
//
// External-recommendations engine (Last.fm + ListenBrainz -> Qobuz) carousels,
// plus the Qobuz-editorial cold-start fallback. Mirrors ForYouView: hardcoded
// arms, each with its own `length > 0` self-hide gate, reusing the shared
// Carousel / SlimCarousel / ArtistCarousel components. Data lives in
// ExternalRecoState (populated by crate::external_reco). At runtime exactly one
// regime is non-empty: personalized (C1-C4) OR editorial fallback.
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.similar-artists.length > 0
|| ExternalRecoState.similar-tracks.length > 0
|| ExternalRecoState.rediscover-tracks.length > 0
|| ExternalRecoState.deep-cut-tracks.length > 0
|| ExternalRecoState.top-albums.albums.length > 0
|| ExternalRecoState.top-artists.length > 0;
spacing: 40px;
alignment: start;
// Skeleton while the first load is in flight and nothing has painted yet.
if ExternalRecoState.loading && !root.any-content: HomeSkeleton { }
// C1 — Similar artists you haven't heard (artist carousel).
if ExternalRecoState.similar-artists.length > 0: ArtistCarousel {
title: @tr("Similar artists you haven't heard");
artists: ExternalRecoState.similar-artists;
artist-clicked(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
// C2 — Similar tracks you haven't heard (track slim carousel).
if ExternalRecoState.similar-tracks.length > 0: SlimCarousel {
title: @tr("Similar tracks you haven't heard");
items: ExternalRecoState.similar-tracks;
item-clicked(id) => {
root.play-track(id);
}
}
// C3 — Listened but not recently (re-discovery track carousel).
if ExternalRecoState.rediscover-tracks.length > 0: SlimCarousel {
title: @tr("Listened, but not lately");
items: ExternalRecoState.rediscover-tracks;
item-clicked(id) => {
root.play-track(id);
}
}
// C4 — From artists you know but not scrobbled (deep cuts).
if ExternalRecoState.deep-cut-tracks.length > 0: SlimCarousel {
title: @tr("Deep cuts from artists you know");
items: ExternalRecoState.deep-cut-tracks;
item-clicked(id) => {
root.play-track(id);
}
}
// Cold-start fallback — Qobuz editorial top albums (album carousel).
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 (artist carousel).
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: nothing loaded and not loading (no source, no editorial).
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;
}
}
}
+24 -1
View File
@@ -15,6 +15,7 @@ import { PlaylistCarousel } from "PlaylistCarousel.slint";
import { PlaylistTagFilter } from "PlaylistTagFilter.slint";
import { HomeSkeleton } from "HomeSkeleton.slint";
import { ForYouView } from "ForYouView.slint";
import { ExternalRecoView } from "ExternalRecoView.slint";
import { GenreFilterPopup } from "GenreFilterPopup.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
@@ -210,6 +211,11 @@ export component HomeView inherits Rectangle {
active: HomeState.active-tab == "forYou";
clicked => { HomeActions.select-tab("forYou"); }
}
Tab {
label: @tr("Recommendations");
active: HomeState.active-tab == "recommendations";
clicked => { HomeActions.select-tab("recommendations"); }
}
}
}
}
@@ -287,7 +293,7 @@ export component HomeView inherits Rectangle {
// would suppress it — gate on `loading` alone. The empty-data
// placeholders below are gated off while loading, so ONLY the
// skeleton shows during the fetch (1:1 Tauri).
if DiscoverState.active-tab != "forYou" && HomeState.loading: HomeSkeleton { }
if DiscoverState.active-tab != "forYou" && DiscoverState.active-tab != "recommendations" && HomeState.loading: HomeSkeleton { }
// Prefs-driven render loop (Slice 5). Rust pushes the ordered
// ENABLED descriptor list for the active tab (home-sections /
@@ -445,6 +451,23 @@ export component HomeView inherits Rectangle {
root.media-action("mix", which, "open");
}
}
// Recommendations (4th tab) — external-reco carousels from
// ExternalRecoState (lazy-loaded on first open).
if DiscoverState.active-tab == "recommendations": ExternalRecoView {
open-album(id) => {
root.album-clicked(id);
}
open-artist(id) => {
root.open-artist(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
play-track(id) => {
root.media-action("track", id, "play");
}
}
}
}
// Shared scrollbar in the right gutter (homologated cross-view).
+24
View File
@@ -328,6 +328,30 @@ export global ForYouState {
in-out property <bool> loaded: false;
}
// Discover > Recommendations (the 4th tab). External-recommendations engine
// (Last.fm + ListenBrainz -> Qobuz) carousels, plus the Qobuz-editorial
// cold-start fallback. Mirrors ForYouState's lazy-load latch + self-hiding
// rows (each row's `length > 0` gate). Populated by crate::external_reco.
export global ExternalRecoState {
in property <bool> loading: false;
in-out property <bool> loaded: false;
// True when no external integration is connected: the editorial fallback
// (top-albums + top-artists) is shown instead of the personalized rows.
in property <bool> editorial-fallback: false;
// C1 — similar artists you haven't heard.
in property <[SlimItem]> similar-artists: [];
// C2 — similar tracks you haven't heard.
in property <[SlimItem]> similar-tracks: [];
// C3 — listened but not recently.
in property <[SlimItem]> rediscover-tracks: [];
// C4 — from artists you know but not scrobbled (deep cuts).
in property <[SlimItem]> deep-cut-tracks: [];
// Cold-start fallback: Qobuz editorial top albums.
in property <DiscoverSection> top-albums;
// Cold-start fallback: Qobuz editorial top artists.
in property <[SlimItem]> top-artists: [];
}
// One genre in the Filter-by-genre popup's simple grid.
export struct GenreChip {
id: string,
+1
View File
@@ -70,6 +70,7 @@ qbz-media-controls = { path = "../qbz-media-controls" }
qbz-audio = { path = "../qbz-audio" }
qbz-core = { path = "../qbz-core" }
qbz-reco = { path = "../qbz-reco" }
qbz-external-reco = { path = "../qbz-external-reco" }
qbz-player = { path = "../qbz-player" }
qbz-cast = { path = "../qbz-cast" }
qconnect-app = { path = "../qconnect-app" }
+54
View File
@@ -163,6 +163,18 @@ pub enum ArtworkTarget {
ForYouMoreFromLibrary { index: usize },
/// A card in ForYouState.rediscover.albums[index].
ForYouRediscover { index: usize },
/// A tile in ExternalRecoState.similar-artists[index] (4th tab).
ExtRecoSimilarArtist { index: usize },
/// A row in ExternalRecoState.similar-tracks[index].
ExtRecoSimilarTrack { index: usize },
/// A row in ExternalRecoState.rediscover-tracks[index].
ExtRecoRediscoverTrack { index: usize },
/// A row in ExternalRecoState.deep-cut-tracks[index].
ExtRecoDeepCutTrack { index: usize },
/// A card in ExternalRecoState.top-albums.albums[index] (cold-start).
ExtRecoTopAlbum { index: usize },
/// A tile in ExternalRecoState.top-artists[index] (cold-start).
ExtRecoTopArtist { index: usize },
/// A card in ForYouState.favorite-albums.albums[index].
ForYouFavoriteAlbum { index: usize },
/// The Spotlight artist portrait.
@@ -1284,6 +1296,48 @@ fn apply_artwork(
model.set_row_data(index, item);
}
}
ArtworkTarget::ExtRecoSimilarArtist { index } => {
let model = window.global::<crate::ExternalRecoState>().get_similar_artists();
if let Some(mut item) = model.row_data(index) {
item.artwork = image;
model.set_row_data(index, item);
}
}
ArtworkTarget::ExtRecoSimilarTrack { index } => {
let model = window.global::<crate::ExternalRecoState>().get_similar_tracks();
if let Some(mut item) = model.row_data(index) {
item.artwork = image;
model.set_row_data(index, item);
}
}
ArtworkTarget::ExtRecoRediscoverTrack { index } => {
let model = window.global::<crate::ExternalRecoState>().get_rediscover_tracks();
if let Some(mut item) = model.row_data(index) {
item.artwork = image;
model.set_row_data(index, item);
}
}
ArtworkTarget::ExtRecoDeepCutTrack { index } => {
let model = window.global::<crate::ExternalRecoState>().get_deep_cut_tracks();
if let Some(mut item) = model.row_data(index) {
item.artwork = image;
model.set_row_data(index, item);
}
}
ArtworkTarget::ExtRecoTopAlbum { index } => {
let model = window.global::<crate::ExternalRecoState>().get_top_albums().albums;
if let Some(mut item) = model.row_data(index) {
item.artwork = image;
model.set_row_data(index, item);
}
}
ArtworkTarget::ExtRecoTopArtist { index } => {
let model = window.global::<crate::ExternalRecoState>().get_top_artists();
if let Some(mut item) = model.row_data(index) {
item.artwork = image;
model.set_row_data(index, item);
}
}
ArtworkTarget::ForYouFavoriteAlbum { index } => {
let model = window.global::<crate::ForYouState>().get_favorite_albums().albums;
if let Some(mut item) = model.row_data(index) {
+3 -2
View File
@@ -183,8 +183,9 @@ pub fn push_descriptors(window: &AppWindow, prefs: &DiscoverPrefs) {
// For You list (always pushed; the For You view is mounted only when active).
state.set_foryou_sections(ModelRc::new(VecModel::from(foryou_descriptors(prefs))));
if active == "forYou" {
// Drive the Home repeater empty for the For You tab.
if active == "forYou" || active == "recommendations" {
// Drive the Home repeater empty for the For You + Recommendations tabs
// (both render from their own dedicated views).
state.set_home_sections(ModelRc::new(VecModel::from(Vec::<SectionDescriptor>::new())));
state.set_editor_sections(ModelRc::new(VecModel::from(Vec::<SectionDescriptor>::new())));
} else {
+339
View File
@@ -0,0 +1,339 @@
//! Discover > Recommendations (the 4th tab) controller.
//!
//! Wires the frontend-agnostic `qbz-external-reco` engine to the Slint frontend:
//! a [`RecoCatalog`] over `QbzCore`, the per-user resolution-cache lifecycle,
//! the local-history gather from the reco store, the scrobbler-username gate,
//! and the apply into `ExternalRecoState`. Lazy-loads on first tab open (mirrors
//! `ensure_for_you_loaded`).
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use qbz_app::shell::AppRuntime;
use qbz_external_reco::{
build_external_carousels, ArtistReco, ExternalCarousels, LastFmHandle, ListenBrainzHandle,
LocalHistory, RecoCache, RecoCatalog, RecoInputs, TrackReco,
};
use qbz_integrations::{LastFmClient, ListenBrainzClient, MusicBrainzClient};
use qbz_models::{Album, Artist, Track};
use slint::{ComponentHandle, ModelRc, VecModel};
use crate::adapter::SlintAdapter;
use crate::artwork::{ArtworkJob, ArtworkTarget, ImageCache};
use crate::{AlbumCardItem, AppWindow, DiscoverSection, ExternalRecoState, SlimItem};
/// Per-user cache directory (the rec->Qobuz resolution cache lives at
/// `<dir>/external_reco_cache.db`). `None` outside an active session.
static CACHE_DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
/// Bind the per-user cache dir on session activation (next to
/// `crate::reco::init_for_user`). Opening the cache opportunistically cleans
/// expired rows.
pub fn init_for_user(base_dir: &Path) {
if let Ok(mut g) = CACHE_DIR.lock() {
*g = Some(base_dir.to_path_buf());
}
// Best-effort: open once to run cleanup; the per-build open reuses the file.
if let Ok(cache) = RecoCache::open_at(base_dir) {
let _ = cache.cleanup_expired();
}
}
/// Drop the per-user cache binding on logout. (Not strictly required: every
/// session activation re-binds via `init_for_user`; kept for symmetry.)
#[allow(dead_code)]
pub fn teardown() {
if let Ok(mut g) = CACHE_DIR.lock() {
*g = None;
}
}
// ---------------------------------------------------------------------------
// RecoCatalog over QbzCore (errors -> empty; "no data" is never an error).
// ---------------------------------------------------------------------------
struct CoreRecoCatalog {
runtime: Arc<AppRuntime<SlintAdapter>>,
}
#[async_trait]
impl RecoCatalog for CoreRecoCatalog {
async fn search_tracks(&self, query: &str, limit: usize) -> Vec<Track> {
self.runtime
.core()
.search_tracks(query, limit as u32, 0, None)
.await
.map(|p| p.items)
.unwrap_or_default()
}
async fn search_artists(&self, query: &str, limit: usize) -> Vec<Artist> {
self.runtime
.core()
.search_artists(query, limit as u32, 0, None)
.await
.map(|p| p.items)
.unwrap_or_default()
}
async fn artist_top_tracks(&self, artist_id: u64, limit: usize) -> Vec<Track> {
self.runtime
.core()
.get_artist_tracks(artist_id, limit as u32, 0)
.await
.map(|c| c.items)
.unwrap_or_default()
}
async fn featured_albums(&self, kind: &str, limit: usize) -> Vec<Album> {
self.runtime
.core()
.get_featured_albums(kind, limit as u32, 0, None)
.await
.map(|p| p.items)
.unwrap_or_default()
}
async fn get_artist(&self, artist_id: u64) -> Option<Artist> {
self.runtime.core().get_artist(artist_id).await.ok()
}
}
// ---------------------------------------------------------------------------
// Loader
// ---------------------------------------------------------------------------
/// Lazy-load the Recommendations tab the first time it is opened. No-op once
/// loaded (the data persists for the session).
pub fn ensure_loaded(
runtime: &Arc<AppRuntime<SlintAdapter>>,
weak: &slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle,
image_cache: &ImageCache,
) {
let Some(w) = weak.upgrade() else {
return;
};
if w.global::<ExternalRecoState>().get_loaded() {
return;
}
w.global::<ExternalRecoState>().set_loading(true);
spawn(runtime.clone(), weak.clone(), handle, image_cache.clone());
}
fn rotation_seed() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() / 86_400)
.unwrap_or(0)
}
fn spawn(
runtime: Arc<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle,
image_cache: ImageCache,
) {
handle.spawn(async move {
let cfg = crate::scrobbler_settings::get();
// Read clients (public reads — Last.fm via the proxy by username, LB by
// username with an optional token for higher limits, MB direct).
let lastfm_client = LastFmClient::new();
let lb_client = ListenBrainzClient::new();
if cfg.listenbrainz_is_authed() {
lb_client
.restore_token(
cfg.listenbrainz_token.clone(),
cfg.listenbrainz_username.clone(),
)
.await;
}
let mb_client = MusicBrainzClient::new();
let lastfm = if cfg.lastfm_is_authed() && !cfg.lastfm_username.is_empty() {
Some(LastFmHandle {
username: cfg.lastfm_username.clone(),
client: &lastfm_client,
})
} else {
None
};
let listenbrainz = if cfg.listenbrainz_is_authed() && !cfg.listenbrainz_username.is_empty() {
Some(ListenBrainzHandle {
username: cfg.listenbrainz_username.clone(),
client: &lb_client,
})
} else {
None
};
let local = LocalHistory {
known_artist_ids: crate::reco::known_artist_ids(2).unwrap_or_default(),
played_track_ids: crate::reco::recent_track_ids(2000)
.map(|v| v.into_iter().collect())
.unwrap_or_default(),
};
let catalog = CoreRecoCatalog {
runtime: runtime.clone(),
};
// Open the resolution cache for this build (reuses the on-disk file).
let cache = CACHE_DIR
.lock()
.ok()
.and_then(|g| g.clone())
.and_then(|dir| RecoCache::open_at(&dir).ok())
.map(Mutex::new);
let inputs = RecoInputs {
lastfm,
listenbrainz,
musicbrainz: &mb_client,
catalog: &catalog,
cache: cache.as_ref(),
local,
rotation_seed: rotation_seed(),
};
let result = build_external_carousels(inputs).await;
apply(&weak, &image_cache, result);
let _ = weak.upgrade_in_event_loop(|w| {
let s = w.global::<ExternalRecoState>();
s.set_loading(false);
s.set_loaded(true);
});
});
}
// ---------------------------------------------------------------------------
// Slint model mappers + apply
// ---------------------------------------------------------------------------
fn slim_from_artist(a: &ArtistReco) -> SlimItem {
SlimItem {
id: a.qobuz_artist_id.to_string().into(),
title: a.name.clone().into(),
subtitle: "".into(),
rank: "".into(),
artwork_url: a.image_url.clone().into(),
artwork: slint::Image::default(),
following: false,
}
}
fn slim_from_track(t: &TrackReco) -> SlimItem {
SlimItem {
id: t.qobuz_track_id.to_string().into(),
title: t.title.clone().into(),
subtitle: t.artist.clone().into(),
rank: "".into(),
artwork_url: t.artwork_url.clone().into(),
artwork: slint::Image::default(),
following: false,
}
}
fn album_card(a: &qbz_external_reco::AlbumReco) -> AlbumCardItem {
AlbumCardItem {
id: a.qobuz_album_id.clone().into(),
title: a.title.clone().into(),
artist: a.artist.clone().into(),
artist_id: a.artist_id.clone().into(),
genre: "".into(),
year: a.year.clone().into(),
quality_tier: a.quality_tier.clone().into(),
quality_label: a.quality_label.clone().into(),
ribbon: "".into(),
ribbon_kind: "".into(),
artwork_url: a.artwork_url.clone().into(),
artwork: slint::Image::default(),
..Default::default()
}
}
fn apply(weak: &slint::Weak<AppWindow>, cache: &ImageCache, data: ExternalCarousels) {
// Artwork jobs built from the resolved rows (owned String urls).
let mut jobs: Vec<ArtworkJob> = Vec::new();
let push_artist = |jobs: &mut Vec<ArtworkJob>, rows: &[ArtistReco], f: fn(usize) -> ArtworkTarget| {
for (i, a) in rows.iter().enumerate() {
if !a.image_url.is_empty() {
jobs.push(ArtworkJob {
url: a.image_url.clone(),
target: f(i),
});
}
}
};
let push_track = |jobs: &mut Vec<ArtworkJob>, rows: &[TrackReco], f: fn(usize) -> ArtworkTarget| {
for (i, t) in rows.iter().enumerate() {
if !t.artwork_url.is_empty() {
jobs.push(ArtworkJob {
url: t.artwork_url.clone(),
target: f(i),
});
}
}
};
push_artist(&mut jobs, &data.similar_artists, |i| {
ArtworkTarget::ExtRecoSimilarArtist { index: i }
});
push_track(&mut jobs, &data.similar_tracks, |i| {
ArtworkTarget::ExtRecoSimilarTrack { index: i }
});
push_track(&mut jobs, &data.rediscover_tracks, |i| {
ArtworkTarget::ExtRecoRediscoverTrack { index: i }
});
push_track(&mut jobs, &data.deep_cut_tracks, |i| {
ArtworkTarget::ExtRecoDeepCutTrack { index: i }
});
for (i, a) in data.top_albums.iter().enumerate() {
if !a.artwork_url.is_empty() {
jobs.push(ArtworkJob {
url: a.artwork_url.clone(),
target: ArtworkTarget::ExtRecoTopAlbum { index: i },
});
}
}
push_artist(&mut jobs, &data.top_artists, |i| {
ArtworkTarget::ExtRecoTopArtist { index: i }
});
// Convert to Slint models INSIDE the event-loop closure: SlimItem /
// AlbumCardItem hold a slint::Image (!Send), so they cannot cross the thread
// boundary. `data` is plain String/u64 (Send) — move it in and map on the UI
// thread (mirrors foryou.rs apply_*).
let w = weak.clone();
let _ = w.upgrade_in_event_loop(move |w| {
let s = w.global::<ExternalRecoState>();
s.set_editorial_fallback(data.editorial_fallback);
s.set_similar_artists(ModelRc::new(VecModel::from(
data.similar_artists.iter().map(slim_from_artist).collect::<Vec<_>>(),
)));
s.set_similar_tracks(ModelRc::new(VecModel::from(
data.similar_tracks.iter().map(slim_from_track).collect::<Vec<_>>(),
)));
s.set_rediscover_tracks(ModelRc::new(VecModel::from(
data.rediscover_tracks.iter().map(slim_from_track).collect::<Vec<_>>(),
)));
s.set_deep_cut_tracks(ModelRc::new(VecModel::from(
data.deep_cut_tracks.iter().map(slim_from_track).collect::<Vec<_>>(),
)));
s.set_top_artists(ModelRc::new(VecModel::from(
data.top_artists.iter().map(slim_from_artist).collect::<Vec<_>>(),
)));
s.set_top_albums(DiscoverSection {
title: qbz_i18n::t("Top albums on Qobuz").into(),
endpoint: "".into(),
albums: ModelRc::new(VecModel::from(
data.top_albums.iter().map(album_card).collect::<Vec<_>>(),
)),
});
});
crate::artwork::spawn_loads(jobs, weak.clone(), cache.clone());
}
+4 -3
View File
@@ -909,9 +909,10 @@ pub fn select_tab(window: &AppWindow, tab: &str) -> Vec<ArtworkJob> {
window.global::<HomeState>().set_active_tab(tab.into());
window.global::<DiscoverState>().set_active_tab(tab.into());
if tab == "forYou" {
// Push the For You descriptor list + drive Home/Editor empty, and clear
// the legacy HomeState models so nothing lingers under the For You view.
if tab == "forYou" || tab == "recommendations" {
// For You + Recommendations both render from their own dedicated state /
// view; push the For You descriptor list + drive Home/Editor empty, and
// clear the legacy HomeState models so nothing lingers underneath.
let prefs = crate::discover_prefs::prefs_snapshot();
crate::discover_prefs::push_descriptors(window, &prefs);
let hstate = window.global::<HomeState>();
+13
View File
@@ -38,6 +38,7 @@ mod discovery_dismiss;
mod fav_cache;
mod favorites;
mod favorites_prefs;
mod external_reco;
mod foryou;
mod genre_filter;
use qbz_dac_wizard as dac_wizard;
@@ -521,6 +522,8 @@ async fn enter_shell_offline(
crate::fav_cache::init_for_user(&dir);
// Recommendation event store (shared events.db with Tauri).
crate::reco::init_for_user(&dir);
// External-recommendations resolution cache (4th Discover tab).
crate::external_reco::init_for_user(&dir);
// Playlist Suggested Songs: open the per-user artist-vector store.
if let Ok(store) = qbz_reco::ArtistVectorStore::open_at(&dir) {
runtime.core().set_artist_vectors(store).await;
@@ -1894,6 +1897,7 @@ fn apply_entry(
}
nav::NavEntry::Discover { tab } => {
let for_you = tab == "forYou";
let recommendations = tab == "recommendations";
{
let weak = weak.clone();
let image_cache = image_cache.clone();
@@ -1906,6 +1910,9 @@ fn apply_entry(
if for_you {
ensure_for_you_loaded(runtime, weak, handle, image_cache);
}
if recommendations {
external_reco::ensure_loaded(runtime, weak, handle, image_cache);
}
}
nav::NavEntry::Favorites { tab } => {
if let Some(fav_tab) = favorites::FavTab::from_tab_id(&tab) {
@@ -12460,6 +12467,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if tab.as_str() == "forYou" {
ensure_for_you_loaded(&runtime, &weak, &handle, &image_cache);
}
if tab.as_str() == "recommendations" {
external_reco::ensure_loaded(&runtime, &weak, &handle, &image_cache);
}
}
});
}
@@ -12855,6 +12865,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if tab == "forYou" {
ensure_for_you_loaded(&runtime, &weak, &handle, &image_cache);
}
if tab == "recommendations" {
external_reco::ensure_loaded(&runtime, &weak, &handle, &image_cache);
}
}
return;
}
+9
View File
@@ -241,6 +241,15 @@ pub fn known_artist_ids(play_threshold: u32) -> Option<HashSet<u64>> {
store.get_known_artist_ids(play_threshold).ok()
}
/// Most-recently-played distinct Qobuz track ids (the local "already heard
/// in-app" set, e.g. for the external-reco "not scrobbled" deep-cut filter).
/// `None` when reco is cold/disabled.
pub fn recent_track_ids(limit: u32) -> Option<Vec<u64>> {
let guard = RECO.lock().ok()?;
let store = guard.as_ref()?;
store.get_recent_track_ids(limit).ok()
}
// ---------------------------------------------------------------------------
// Training (W5)
// ---------------------------------------------------------------------------