fix(home): render all 13 configurator sections; own recent-albums history
Two Home bugs, one dropped port. #566: four configurator sections (favoriteAlbums 'Library Albums', qobuzMixes, releaseWatch, topArtists) were offered and persisted but had no HomeView render arm and no data in load_home — enabling them produced an invisible row. All four were genuine Tauri-Home features (DiscoveryView.svelte arms :632/:653/:660/ :666, loaded unconditionally), so this is the full port, not a trim: own HomeState fields (separate lifecycle from ForYouState), shared foryou fetch pipelines (favorite_album_cards / fetch_release_watch / top_artist_cards extracted pub(crate), For You behavior untouched), Home-scoped artwork targets, and HomeView arms mirroring ForYouView. load_home joins the three fetches concurrently with the index — zero added latency. A HOME_RENDERABLE guard next to descriptors_for is now the single source of truth: an enabled id without a render arm can never emit an invisible descriptor again. #567: 'Recently Played Albums' starved because albums derived from the 24-track history (long albums collapse to ~4 distinct). recently.rs now keeps a separate album history (dedupe by id at record time, own 24 cap) with additive migration from the legacy bare-array format; tracks keep their cap; load()/load_albums() signatures unchanged. Zero new msgids (all three titles reuse For You's). qbz-app tests 10/10; slintcheck green. cc
This commit is contained in:
8 files changed
+384
-70
No files matched your search
@@ -169,7 +169,10 @@ pub struct DiscoverPrefs {
|
||||
pub fn default_prefs() -> DiscoverPrefs {
|
||||
use DiscoverySectionId::*;
|
||||
DiscoverPrefs {
|
||||
// home: first 7 ON, last 6 OFF.
|
||||
// home: first 7 ON, last 6 OFF (1:1 with Tauri sectionPrefs.ts:63-77).
|
||||
// All 13 ids render on Home since #566 completed the port: qobuzMixes
|
||||
// / releaseWatch / topArtists / favoriteAlbums were genuine Tauri-Home
|
||||
// sections whose Slint render arms + data pipelines were missing.
|
||||
home: vec![
|
||||
pref(NewReleases, true),
|
||||
pref(PressAwards, true),
|
||||
@@ -503,7 +506,7 @@ mod tests {
|
||||
#[test]
|
||||
fn defaults_match_spec_exactly() {
|
||||
let d = default_prefs();
|
||||
// home: 13 entries, first 7 ON.
|
||||
// home: 13 entries, first 7 ON (1:1 Tauri sectionPrefs.ts).
|
||||
assert_eq!(
|
||||
ids(&d.home),
|
||||
vec![
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Typography } from "../foundation/typography.slint";
|
||||
import { HomeState, HomeActions, DiscoverState, DiscoverActions, GenreFilterState, GenreFilterActions, NavState, ContentView, ShellState, SettingsState } from "../state.slint";
|
||||
import { Carousel } from "Carousel.slint";
|
||||
import { SlimCarousel } from "SlimCarousel.slint";
|
||||
import { ArtistCarousel } from "ArtistCarousel.slint";
|
||||
import { QobuzMixesRow } from "QobuzMixesRow.slint";
|
||||
import { PlaylistCarousel } from "PlaylistCarousel.slint";
|
||||
import { PlaylistTagFilter } from "PlaylistTagFilter.slint";
|
||||
import { HomeSkeleton } from "HomeSkeleton.slint";
|
||||
@@ -421,6 +423,61 @@ export component HomeView inherits Rectangle {
|
||||
root.album-clicked(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Library Albums (#566) — the user's favorite albums,
|
||||
// mirroring the For You favoriteAlbums carousel. Data +
|
||||
// localized title are pushed by Rust into
|
||||
// HomeState.favorite-albums; self-hides while empty
|
||||
// (no placeholder, 1:1 with the For You arm).
|
||||
if desc.id == "favoriteAlbums"
|
||||
&& HomeState.favorite-albums.albums.length > 0: Carousel {
|
||||
section: HomeState.favorite-albums;
|
||||
album-clicked(id) => {
|
||||
root.album-clicked(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
|
||||
// Qobuz Mixes (#566) — static navigation tiles, ALWAYS
|
||||
// rendered when enabled (no data gate; 1:1 with the For
|
||||
// You arm and Tauri Home).
|
||||
if desc.id == "qobuzMixes": QobuzMixesRow {
|
||||
open-mix(which) => {
|
||||
root.media-action("mix", which, "open");
|
||||
}
|
||||
}
|
||||
|
||||
// Release Watch (#566) — album carousel, mirroring the
|
||||
// For You releaseWatch arm; data + localized title pushed
|
||||
// by Rust into HomeState.release-watch. Self-hides while
|
||||
// empty.
|
||||
if desc.id == "releaseWatch"
|
||||
&& HomeState.release-watch.albums.length > 0: Carousel {
|
||||
section: HomeState.release-watch;
|
||||
album-clicked(id) => {
|
||||
root.album-clicked(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
|
||||
// Your Top Artists (#566) — artist carousel, mirroring
|
||||
// the For You topArtists arm (title @tr'd here, like For
|
||||
// You's). Self-hides while empty.
|
||||
if desc.id == "topArtists"
|
||||
&& HomeState.top-artists.length > 0: ArtistCarousel {
|
||||
title: @tr("Your Top Artists");
|
||||
artists: HomeState.top-artists;
|
||||
artist-clicked(id) => {
|
||||
root.open-artist(id);
|
||||
}
|
||||
media-action(kind, id, action) => {
|
||||
root.media-action(kind, id, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For You renders its own ordered, heterogeneous
|
||||
|
||||
@@ -168,6 +168,15 @@ export global HomeState {
|
||||
in property <[SlimItem]> popular: [];
|
||||
in property <[SlimItem]> recent: [];
|
||||
in property <[AlbumCardItem]> recent-albums: [];
|
||||
// The #566 ported rails — same data pipelines as their ForYouState twins
|
||||
// but separate lifecycles (Home loads them eagerly with the discover
|
||||
// index; For You lazily on first open). The HomeView arms self-hide
|
||||
// while empty. Titles: favorite-albums / release-watch are set by Rust;
|
||||
// top-artists' title is @tr in the view arm (bare list, like For You).
|
||||
// (qobuzMixes, the fourth ported section, is static tiles — no state.)
|
||||
in property <DiscoverSection> favorite-albums;
|
||||
in property <DiscoverSection> release-watch;
|
||||
in property <[SlimItem]> top-artists: [];
|
||||
// Qobuz Playlists row (Home + Editor's Picks). Single-cover cards; the
|
||||
// active tab's set is swapped in by crate::home::select_tab.
|
||||
in property <[SearchPlaylistItem]> playlists: [];
|
||||
|
||||
@@ -65,6 +65,16 @@ pub enum ArtworkTarget {
|
||||
Recent { idx: usize },
|
||||
/// A card in `HomeState.recent-albums[idx]`.
|
||||
RecentAlbum { idx: usize },
|
||||
/// A card in `HomeState.favorite-albums.albums[idx]` — the Home tab's
|
||||
/// "Library Albums" rail (#566). Separate from `ForYouFavoriteAlbum`:
|
||||
/// the two rails share the data pipeline but not the model lifecycle.
|
||||
HomeFavoriteAlbum { idx: usize },
|
||||
/// A card in `HomeState.release-watch.albums[idx]` — the Home tab's
|
||||
/// "Release Watch" rail (#566; ForYouReleaseWatch's Home twin).
|
||||
HomeReleaseWatchAlbum { idx: usize },
|
||||
/// A tile in `HomeState.top-artists[idx]` — the Home tab's "Your Top
|
||||
/// Artists" rail (#566; ForYouTopArtist's Home twin).
|
||||
HomeTopArtist { idx: usize },
|
||||
/// A single playlist cover of `HomeState.playlists[idx]` (single cover →
|
||||
/// slot 0, unlike the 4-slot SearchPlaylistCover/FavPlaylistCover).
|
||||
HomePlaylistCover { idx: usize },
|
||||
@@ -918,6 +928,30 @@ fn apply_artwork(
|
||||
item.artwork = image;
|
||||
albums.set_row_data(idx, item);
|
||||
}
|
||||
ArtworkTarget::HomeFavoriteAlbum { idx } => {
|
||||
let section = home.get_favorite_albums();
|
||||
let Some(mut item) = section.albums.row_data(idx) else {
|
||||
return;
|
||||
};
|
||||
item.artwork = image;
|
||||
section.albums.set_row_data(idx, item);
|
||||
}
|
||||
ArtworkTarget::HomeReleaseWatchAlbum { idx } => {
|
||||
let section = home.get_release_watch();
|
||||
let Some(mut item) = section.albums.row_data(idx) else {
|
||||
return;
|
||||
};
|
||||
item.artwork = image;
|
||||
section.albums.set_row_data(idx, item);
|
||||
}
|
||||
ArtworkTarget::HomeTopArtist { idx } => {
|
||||
let model = home.get_top_artists();
|
||||
let Some(mut item) = model.row_data(idx) else {
|
||||
return;
|
||||
};
|
||||
item.artwork = image;
|
||||
model.set_row_data(idx, item);
|
||||
}
|
||||
ArtworkTarget::HomePlaylistCover { idx } => {
|
||||
let model = home.get_playlists();
|
||||
if let Some(mut item) = model.row_data(idx) {
|
||||
|
||||
@@ -136,7 +136,10 @@ fn map_artist(artist: Artist, following: bool) -> ArtistSlim {
|
||||
// orchestrator can fire its apply the moment the call resolves.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn fetch_release_watch<A>(runtime: &Arc<AppRuntime<A>>) -> Vec<AlbumCard>
|
||||
/// `pub(crate)` since #566: Home's Release Watch rail (`home::load_home`)
|
||||
/// fetches through this SAME pipeline (blacklist filter included) so both
|
||||
/// tabs render identical data.
|
||||
pub(crate) async fn fetch_release_watch<A>(runtime: &Arc<AppRuntime<A>>) -> Vec<AlbumCard>
|
||||
where
|
||||
A: FrontendAdapter + Send + Sync + 'static,
|
||||
{
|
||||
@@ -421,6 +424,17 @@ fn top_artist_slims(fav_artists: &[Artist]) -> Vec<ArtistSlim> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The full "Your Top Artists" data pipeline (favorite artists -> cap 18,
|
||||
/// following=true) as one call. Shared with Home (#566), mirroring
|
||||
/// [`favorite_album_cards`]: For You's own `artists_branch` keeps the pieces
|
||||
/// because it also needs the raw `Vec<Artist>` for To-Follow / Spotlight.
|
||||
pub(crate) async fn top_artist_cards<A>(runtime: &Arc<AppRuntime<A>>) -> Vec<ArtistSlim>
|
||||
where
|
||||
A: FrontendAdapter + Send + Sync + 'static,
|
||||
{
|
||||
top_artist_slims(&fetch_fav_artists(runtime).await)
|
||||
}
|
||||
|
||||
/// Rediscover — favorite albums the user hasn't returned to lately. Prefers the
|
||||
/// reco store's "forgotten favorites" (favorited, not played in 30d, from the
|
||||
/// Tauri-shared events.db) when warm; falls back to "not in the local recents
|
||||
@@ -462,6 +476,22 @@ fn build_favorite_albums(fav_albums: &[Album]) -> Vec<AlbumCard> {
|
||||
fav_albums.iter().take(18).cloned().map(map_album).collect()
|
||||
}
|
||||
|
||||
/// The full "Library Albums" data pipeline (fetch -> taste-order -> cap 18)
|
||||
/// as one call. Shared with Home (#566): `home::load_home` populates
|
||||
/// `HomeState.favorite-albums` from this SAME pipeline, so the Home rail and
|
||||
/// the For You rail render identical data. For You's own `albums_branch`
|
||||
/// keeps calling the pieces directly because it also needs the un-capped
|
||||
/// `Vec<Album>` for Rediscover / Radio / the genre backfill.
|
||||
pub(crate) async fn favorite_album_cards<A>(runtime: &Arc<AppRuntime<A>>) -> Vec<AlbumCard>
|
||||
where
|
||||
A: FrontendAdapter + Send + Sync + 'static,
|
||||
{
|
||||
let fav_albums = fetch_fav_albums(runtime).await;
|
||||
let scored = crate::reco::scored_favorite_album_ids(80);
|
||||
let fav_albums = order_by_score(fav_albums, scored.as_deref());
|
||||
build_favorite_albums(&fav_albums)
|
||||
}
|
||||
|
||||
/// Radio Stations — album-seeded tiles from recent + favorite albums,
|
||||
/// deduped, capped at 12.
|
||||
fn build_radio(
|
||||
@@ -533,7 +563,9 @@ fn album_items(cards: &[AlbumCard]) -> Vec<AlbumCardItem> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn artist_items(artists: &[ArtistSlim]) -> Vec<SlimItem> {
|
||||
/// `pub(crate)` since #566: `home::apply_home` reuses it for the Home
|
||||
/// "Your Top Artists" rail so both tabs map artists -> items identically.
|
||||
pub(crate) fn artist_items(artists: &[ArtistSlim]) -> Vec<SlimItem> {
|
||||
artists
|
||||
.iter()
|
||||
.map(|a| SlimItem {
|
||||
@@ -548,7 +580,10 @@ fn artist_items(artists: &[ArtistSlim]) -> Vec<SlimItem> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn section(title: &str, cards: &[AlbumCard]) -> DiscoverSection {
|
||||
/// Build a `DiscoverSection` from album cards. `pub(crate)` since #566:
|
||||
/// `home::apply_home` reuses it for the Home "Library Albums" rail so both
|
||||
/// tabs map cards -> items identically (incl. the fav-cache heart state).
|
||||
pub(crate) fn section(title: &str, cards: &[AlbumCard]) -> DiscoverSection {
|
||||
DiscoverSection {
|
||||
title: title.into(),
|
||||
// For You sections have no Discover full-list page.
|
||||
|
||||
+79
-6
@@ -37,6 +37,21 @@ pub struct HomeData {
|
||||
/// Category tags for the Qobuz Playlists multi-select filter: (slug,
|
||||
/// localized name). Empty when the index carries no `playlists_tags`.
|
||||
pub playlist_tags: Vec<(String, String)>,
|
||||
/// "Library Albums" rail (#566) — the user's favorite albums from the
|
||||
/// SAME pipeline For You uses (`foryou::favorite_album_cards`), fetched
|
||||
/// concurrently with the discover index. Feeds
|
||||
/// `HomeState.favorite-albums`; the view arm self-hides while empty.
|
||||
pub favorite_albums: Vec<crate::foryou::AlbumCard>,
|
||||
/// "Release Watch" rail (#566) — same pipeline as For You
|
||||
/// (`foryou::fetch_release_watch`, blacklist-filtered), fetched
|
||||
/// concurrently. Feeds `HomeState.release-watch`; self-hides while empty.
|
||||
pub release_watch: Vec<crate::foryou::AlbumCard>,
|
||||
/// "Your Top Artists" rail (#566) — same pipeline as For You
|
||||
/// (`foryou::top_artist_cards`), fetched concurrently. Feeds
|
||||
/// `HomeState.top-artists`; self-hides while empty. (qobuzMixes, the
|
||||
/// fourth ported Tauri-Home section, is static navigation tiles — no
|
||||
/// data field needed.)
|
||||
pub top_artists: Vec<crate::foryou::ArtistSlim>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
@@ -147,11 +162,18 @@ pub async fn load_home<A>(
|
||||
where
|
||||
A: FrontendAdapter + Send + Sync + 'static,
|
||||
{
|
||||
let response = runtime
|
||||
.core()
|
||||
.get_discover_index(genre_ids)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
// The personalized Home rails (#566: Library Albums / Release Watch /
|
||||
// Your Top Artists) resolve CONCURRENTLY with the index so they add no
|
||||
// latency to the home load. Fetched unconditionally (like the
|
||||
// Qobuzissimes cache-pool precedent below): the configurator re-render
|
||||
// is cache-only, so enabling a section must find its data populated.
|
||||
let (response, favorite_albums, release_watch, top_artists) = futures_util::join!(
|
||||
runtime.core().get_discover_index(genre_ids),
|
||||
crate::foryou::favorite_album_cards(runtime),
|
||||
crate::foryou::fetch_release_watch(runtime),
|
||||
crate::foryou::top_artist_cards(runtime),
|
||||
);
|
||||
let response = response.map_err(|e| e.to_string())?;
|
||||
let mut containers = response.containers;
|
||||
|
||||
// T8: drop blacklisted DiscoverAlbums (ANY of artists[], featured-aware)
|
||||
@@ -335,6 +357,9 @@ where
|
||||
playlists,
|
||||
editor_playlists,
|
||||
playlist_tags,
|
||||
favorite_albums,
|
||||
release_watch,
|
||||
top_artists,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -688,12 +713,40 @@ fn descriptor_section(data: &SectionData) -> DiscoverSection {
|
||||
}
|
||||
}
|
||||
|
||||
/// SINGLE SOURCE OF TRUTH for what the Home / Editor's Picks repeater can
|
||||
/// actually render (#566): the section ids `HomeView.slint`'s delegate
|
||||
/// if-chain has arms for. `descriptors_for` drops any enabled id NOT in this
|
||||
/// set, so a stale persisted pref (e.g. qobuzMixes / releaseWatch / topArtists,
|
||||
/// removed from the Home defaults 2026-07) can never emit an armless
|
||||
/// descriptor again — an enabled section that renders nothing. Belt and
|
||||
/// suspenders with `reconcile_list` (qbz-app), which already scrubs ids
|
||||
/// absent from the tab defaults at load time. Extend this list IN THE SAME
|
||||
/// CHANGE that adds a new arm to HomeView.slint.
|
||||
const HOME_RENDERABLE: &[DiscoverySectionId] = &[
|
||||
DiscoverySectionId::NewReleases,
|
||||
DiscoverySectionId::PressAwards,
|
||||
DiscoverySectionId::IdealDiscography,
|
||||
DiscoverySectionId::EditorPicks,
|
||||
DiscoverySectionId::Qobuzissimes,
|
||||
DiscoverySectionId::MostStreamed,
|
||||
DiscoverySectionId::QobuzPlaylists,
|
||||
DiscoverySectionId::RecentlyPlayedAlbums,
|
||||
DiscoverySectionId::ContinueListening,
|
||||
DiscoverySectionId::FavoriteAlbums,
|
||||
DiscoverySectionId::QobuzMixes,
|
||||
DiscoverySectionId::ReleaseWatch,
|
||||
DiscoverySectionId::TopArtists,
|
||||
];
|
||||
|
||||
/// Build one tab's ordered ENABLED descriptor list from `prefs` + the cached
|
||||
/// section data. Album-carousel ids embed their `DiscoverSection` (Home/Editor
|
||||
/// share the Carousel component but have no per-id HomeState field). The
|
||||
/// fixed-data ids (qobuzPlaylists / continueListening / mostStreamed-slim) and
|
||||
/// the always-present-with-placeholder ids (recentlyPlayedAlbums on Home) bind
|
||||
/// HomeState fields in the view; they carry an empty `section`.
|
||||
/// HomeState fields in the view; they carry an empty `section` — as do the
|
||||
/// #566 ported rails: favoriteAlbums / releaseWatch / topArtists bind their
|
||||
/// HomeState fields and self-hide while empty; qobuzMixes is static
|
||||
/// navigation tiles, always rendered when enabled (Tauri parity).
|
||||
///
|
||||
/// **Empty-section policy (b):** an album-carousel id with no cached data is
|
||||
/// DROPPED (no backing `SectionData` → nothing to render, and these have no
|
||||
@@ -706,6 +759,11 @@ fn descriptors_for(prefs: &DiscoverPrefs, tab: DiscoveryTab, cached: &[SectionDa
|
||||
let editor = tab == DiscoveryTab::EditorPicks;
|
||||
let mut out = Vec::new();
|
||||
for id in prefs.enabled_ordered(tab) {
|
||||
// #566 structural guard: skip ids the HomeView repeater has no arm
|
||||
// for (stale persisted prefs) instead of emitting an invisible row.
|
||||
if !HOME_RENDERABLE.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
// mostStreamed renders as an album carousel on Editor's Picks, a slim
|
||||
// grid on Home — encode that in `kind` so the delegate dispatches it
|
||||
// without reading active-tab.
|
||||
@@ -992,6 +1050,21 @@ pub fn apply_home(window: &AppWindow, data: HomeData) {
|
||||
state.set_popular(ModelRc::new(VecModel::from(popular)));
|
||||
state.set_recent(ModelRc::new(VecModel::from(recent)));
|
||||
state.set_recent_albums(ModelRc::new(VecModel::from(recent_albums)));
|
||||
// The #566 ported rails — same section builders + title msgids as their
|
||||
// For You twins (foryou::apply_favorite_albums / apply_release_watch /
|
||||
// apply_top_artists), separate lifecycles. Top Artists' title lives in
|
||||
// the HomeView arm (@tr, like ForYouView's) — its model is a bare list.
|
||||
state.set_favorite_albums(crate::foryou::section(
|
||||
&qbz_i18n::t("Library Albums"),
|
||||
&data.favorite_albums,
|
||||
));
|
||||
state.set_release_watch(crate::foryou::section(
|
||||
&qbz_i18n::t("Release Watch"),
|
||||
&data.release_watch,
|
||||
));
|
||||
state.set_top_artists(ModelRc::new(VecModel::from(crate::foryou::artist_items(
|
||||
&data.top_artists,
|
||||
))));
|
||||
state.set_playlists(ModelRc::new(VecModel::from(home_playlists)));
|
||||
state.set_playlist_tags(ModelRc::new(VecModel::from(tag_items)));
|
||||
state.set_playlist_tag_count(0);
|
||||
|
||||
@@ -721,6 +721,32 @@ async fn reload_home(
|
||||
}
|
||||
}
|
||||
|
||||
// #566 ported-rail covers — Library Albums / Release Watch are
|
||||
// Qobuz catalog albums and Top Artists are Qobuz artist images,
|
||||
// so the plain loader applies (same as their For You twins).
|
||||
// Prefetched regardless of the pref state, like the slim grids
|
||||
// above: the models are populated either way and the configurator
|
||||
// re-render is cache-only, so enabling a section must find its
|
||||
// covers ready. (qobuzMixes is static tiles — no artwork.)
|
||||
jobs.extend(data.favorite_albums.iter().enumerate().filter_map(|(idx, card)| {
|
||||
(!card.artwork_url.is_empty()).then(|| artwork::ArtworkJob {
|
||||
target: artwork::ArtworkTarget::HomeFavoriteAlbum { idx },
|
||||
url: card.artwork_url.clone(),
|
||||
})
|
||||
}));
|
||||
jobs.extend(data.release_watch.iter().enumerate().filter_map(|(idx, card)| {
|
||||
(!card.artwork_url.is_empty()).then(|| artwork::ArtworkJob {
|
||||
target: artwork::ArtworkTarget::HomeReleaseWatchAlbum { idx },
|
||||
url: card.artwork_url.clone(),
|
||||
})
|
||||
}));
|
||||
jobs.extend(data.top_artists.iter().enumerate().filter_map(|(idx, artist)| {
|
||||
(!artist.artwork_url.is_empty()).then(|| artwork::ArtworkJob {
|
||||
target: artwork::ArtworkTarget::HomeTopArtist { idx },
|
||||
url: artist.artwork_url.clone(),
|
||||
})
|
||||
}));
|
||||
|
||||
// Qobuz Playlists row covers for the active tab (single-cover,
|
||||
// Qobuz CDN URLs → the plain loader, never the local/Plex funnel).
|
||||
let empty_playlists: Vec<home::PlaylistCardData> = Vec::new();
|
||||
|
||||
+136
-59
@@ -1,10 +1,18 @@
|
||||
//! Recently-played store.
|
||||
//!
|
||||
//! A small JSON file at the shared QBZ data path holding the last few
|
||||
//! played tracks, newest first. Discover Home renders two sections from
|
||||
//! it — recently-played tracks (slim cards) and recently-played albums
|
||||
//! (derived by de-duplicating the track history by album). The playback
|
||||
//! session calls [`record`] when a track starts.
|
||||
//! played tracks AND the last few played albums, newest first. Discover
|
||||
//! Home renders two sections from it — recently-played tracks (slim
|
||||
//! cards) and recently-played albums. The playback session calls
|
||||
//! [`record`] when a track starts.
|
||||
//!
|
||||
//! The album history is a SEPARATE list with its own cap (#567): deriving
|
||||
//! albums from the 24-track window collapsed long albums into ~4 distinct
|
||||
//! album cards, starving the "Recently Played Albums" rail. Both lists are
|
||||
//! deduplicated by id at record time. Persisted format is an object
|
||||
//! `{ "tracks": [...], "albums": [...] }`; the legacy format (a bare track
|
||||
//! array) is migrated on read by deriving the album list from the track
|
||||
//! window exactly as before, so old stores lose nothing.
|
||||
//!
|
||||
//! Until playback is wired the store is simply empty and the Home
|
||||
//! sections that read it hide themselves — the data path exists end to
|
||||
@@ -19,6 +27,10 @@ use serde::{Deserialize, Serialize};
|
||||
/// How many recent tracks to keep.
|
||||
const MAX_RECENT: usize = 24;
|
||||
|
||||
/// How many recent albums to keep — independent of the track cap, so a
|
||||
/// string of long albums cannot shrink the distinct-album history (#567).
|
||||
const MAX_RECENT_ALBUMS: usize = 24;
|
||||
|
||||
/// Album-level metadata captured when an album is fetched for playback,
|
||||
/// keyed by album id. The queue track itself (a `qbz_models::QueueTrack`)
|
||||
/// carries no genre / release-date and — for the `album/get` path — no
|
||||
@@ -97,103 +109,105 @@ pub struct RecentTrack {
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// One recently-played album, derived from the track history.
|
||||
#[derive(Clone, Debug)]
|
||||
/// One recently-played album. Since #567 this is its OWN persisted history
|
||||
/// (deduplicated by album id at record time, capped at [`MAX_RECENT_ALBUMS`]),
|
||||
/// no longer derived from the 24-track window at read time. Every field takes
|
||||
/// a serde default so entries written by older builds (or the legacy-derive
|
||||
/// path) stay readable if fields are added later.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct RecentAlbum {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub artist: String,
|
||||
#[serde(default)]
|
||||
pub artwork_url: String,
|
||||
#[serde(default)]
|
||||
pub quality_tier: String,
|
||||
#[serde(default)]
|
||||
pub quality_label: String,
|
||||
#[serde(default)]
|
||||
pub genre: String,
|
||||
/// Raw ISO release date; localized at render time.
|
||||
#[serde(default)]
|
||||
pub release_date: String,
|
||||
/// "qobuz" | "plex" | "local" — see RecentTrack::source.
|
||||
#[serde(default)]
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// The persisted store shape since #567: the track history plus the separate
|
||||
/// album history. The legacy shape (a bare `Vec<RecentTrack>` array) is
|
||||
/// handled by [`read_store`]'s fallback branch.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct RecentStore {
|
||||
#[serde(default)]
|
||||
tracks: Vec<RecentTrack>,
|
||||
#[serde(default)]
|
||||
albums: Vec<RecentAlbum>,
|
||||
}
|
||||
|
||||
fn store_path() -> Option<PathBuf> {
|
||||
Some(dirs::data_dir()?.join("qbz").join("recently_played.json"))
|
||||
}
|
||||
|
||||
/// Load the recently-played tracks, newest first. Returns an empty list
|
||||
/// when the store does not exist yet or cannot be read.
|
||||
pub fn load() -> Vec<RecentTrack> {
|
||||
let Some(path) = store_path() else {
|
||||
return Vec::new();
|
||||
};
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recently-played albums, newest first, de-duplicated from the track
|
||||
/// history. A track with no album id is skipped.
|
||||
pub fn load_albums() -> Vec<RecentAlbum> {
|
||||
/// Derive an album list from a track window by first-occurrence de-dup —
|
||||
/// the pre-#567 behaviour, kept ONLY for the one-time legacy migration in
|
||||
/// [`read_store`]. A track with no album id is skipped.
|
||||
fn derive_albums(tracks: &[RecentTrack]) -> Vec<RecentAlbum> {
|
||||
let mut albums: Vec<RecentAlbum> = Vec::new();
|
||||
for track in load() {
|
||||
for track in tracks {
|
||||
if track.album_id.is_empty() || albums.iter().any(|a| a.id == track.album_id) {
|
||||
continue;
|
||||
}
|
||||
albums.push(RecentAlbum {
|
||||
id: track.album_id,
|
||||
title: track.album_title,
|
||||
artist: track.album_artist,
|
||||
artwork_url: track.album_artwork_url,
|
||||
quality_tier: track.quality_tier,
|
||||
quality_label: track.quality_label,
|
||||
genre: track.genre,
|
||||
release_date: track.release_date,
|
||||
source: track.source,
|
||||
id: track.album_id.clone(),
|
||||
title: track.album_title.clone(),
|
||||
artist: track.album_artist.clone(),
|
||||
artwork_url: track.album_artwork_url.clone(),
|
||||
quality_tier: track.quality_tier.clone(),
|
||||
quality_label: track.quality_label.clone(),
|
||||
genre: track.genre.clone(),
|
||||
release_date: track.release_date.clone(),
|
||||
source: track.source.clone(),
|
||||
});
|
||||
}
|
||||
albums
|
||||
}
|
||||
|
||||
/// Remove every recently-played entry whose `album_id` is in `album_ids`.
|
||||
/// Used when a Local Library folder is deleted so its albums/tracks no longer
|
||||
/// linger in Recently Played. Returns how many track entries were removed.
|
||||
pub fn prune_albums(album_ids: &[String]) -> usize {
|
||||
if album_ids.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
/// Read the whole store. Missing / unreadable file -> empty. A legacy bare
|
||||
/// track array (pre-#567) migrates additively: the album list is derived from
|
||||
/// the track window exactly as the old reader did; the next write persists
|
||||
/// the new object shape.
|
||||
fn read_store() -> RecentStore {
|
||||
let Some(path) = store_path() else {
|
||||
return 0;
|
||||
return RecentStore::default();
|
||||
};
|
||||
let mut list = load();
|
||||
let before = list.len();
|
||||
list.retain(|t| !album_ids.iter().any(|k| k == &t.album_id));
|
||||
let removed = before - list.len();
|
||||
if removed > 0 {
|
||||
if let Ok(json) = serde_json::to_vec_pretty(&list) {
|
||||
let _ = std::fs::write(&path, json);
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
return RecentStore::default();
|
||||
};
|
||||
if let Ok(store) = serde_json::from_slice::<RecentStore>(&bytes) {
|
||||
return store;
|
||||
}
|
||||
removed
|
||||
let tracks: Vec<RecentTrack> = serde_json::from_slice(&bytes).unwrap_or_default();
|
||||
let albums = derive_albums(&tracks);
|
||||
RecentStore { tracks, albums }
|
||||
}
|
||||
|
||||
/// Record a played track at the front of the list. Deduplicates by id and
|
||||
/// caps the list at [`MAX_RECENT`]. Called by the playback session when a
|
||||
/// track starts.
|
||||
#[allow(dead_code)] // wired by the playback session
|
||||
pub fn record(track: RecentTrack) {
|
||||
/// Persist the whole store (pretty JSON, best-effort with logged warnings).
|
||||
fn write_store(store: &RecentStore) {
|
||||
let Some(path) = store_path() else {
|
||||
return;
|
||||
};
|
||||
let mut list = load();
|
||||
list.retain(|t| t.id != track.id);
|
||||
list.insert(0, track);
|
||||
list.truncate(MAX_RECENT);
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
log::warn!("[qbz-slint] recently-played store dir failed: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
match serde_json::to_vec_pretty(&list) {
|
||||
match serde_json::to_vec_pretty(store) {
|
||||
Ok(json) => {
|
||||
if let Err(e) = std::fs::write(&path, json) {
|
||||
log::warn!("[qbz-slint] recently-played write failed: {e}");
|
||||
@@ -202,3 +216,66 @@ pub fn record(track: RecentTrack) {
|
||||
Err(e) => log::warn!("[qbz-slint] recently-played serialize failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the recently-played tracks, newest first. Returns an empty list
|
||||
/// when the store does not exist yet or cannot be read.
|
||||
pub fn load() -> Vec<RecentTrack> {
|
||||
read_store().tracks
|
||||
}
|
||||
|
||||
/// Recently-played albums, newest first, from the dedicated album history
|
||||
/// (legacy stores derive it from the track window once — see [`read_store`]).
|
||||
pub fn load_albums() -> Vec<RecentAlbum> {
|
||||
read_store().albums
|
||||
}
|
||||
|
||||
/// Remove every recently-played entry whose `album_id` is in `album_ids`.
|
||||
/// Used when a Local Library folder is deleted so its albums/tracks no longer
|
||||
/// linger in Recently Played. Prunes BOTH the track and the album histories.
|
||||
/// Returns how many track entries were removed.
|
||||
pub fn prune_albums(album_ids: &[String]) -> usize {
|
||||
if album_ids.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let mut store = read_store();
|
||||
let tracks_before = store.tracks.len();
|
||||
let albums_before = store.albums.len();
|
||||
store.tracks.retain(|t| !album_ids.iter().any(|k| k == &t.album_id));
|
||||
store.albums.retain(|a| !album_ids.iter().any(|k| k == &a.id));
|
||||
let removed = tracks_before - store.tracks.len();
|
||||
if removed > 0 || albums_before != store.albums.len() {
|
||||
write_store(&store);
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Record a played track at the front of the track history (dedup by track
|
||||
/// id, capped at [`MAX_RECENT`]) and its album at the front of the album
|
||||
/// history (dedup by album id, capped at [`MAX_RECENT_ALBUMS`]). Called by
|
||||
/// the playback session when a track starts.
|
||||
#[allow(dead_code)] // wired by the playback session
|
||||
pub fn record(track: RecentTrack) {
|
||||
let mut store = read_store();
|
||||
if !track.album_id.is_empty() {
|
||||
store.albums.retain(|a| a.id != track.album_id);
|
||||
store.albums.insert(
|
||||
0,
|
||||
RecentAlbum {
|
||||
id: track.album_id.clone(),
|
||||
title: track.album_title.clone(),
|
||||
artist: track.album_artist.clone(),
|
||||
artwork_url: track.album_artwork_url.clone(),
|
||||
quality_tier: track.quality_tier.clone(),
|
||||
quality_label: track.quality_label.clone(),
|
||||
genre: track.genre.clone(),
|
||||
release_date: track.release_date.clone(),
|
||||
source: track.source.clone(),
|
||||
},
|
||||
);
|
||||
store.albums.truncate(MAX_RECENT_ALBUMS);
|
||||
}
|
||||
store.tracks.retain(|t| t.id != track.id);
|
||||
store.tracks.insert(0, track);
|
||||
store.tracks.truncate(MAX_RECENT);
|
||||
write_store(&store);
|
||||
}
|
||||
Reference in new issue
Block a user