MusicBrainz was hardcoded ON in the core client with no toggle, no
persistence and no startup wiring, so it could never be turned off. Add a
persisted opt-out (default ON: ui_prefs.musicbrainz_enabled +
Settings>Integrations toggle) that seeds the core client at startup and
flips it live via handle_bool.
Gate the MB-dependent surfaces when off: the artist Network/Scene sidebar
marks itself unavailable and opens on the MB-independent Magazine tab;
the playlist "Suggest songs" CTA hides and activate() early-outs; and the
musician page tolerates a disabled/failed MB resolve, falling through to
the Qobuz fallback instead of rendering blank.
cc
The heart on a Qobuz playlist detail was wired to Qobuz /favorite/create,
which does not accept playlist_ids, so it persisted nowhere and never
reflected in the library. Rewire it to the INTERNAL library favorite
(db.set_playlist_favorite, mirroring the Favorites card + Tauri's
v2_playlist_set_favorite) and seed it on open.
Add the two missing actions on a non-owned Qobuz playlist:
- Copy to library: clone it (create + add all tracks, carrying the source
artwork + attribution) as an internal owned copy.
- Follow on Qobuz: subscribe/unsubscribe via new qbz-core passthroughs to
the existing client subscribe_playlist/unsubscribe_playlist.
Surface hearted-but-not-owned playlists in Library>Playlists by merging
the internal favorite ids into the manager list (they don't come back
from get_user_playlists). Copies/follows already appear there (owned /
subscribed). New PlaylistState.is-following / is-copied.
cc
When the queue runs out with InfiniteRadio autoplay on, build a smart
artist radio seeded by the just-finished track and start it, so playback
continues with related music and chains radio -> radio. The toggle, the
AutoplayMode preference and the radio primitives already existed; only
the trigger and the immersive button were missing.
Wire the trigger at the auto-advance queue-end (naturally peer-gated) and
enable the immersive infinity button (binds QueueState.infinite-play +
toggle-infinite-play). Add QueueController::is_infinite_play().
Tauri appends the radio and retries next(); that would replay the old
queue here because QueueManager::next() nulls current_index at the edge,
so this starts the radio fresh via play_tracks to reach the same intended
behavior. Manual-next refill is deferred (needs an explicit peer gate).
cc
Qobuz /radio/album and /radio/track return a tracks page that omits
`total`; the strict SearchResultsPage<Track> derive rejected it with
"missing field `total`", breaking AlbumView and Discover For You radio.
Port Tauri's lenient_page_flexible into qbz-models and apply it to
RadioResponse.tracks (now non-optional, matching Tauri); derive Default
on Track so the absent-tracks branch resolves to an empty page. Two new
qbz-models tests cover the missing-total and absent-tracks cases.
Wire ArtistView's previously-dead radio dropdown: both items now carry
ArtistState.id and have handler arms -- "QBZ Radio" -> smart pool
builder, "Qobuz Radio" -> the plain /radio/artist endpoint
(play_artist_radio, no longer dead_code). Add a "QBZ track radio"
track-menu item backed by a new play_smart_track_radio that fetches the
track to seed its performer.
cc
Surface the new Recommendations tab everywhere Discover sub-sections are listed,
and make it opt-out.
- Flyouts: the sidebar section-nav flyout and both header nav flyouts (full text
+ compact icon) now list "Recommendations" alongside Home / Editor's Picks /
For You. The Rust route handler already maps "discover-recommendations"
(strip_prefix + select_tab + ensure_loaded), so no routing change was needed.
- Opt-out: a show_recommendations flag (default on) persisted in the per-user
discover-prefs store (qbz_app), seeded into SettingsState on session start.
Settings > Integrations gains a "Show Recommendations in Discover" toggle whose
description nudges connecting Last.fm + ListenBrainz (MusicBrainz is used
automatically to match releases). Off => the tab pill and every flyout entry
disappear.
- Gating uses two mounted instances per flyout (one with the entry, one without)
because Slint can't type-infer [NavMenuEntry] from a ternary of array literals;
the HomeView pill uses a plain if.
cargo check -p qbz green.
cc
Owner-directed polish after testing the Recommendations tab.
- Full albums only: drop singles/EPs/boxsets/compilations from every album row
(Recommended Albums, Fresh Releases, Deep cuts). Qobuz's `release_type` is the
source of truth ("album" | "single" | "boxset" | "compilation"); when absent,
fall back to tracks_count >= 5. Kills the karaoke single that showed up under
Fresh Releases.
- Slop second layer: reject karaoke / tribute / "made famous by" / "in the style
of" / instrumental-version releases by artist-or-title keyword, applied both
pre-resolution (candidates) and post-resolution (the Qobuz album), so AI-slop
that still wears a full-album shape gets filtered.
- Taller rows in this view only: AlbumCard/ArtistCard/Carousel/ArtistCarousel
grow an overridable `card-height` (defaults unchanged, so Home/Search are
untouched); ExternalRecoView passes 266 to album rows and 256 to artist rows,
whose cards carry the "Similar to…" subtitle (~250px) and were clipping the
Follow chip at the old fixed 220.
cargo check -p qbz green.
cc
Owner-directed polish on the Recommendations tab to make it richer + faster +
non-repetitive.
- Common vs Recent split: the single "Recommended Artists" row over-represented a
recent soundtrack binge (a live preview showed "The Newton Brothers" flooding
the row with 8 film composers while the core metal was buried). Split into two
rows seeded from DIFFERENT periods: "More like the artists you love" (overall
top) and "Based on what you've been into lately" (1-month top) — so recent
one-offs never pollute the core taste. = 7 rows now.
- Round-robin per seed inside each artist row (re-added from the v1 engine; the
rework had dropped it) + per-seed cap, so no single seed floods the carousel.
- Results cache (48h TTL): caches the FULL built rows (serialized
ExternalCarousels) per-user, keyed by connected sources. Tab open within 48h =
INSTANT paint from cache, zero fetch; stale/missing = build + store. Rebuilds
every 48h (fresh rotation) so it's never "eternally the same" and opens stop
being slow. Distinct from the rec->Qobuz resolution cache (30d/7d).
- Toast on a real fetch (cache miss/stale): "Generating recommendations…" so the
wait is communicated; the skeleton stays; no toast on a cache hit.
cargo check -p qbz green (6m44s).
cc
Reworks the 4th Discover tab per owner feedback (the track-heavy v1 surfaced
already-heard artists). Verified against three sources that Last.fm has NO
recommendations API (getRecommendedArtists removed; web is PJAX HTML behind the
website cookie, which the API session key cannot reach) — so recommendations are
replicated from the documented similarity recipe, and ListenBrainz provides the
genuinely clean personalized feeds.
New lineup (each row paints progressively as its builder resolves):
- Recommended Artists (Last.fm artist.getSimilar seeded by last-month top, minus
the heard set; "Similar to X, Y" subtitle on the artist card).
- Recommended Albums (Last.fm artist.getTopAlbums minus your scrobbled albums).
- Fresh Releases (ListenBrainz, from artists you follow).
- Weekly Exploration + Weekly Jams (ListenBrainz curated playlists, by source_patch).
- Deep-cut albums from artists you know (Qobuz catalog, not heard in-app).
- Cold-start fallback: Qobuz editorial top albums + artists.
The "not heard" filter is now ARTIST-level (the v1 bug derived artists from
unheard TRACKS) and compares against the LOCAL reco history + a light Last.fm/LB
top-set, not a full history sweep. Album validation (UPC -> fuzzy text) added.
qbz-integrations gains Last.fm get_artist_top_albums/get_user_top_albums and
ListenBrainz get_created_for_playlists/get_playlist_tracks/get_fresh_releases.
NOTE: the Cloudflare proxy was switched to allow ALL Last.fm read methods
(anything with ".get"), so future reads need NO proxy redeploy — but the live
proxy must be redeployed ONCE with this change or all Last.fm reads keep 400ing.
cargo check -p qbz green.
cc
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
The Slint UI for the artist_vectors suggestions, 1:1 with the Svelte
PlaylistSuggestions component (backend T1-T7 already landed):
- T8: PlaylistSuggestionsState/Actions globals (distinct from the immersive
SuggestionsState) + a controller (crates/qbz/src/playlist_suggestions.rs)
that holds the pool/pagination/dedup and calls core.generate_playlist_suggestions
on a spawned task; wired into main.rs; per-row cover artwork target; suggestions
reset on playlist navigation.
- T9: the Suggested Songs section in PlaylistView.slint (docked after the
virtualized track list), manual activation via a header wand-sparkles button,
per-row Add/Play/info/dismiss + loading/empty states; a Close (x) button hides
the panel (activated is in-out).
- T10: per-playlist dismissed-track store (playlist_suggestions_dismiss.rs),
filtered out of the pool.
- i18n keys added to es/de/fr/pt.
Owner-built and verified working.
cc
Slint cannot split the .slint compilation across crates, but a Slint LIBRARY
crate exposes its generated Rust types to dependent crates (verified with a
minimal proof). So:
- NEW crate `qbz-ui` (lib): owns the whole .slint tree + build.rs + bundled
translations; `slint::include_modules!()` + the generated types (AppWindow,
MiniPlayerWindow, all globals/structs) are re-exported. Translation domain is
now `qbz-ui` (the .po files renamed accordingly).
- `qbz-slint` (bin) -> renamed to `qbz` (package + `[[bin]] name = "qbz"`),
reclaiming the binary name from the retired Tauri app. It depends on `qbz-ui`
and does `pub use qbz_ui::*;` so existing `crate::AppWindow` / `crate::*State`
references resolve unchanged.
This splits the single ~20 GB rustc into two crates (UI/codegen vs the 75k LOC
of logic) — the foundation for lower build peak, incremental caching, and the
upcoming per-feature crate extraction.
Also fixed for the move: qbz-i18n bundled-.po include paths, the font/WGSL
`include_bytes!`/`include_str!` paths (-> ../../qbz-ui/ui/), workspace members,
dev scripts (`-p qbz`), and the CI workflows. `cargo check -p qbz` is green.
cc