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:
12 files changed
+588
-7
No files matched your search
Generated
+1
@@ -5420,6 +5420,7 @@ dependencies = [
|
||||
"qbz-core",
|
||||
"qbz-credentials",
|
||||
"qbz-dac-wizard",
|
||||
"qbz-external-reco",
|
||||
"qbz-i18n",
|
||||
"qbz-integrations",
|
||||
"qbz-library",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user