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:
vicrodh committed 2026-07-08 11:46:57 -06:00
1 parent 99a2079df1
commit 338d20df9b
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![
+57
View File
@@ -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
+9
View File
@@ -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: [];
+34
View File
@@ -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) {
+38 -3
View File
@@ -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
View File
@@ -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);
+26
View File
@@ -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
View File
@@ -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);
}