Adds qbz_core::offline_resolve::resolve_offline_bytes — the OFFLINE tier of
the playback tier-walk, frontend-agnostic and player-untouched (the user's
locked Slice-1 decision): resolve a track's offline-cached bytes by Qobuz id
(v2 CMAF decrypt via the secret vault, optionally emitting UnlockStart/End
through a sink; v1 plain-FLAC read; None on miss).
qbz-core now depends on qbz-offline-cache (aligns with the long-intended
`offline` feature seam). Both frontends will slot this in before the network
tier: Tauri keeps its hardware/quality gate around the returned bytes; Slint
calls it before Player::play_track. Player's L1/L2 + network path is unchanged.
Next: QbzCore compose methods (play_track_resolved / gapless), wire Slint's
session to construct + own an OfflineCacheState, route the Slint play path
through the resolver, and point Tauri's offline branch at it.
cc
Connects the previously-inert track context-menu items now that the
backends exist:
- Add to library -> add_favorite("track").
- Go to album / Go to artist -> resolve the track (get_track) to its
album / performer and navigate there.
- Create radio -> Qobuz track radio (new get_radio_track core wrapper
over the existing qbz-qobuz endpoint + playback::play_track_radio).
- Share Qobuz link -> https://www.qobuz.com/track/{id} to clipboard
(new arboard dep + share module).
- Share Song.link -> Odesli (api.song.link) pageUrl resolution to
clipboard.
- Add to playlist -> a global picker modal (PlaylistPickerModal +
PlaylistPickerState/Actions + playlist_picker controller) listing
the user's playlists; picking one calls add_tracks_to_playlist.
Add to mixtape / Make available offline / Track info stay unwired
(no backend yet).
cc
The For You Qobuz Mixes tiles now open functional mix detail pages.
- New MixView: header (mix name + description + Play all) over the
mix's track list (reusing SearchTrackRow). Clicking a track plays
the whole mix from there; Play all starts at the top.
- crate::mix resolves each mix to a track list from available
backend: DailyQ/WeeklyQ -> /dynamic/suggest seeded from the local
play-history track ids; FavQ -> favorite tracks shuffled (xorshift,
no rng dep); TopQ -> tracks aggregated from the user's playlists.
The loaded list is cached so play-all / play-from-track build the
queue without re-fetching.
- Backend: ported /dynamic/suggest into qbz-qobuz
(QobuzClient::get_dynamic_suggest — POST, items parsed leniently
into Vec<Track>) + QbzCore::get_dynamic_suggest.
- ContentView.mix + NavEntry::Mix { kind } so each mix is a history
entry; the tile click records + navigates. New MixTrack artwork
target. playback::play_tracks generalizes the radio queue builder
with a start index (radio reuses it at 0).
Tauri's exact generation (listened-track analysis payloads, playlist
play-stats ranking) is approximated; same surfaces + playback, sourced
from the backend the MVP has.
cc
Ports the Tauri radio_engine into a standalone, frontend-agnostic
crate so any client (Slint, TUI, CLI) can build and play a smart
radio without Tauri.
- New crate qbz-radio (builder + db + engine + tests), copied
verbatim from src-tauri/radio_engine with the only change being
the dependency rewrite: crate::api::{QobuzClient, Track,
TracksContainer} -> qbz_qobuz::QobuzClient + qbz_models. Depends
only on qbz-qobuz + qbz-models + rusqlite + dirs. All four engine
tests pass.
- QbzCore::create_smart_artist_radio builds the session pool + pulls
up to 50 track ids + resolves them to full tracks. The radio DB's
rusqlite Connection is !Send, so the build + pull run on
spawn_blocking threads (the async builder driven via block_on),
mirroring the Tauri command.
- The artist "radio" action (Spotlight RADIO card + artist radio)
now uses the smart builder (playback::play_smart_artist_radio);
album radio (Radio Stations) keeps the Qobuz /radio/album
endpoint, so both radio systems are in use. play_artist_radio
(Qobuz /radio/artist) is retained as the simpler alternative.
cc
Completes the remaining For You sections by porting two Qobuz API
endpoints down into qbz-core and adding the local "forgotten
favorites" approximation.
Backend ports (qbz-models + qbz-qobuz + qbz-core):
- /album/suggest -> AlbumSuggestResponse, QobuzClient::get_album_suggest,
QbzCore::get_album_suggest.
- /radio/artist + /radio/album -> RadioResponse, get_radio_artist /
get_radio_album on the client + core. (Faithful ports of the legacy
src-tauri/api/client.rs methods — same paths, params, shapes.)
For You sections:
- Radio Stations — album-seeded radio tiles (recent + favorite
albums, deduped, capped 12), rendered with RadioCard; click starts
a real Qobuz album radio.
- More From Your Library — /album/suggest seeded from the most recent
played album (else first favorite).
- Rediscover Your Library — favorite albums not in the local
play-history (approximation of Tauri's reco-DB forgotten
favorites).
- Spotlight's RADIO card + the artist "radio" action now create a
real Qobuz artist radio (playback::play_artist_radio /
play_album_radio build a queue from the radio track list and play).
Section order now matches Tauri: Qobuz Mixes, Release Watch, Radio
Stations, Recently Played Tracks/Albums, Your Top Artists, More From
Your Library, Rediscover, Artists to Follow, Spotlight.
Still backend-blocked: the Qobuz mix detail views (DailyQ/WeeklyQ/etc.)
and the smart radio_engine (the Qobuz radio used here is the simpler
/radio/* endpoint, not the local pool builder).
cc
Ports the core of v2_discover_artists_by_location to QbzCore,
unblocking the Slint ArtistsByLocationView. Reuses the affinity
helpers already living in qbz-integrations (compute_affinity_score,
extract_affinity_seeds, is_broad_genre, genre_summary).
Pipeline:
- Smart area resolution — resolve a city area_id to its parent
subdivision via resolve_parent_subdivision for broader results
(Leyton → England), falling back to the raw area name.
- Drop overly broad genre tags (rock / pop / latin) from the
search set so the query doesn't return the whole country's
catalog; fall back to the raw list only if everything was
broad.
- MB tag+area search per genre, dedupe candidates by mbid,
accumulate affinity score + a multi-genre-hit bonus.
- Sort by score (stable by mbid for paginated calls), page with
offset/limit, validate each against Qobuz (exact normalized
name match, most-albums wins for collisions).
Deferred vs the Tauri command: the scene cache (get/set) and the
`scene-discovery-progress` events. The pure discovery result is
identical otherwise.
cc
Two new QbzCore methods unblocking the MusicianPageView port — both
direct ports of v2_resolve_musician and v2_get_musician_appearances
from src-tauri/commands_v2/integrations.rs.
musicbrainz_resolve_musician(name, role):
- MB client resolve_artist for the MBID, then a Qobuz artist
search for an exact name match. Confirmed when the Qobuz
artist matches; Contextual when there are appears-on albums
only; Weak when MB knows the artist but Qobuz has nothing;
None otherwise.
- The sidebar's musician-clicked dispatcher uses the confidence
to decide whether to route the click into the artist page
(Confirmed) or into MusicianPageView (Contextual / Weak).
musicbrainz_get_musician_appearances(name, role, limit, offset):
- Wraps QbzCore::search_albums with the same per-row mapping
Tauri does: album_id, title, artwork (large/small fallback),
artist name, year, and the caller's role string stamped onto
each row as `role_on_album`. Returns
`MusicianAppearances { albums, total }`.
cc
Ports the minimum of src-tauri/src/reco_store needed to honour the
"skip artists I already listen to" gate in the discovery pipeline.
play_history (`crates/qbz-slint/src/play_history.rs`):
- Tiny SQLite store at <data-dir>/qbz/play_history.db with WAL
(ADR-002), holding play_events (artist_id + occurred_at, one row
per audible track start) and artist_names (id -> name upsert).
Lazy open, errors collapsed to log::warn so a missing DB just
means an empty filter — same default Tauri lands on a fresh
profile.
- record_play(artist_id, name) wired into playback::record_recent
so every audible track start increments the per-artist count.
- known_artists(threshold) returns the (HashSet<u64>,
HashSet<String>) pair Tauri's filter expects — qobuz ids for
suppressing validated matches, normalized names for suppressing
MB candidates upstream.
qbz-core::musicbrainz_discover_artists:
- New `known_artists` closure parameter, called once per
invocation. Threshold matches Tauri (>2 plays).
- Filter applied at both stages: MB candidates dropped when their
normalized name is in known_names; Qobuz matches dropped when
their id is in known_ids (mirrors local_known_qobuz_ids check
in v2_get_discovery_artists).
The Tauri reco_store has many other features (favorites, top
genres, home seeds) the Slint shell does not use yet — those stay
out of this commit; only the play-count surface needed by the
sidebar's "You may also like" filter is ported.
cc
QbzCore gains an Arc<Mutex<Option<MusicBrainzCache>>> field plus a
`set_musicbrainz_cache(cache)` setter so the frontend (which owns
the data-dir path) can install the cache after construction.
musicbrainz_get_artist_metadata and musicbrainz_get_artist_relation-
ships now check the cache before fetching and persist successful
results on the way out — mirrors the v2_musicbrainz_get_artist_*
behavior in src-tauri/src/commands_v2/integrations.rs. Missing cache
degrades gracefully: methods skip the lookup and the persist hook
silently when no cache is set.
qbz-slint opens the cache at startup at <data-dir>/qbz/cache/
musicbrainz_cache.db (same path Tauri uses) and hands it to the
core. Failure to open the directory or the SQLite file just falls
back to direct network calls — a warn line in the log surfaces it,
but the sidebar still works.
cc
Closes the network sidebar with the fifth section — tag-based "You may
also like" discovery, with a per-tag dismiss store so a thumbs-down
sticks across artist pages that share the same primary genre tag.
Backend (`qbz-integrations` + `qbz-core`):
- DiscoveryArtist and DiscoveryResponse DTOs added to
qbz-integrations::musicbrainz::models.
- QbzCore::musicbrainz_discover_artists ports
v2_get_discovery_artists end to end (sans the RecoState local
"play count > 2" filter, which has no equivalent in the Slint
shell yet): primary-tag search, exclude seed + similar +
dismissed, deterministic shuffle keyed on seed_mbid, validate
each candidate on Qobuz, fall back to the secondary tag if we
have fewer than 5 matches.
- normalize_artist_name re-exported at the qbz-core crate root so
the dismiss handler in qbz-slint can use the same key shape that
the discovery pipeline filters on.
- Deterministic shuffle implemented with a simple xorshift PRNG
seeded from a stable hash of seed_mbid — keeps qbz-core free of
the rand dep.
Slint controller (`crates/qbz-slint/src/`):
- discovery_dismiss.rs — small JSON store at
`<data-dir>/qbz/discovery_dismiss.json` keyed by lowercased tag
-> list of normalized artist names. dismissed_for_tag is passed
into the discovery call as the filter source; dismiss() is
invoked by the thumbs-down handler.
- artist::load_mb_discovery / apply_mb_discovery /
remove_discovery_artist plumb the pipeline result through the
standard load-off-thread / apply-on-UI split. Discovery state
includes primary_tag so the dismiss handler can persist under
the right key.
- main.rs chains discovery after relationships in the same MB
enrichment task. NetworkSidebarActions.discovery-dismissed (now
carrying mbid + name) normalizes the name, persists the
dismiss, and removes the row from the visible list.
Slint UI (`ui/state.slint`, `ui/artist/ArtistPageView.slint`):
- discovery-tag property added to NetworkSidebarState.
- YOU MAY ALSO LIKE section after Relationships — one row per
candidate (SidebarLink + a 24x24 thumbs-down button on the
right). Clicking the link navigates via the artist-clicked
callback when a Qobuz id is present.
- thumbs-down.svg icon added to ui/assets/icons (Lucide
ThumbsDown).
cc
Adds qbz-integrations as a dependency of qbz-core and embeds a
MusicBrainzClient on the QbzCore struct (initialized in new()). The
client's own enabled flag governs whether requests fire; calls return
the integration-layer ServiceUnavailable error when disabled.
New methods on QbzCore<A>:
musicbrainz_is_enabled()
musicbrainz_set_enabled(enabled)
musicbrainz_resolve_artist(name) -> Option<ResolvedArtist>
musicbrainz_get_artist_metadata(mbid) -> ArtistMetadata
musicbrainz_get_artist_relationships(mbid) -> ArtistRelationships
The metadata method calls location::extract_metadata on the full artist
response and then resolves the real country from the begin_area
hierarchy when a city-level location was found (MB's "country" field is
where the artist is currently active, not where they were born/formed).
The relationships method splits relations into members / past_members /
groups / collaborators by relation_type and direction, mirroring
v2_musicbrainz_get_artist_relationships exactly.
No cache wiring yet — every call hits the network. A cache wrapper can
be plugged in later via a setter without changing the method
signatures.
cc
- Most popular: a crowned hero inline with the artists carousel; its
cover now resolves (a dedicated artwork target was missing).
- Artists: a real carousel on the All tab, ArtistCard restyled to the
Tauri For-You tile with a Follow chip (favorites the artist) shown
when the user does not follow it.
- Playlists: a PlaylistCarousel of PlaylistCard, with a count-based
PlaylistCollage (1/2/3/4-up) instead of a flat row list.
- Carousels gained a View all link; result counts removed from titles.
- searchType filter is a styled radio row (ring + accent dot) at the
top right with a clear button, not a dropdown.
- Title fixed to Search Results.
- QbzCore::favorite_artist_ids backs the follow state.
cc
search_all wraps catalog_search, parses the four category pages and the
most_popular hero, and drops blacklisted artists. The blacklist is a
parameter (BlacklistFilter) so Search does not depend on the un-migrated
artist_blacklist module.
cc
QueueManager::get_state_full() returns the upcoming and history lists
without the 20/10 caps that get_state() applies, so clients can
paginate the upcoming list. QbzCore exposes get_queue_state_full()
plus favorite_track_ids() and set_track_favorite() for reflecting and
toggling per-track favorite state.
cc
Bundle token extraction requires the network. When the host is fully
offline at startup, the failure used to propagate out of CoreBridge::new
and leave the bridge as None, which made every queue/playback V2 command
panic at `bridge.get().expect("CoreBridge not initialized")`.
QbzCore::init() now treats bundle extraction as best-effort: on failure
the core still finishes initialization, the queue manager and player stay
usable for local/offline playback, and API methods return
CoreError::NotInitialized cleanly instead of crashing the runtime. Adds
is_api_initialized and try_init_api so callers can rebuild the API
client lazily once the network returns.
cc
Path C: legacy command called client.get_releases_grid for the
artist detail view's release-type pagination grid. No existing
v2 equivalent, so add v2_get_releases_grid via QbzCore ->
QobuzClient::get_releases_grid directly.
cc
Path C: legacy command called client.get_artist_tracks (the
/artist/get?extra=tracks path) and returned a TracksContainer.
No existing v2 equivalent, so add v2_get_artist_tracks via
QbzCore -> QobuzClient::get_artist_tracks directly.
cc
Path C: legacy command called client.get_artist_detail with the
hard-coded mobile defaults of limit=1000, offset=0 to populate the
suggestions panel's playlists and tracks_appears_on. No close v2
equivalent existed (v2_get_artist_with_albums uses
extra=albums only), so add v2_get_artist_detail that goes through
QbzCore -> QobuzClient::get_artist_detail directly.
cc
Path C: legacy command projected `Artist.albums` from
client.get_artist_with_pagination(with_albums=true). The existing
v2_get_artist_with_albums returns the full Artist envelope, so
add a dedicated v2_get_artist_albums in QbzCore that performs the
same projection and exposes only ArtistAlbums to match what the
two callsites destructure.
cc
Add audio_cache_l1_max_bytes to MemoryProfile and wire it into
AppState's AudioCache constructor. Normal hosts keep the historical
400 MB cap; LowMemory hosts drop to 50 MB.
Without this, a Pi 3B (1 GB total) starts with the L1 cache cap set
at ~40 % of physical RAM. Even with the Feature #3 prefetch caps
already in place, the runtime watchdog from the previous commit
ends up firing more often than necessary because nothing prevents
a single L1 fill cycle from claiming 400 MB. Capping at 50 MB caps
how much the cache can grow before either: (a) eviction picks up
naturally, or (b) the watchdog steps in if the rest of the system
is also under pressure.
Pure refactor in lib.rs — replaces a hard-coded 400 * 1024 * 1024
with a profile field read. The default-constructed AudioCache
fallback (when disk-spillover init fails) keeps its own crate
default; no need to thread the profile there since the failure
path is rare.
Two tests added: a presence-bound assertion in the existing Pi 3B
case, and an explicit comparison that LowMemory's cap is strictly
smaller than Normal's.
cc
Extend qbz-core's system_capabilities with a parse_meminfo_available_kb
helper plus a small MemoryPressure value object that classifies the
host's current available memory against thresholds derived from
MemoryProfile. The watchdog (src-tauri side, next commit) polls this
to decide when to evict caches.
Thresholds intentionally simple for v1: is_low triggers under 15 %
available, is_critical under 5 %. Both are strict-inequality so the
exact-percent boundaries don't flap.
The pure pressure_from_figures(avail_kb, total_kb) factors out the
math from the /proc read so threshold behavior is tested without
touching the filesystem. Refactors the existing
parse_meminfo_total_kb to share a parse_meminfo_field_kb helper with
the new available-memory parser.
Adds 9 tests covering the parser, the threshold boundaries, the zero-
total divide-safety guard, and the codehd7 issue #331 scenario
(Pi 3B, ~25 MB available out of 938 MB → critical).
cc
New module in qbz-core that probes /proc/meminfo at startup and
classifies the host into Normal (>= 2 GB RAM) or LowMemory (< 2 GB)
buckets, then derives runtime caps for memory-sensitive subsystems:
prefetch_count, max_initial_buffer_bytes, max_concurrent_prefetch,
allow_hires_prefetch.
Detection is split into a pure parse_meminfo_total_kb +
detect_profile_from_meminfo pair (testable with synthetic input)
and a thin detect_profile wrapper that reads the file. The
process-wide profile is cached in a OnceLock and logged once at
first access, so subsequent callers pay zero filesystem cost.
The Normal-fallback path covers macOS, Windows, and any Linux where
/proc/meminfo is missing or unparseable — we never accidentally
throttle a system whose memory we couldn't measure.
This commit only ships the module; wiring into the prefetch loop
and streaming buffer follows in the next commits.
Adds 10 unit tests covering the parser, the threshold boundary
(exactly 2 GB → Normal, just under → LowMemory), the Pi 3B and
Pi Zero 2W cases that motivated this work (#331), and the
unparseable-input fallback.
cc
QueueManager::clear previously always preserved the current track;
plus bridge.play_index only mutates queue state, never triggers
audio. Result: when the user hit Clear Queue with nothing playing,
a stale track stayed in NOW PLAYING across app restarts.
- QueueManager::clear(keep_current: bool) — existing callers pin to
true (legacy behavior). New test covers the false path.
- QbzCore::clear_queue / CoreBridge::clear_queue take the flag through.
- v2_clear_queue accepts an optional includeCurrent flag from the
frontend; when true, calls bridge.stop() first so the audio pipeline
actually stops, then wipes the current slot too.
- qconnect sync path + qbzd daemon pin keep_current=true (no behavior
change).
cc
/label/get is gone from the Qobuz v9.7.0.3 Android app (source:
c30/b.java doesn't declare it anymore). With the Qobuz beta rollout
the server-side deprecation timer is probably running, so we yank our
dependency now.
Removed:
- LABEL_GET path constant (qbz-qobuz + src-tauri/api)
- get_label() client method (qbz-qobuz + src-tauri/api)
- get_label() wrapper in QbzCore + CoreBridge
- v2_get_label tauri command + its lib.rs registration
- LabelDetail backend type (qbz-models + src-tauri/api/models)
- commands::search::get_label (dead, was never registered)
- Unused LabelDetail import in src/routes/+page.svelte
Preserved:
- Frontend LabelDetail interface (it's a UI view-model shaped by
LabelReleasesView, not a backend contract)
- /api/labels/{id} HTTP route on qbzd — now composes /label/page +
/label/getAlbums internally so external HTTP clients still see the
same { id, name, description, image, albums: { items, total, ... } }
shape
cc
Closes the gap between our client and the Qobuz Android app's current
label API (source: c30/b.java). Qobuz is rolling out a web beta so the
legacy /label/get is likely on borrowed time — adding the v9.7.0.3
endpoints now so the migration can happen in follow-up commits.
New endpoints (paths + client + core + bridge + v2 command + registered):
- /label/getAlbums → v2_get_label_albums
paginated catalog with sort/order/genre_ids/from_date/to_date
- /label/getNextReleases → v2_get_label_next_releases
- /label/getAwardedReleases → v2_get_label_awarded_releases
- /label/getPlaylists → v2_get_label_playlists
- /label/getTopArtists → v2_get_label_top_artists
- /label/story → v2_get_label_story
- /label/getList → v2_get_label_list (POST, mirrors
track/getList signing)
New response types in qbz-models:
- LabelListPage<T> — generic { has_more, items: [T], total?, offset?,
limit? } envelope, tolerant to either V2 list or SearchResultsPage
shapes until samples land
- LabelStoryResponse, LabelGetListResponse — best-effort with optional
fields so a shape mismatch is non-fatal
The legacy /label/get + get_label() + v2_get_label + LabelDetail are
retained untouched for now; frontend migration and deletion come next.
cc
The frontend was resolving queue-panel clicks via
`actualIndex = current_index + trackIndex`, mixing the unshuffled
canonical position with the shuffle-aware display position. On shuffle
mode, the resulting index pointed at a different track than the one
the user clicked, which is exactly what issue #327 reports.
Solution:
- New `QueueManager::play_upcoming_at(upcoming_index)` resolves the
click position through `shuffle_order` + `shuffle_position` when
shuffle is on, and falls back to `current_index + upcoming_index + 1`
otherwise.
- Exposed through `QbzCore::play_upcoming_at` (emits QueueUpdated) and
`CoreBridge::play_upcoming_at`.
- New `v2_play_queue_upcoming_at` tauri command.
- `QueuePanel.onPlayTrack` now carries the `originalIndex` so the
caller knows the exact upcoming-list position the user clicked.
- `handleQueueTrackPlay` calls the new command directly — no more
client-side index arithmetic.
Also deletes the two dead `From<crate::queue::QueueTrack>` impls in
commands_v2/queue.rs (canonical queue lives in qbz-player now), per
the zero-legacy V2 policy.
Tests: two new unit tests cover the linear and shuffle-aware paths.
Session persistence is untouched — the new method only consults
existing state.
cc
/album/get's AwardDto has id: String? — Qobuz sometimes returns an
award entry with only a name and no id. Rather than disabling the
click or toasting, we look up the id in the Qobuz award catalog.
- Adds v2_get_award_explore (paginated /award/explore) all the way
through qbz-qobuz → core → bridge → command → invoke_handler.
- New frontend awardCatalogStore caches the full (id, name) list
lazily, one fetch per session, resolved on first click that needs
it. Further clicks are instant.
- handleAwardClick goes straight to navigation when id is present,
falls back to resolveAwardIdByName otherwise. Toast only fires if
the name isn't in the catalog either (genuinely unknown award).
cc
Backend plumbing for Phase 3 AwardView. New endpoints:
- v2_get_award_page(awardId) → hero + categorized releases
- v2_get_award_albums(awardId, …) → paginated album list
Mirrors Android's AwardDto shape (id, name, image, awarded_at,
magazine, releases, playlists). Follows the label/page pattern for
signing and error handling. Deserializer accepts the V2 generic
{has_more, items} envelope as well as the legacy search-style
{albums: {items, total}} in case Qobuz swaps shapes per account.
cc
The inferred OpenAPI spec
(qbz-nix-docs/qobuz-api-inferred-openapi-v9.7.0.3.yaml, source:
x20/b.java) documents the endpoint as /favorite/getNewReleases with
three required query params:
@GET favorite/getNewReleases
- type: String (artists | labels | awards — matches the three
tabs in the mobile UI)
- limit: Integer
- offset: Integer
My earlier attempts at /albums/releaseWatch were chasing the Android
route-table entry for the Release Watch *screen*, not the HTTP call
that screen makes. Different layer.
Also removes my made-up speculation about mobile app_id gating — the
/favorite/* endpoints are standard web-API calls the existing client
already knows how to sign (via authenticated_headers + /0.2/ base URL).
v2_get_release_watch now takes an optional releaseType arg defaulting
to 'artists' so callers can switch tabs later without another command.
cc
Third try's the charm. The previous attempt called the endpoint via
signed_get_auth (RPC-style with request_sig + request_ts), which
yielded 'no Service found with those values' — the RPC dispatcher
simply doesn't know this path.
The /albums/* plural namespace (releaseWatch, favoriteAlbums,
purchasedAlbums, similarTo, essential, artistAlbums, discoverAlbums)
is REST-style, not RPC. QBZ already uses the same pattern for the
/radio/* endpoints in src-tauri/src/api/client.rs: plain
.get(url).headers(authenticated_headers()).query(...) with X-App-Id
and X-User-Auth-Token, and no signing. Same pattern here.
Also reverted the release_watch container addition to
DiscoverContainers — /discover/index does not return it alongside
the editorial sections, confirmed by the empty result when reading
from there even with follows set.
Radio and Artist Spotlight work in QBZ via this same REST + auth
headers pattern despite being 'mobile' features; Release Watch
should too.
cc
The initial implementation added a dedicated get_release_watch client
method calling /albums/releaseWatch, but that path is only routed on
the mobile API; the web API (api.json/0.2) that qbz-qobuz speaks to
returned 'no Service found with those values'.
The correct source is already available: /discover/index — which we
were already calling for newReleases/pressAwards/qobuzissimes/etc —
also returns a release_watch container when the user has follows. We
were silently dropping it because DiscoverContainers didn't declare
the field.
Now:
- DiscoverContainers (Rust + TS mirror) declares the release_watch
field with a serde alias that accepts both camelCase and snake_case
spellings, so future naming churn upstream is tolerated
- HomeView reads it in fetchAllDiscoverData next to the other editorial
sections (one less network round-trip)
- ForYouTab calls v2_get_discover_index and extracts only the
release_watch container
- Reverted the custom /albums/releaseWatch endpoint, client method,
core passthrough, bridge wrapper and v2_get_release_watch command
Section identity (homeSettingsStore id, i18n keys, render blocks)
is unchanged.
cc
The Qobuz mobile client (v9.7) surfaces a 'Release Watch' /
'Radar de Novedades' feed in its Discover screens — new releases
from the artists, labels and award series the user follows. We
didn't have parity for it. This adds it as a module in both
Discover tabs.
Backend:
- qbz-qobuz: new ALBUMS_RELEASE_WATCH endpoint constant
(/albums/releaseWatch, plural as in mobile) and signed_get_auth
client method returning a SearchResultsPage<Album>. Signing
method is 'albumsreleaseWatch' per the Qobuz secret protocol.
- qbz-core: passthrough get_release_watch on QbzCore.
- src-tauri: core_bridge wrapper, new v2_get_release_watch Tauri
command registered in lib.rs. Filters through the artist
blacklist like other album endpoints.
Frontend:
- HomeView: new renderable section 'releaseWatch' in the home tab
(default visible, placed right after New Releases). Parallel
fetch alongside the existing discover_index call; mapped via
the existing toAlbumCard helper.
- ForYouTab: section appears ahead of 'Essentials' since followed
artists/labels are typically more personal.
- homeSettingsStore: new HomeSectionId 'releaseWatch' in the type
union and DEFAULT_SECTIONS so the user can toggle/reorder it
from home customization.
- i18n: discover.releaseWatch.title + subtitle added to all five
locales, using Qobuz's own translations where they exist
('Radar de Novedades' for Spanish, 'Release-Radar' for German)
with the 'followed artists/labels/awards' subtitle copy adapted
from the mobile app's strings.xml resources.
cc
Add request_ts + request_sig (MD5 signature) to catalog/search,
album/search, track/search, artist/search, and playlist/search
endpoints matching the mobile app authentication pattern.
Migrate v2_search_all from legacy AppState to CoreBridge, removing
the last direct API call that bypassed the V2 crate architecture.
cc
The save was broken in two ways:
1. Only current_track + upcoming (capped at 20) were saved, dropping
all history tracks. Queue was reconstructed incomplete on restore.
2. Track metadata (hires, bit_depth, sample_rate, source, etc.) was
not being persisted, only the basic 6 fields.
Add v2_get_all_queue_tracks command that returns the full uncapped
track list with correct current_index. Use it in saveSessionBeforeClose
instead of the capped v2_get_queue_state. Map all metadata fields.
Replace the flat album grid LabelView with a rich detail page featuring:
- Header with label image, name, description, and play button
- Popular Tracks section (5/10/all expand, same UX as ArtistDetailView)
- Releases horizontal scroll row with "See all" → LabelReleasesView
- Critics' Picks section from second releases container
- Playlists, Artists, and More Labels sections
Backend: add /label/page and /label/explore API endpoints with V2
commands (v2_get_label_page, v2_get_label_explore). Models use
serde_json::Value for flexible section parsing.
Old LabelView renamed to LabelReleasesView for the "See all" flow.
i18n keys added to all 4 locales (en, es, de, fr).
Previously, V2 queue commands had a dual-queue bug:
- Writes (v2_set_queue, v2_add_to_queue, etc.) went to AppState.queue (legacy)
- Reads (v2_get_queue_state) came from CoreBridge/QbzCore queue
- Result: frontend sets queue, syncs, gets empty/stale data
This commit fixes the architecture:
- All V2 queue commands now use CoreBridge methods
- Added queue methods to QbzCore: add_track, add_tracks, add_track_next,
set_queue, remove_track, remove_upcoming_track, move_track, play_index,
next_track, previous_track, peek_upcoming
- Added corresponding methods to CoreBridge
- Fixed v2_set_shuffle to use CoreBridge (was using legacy AppState)
- Fixed runtime requirements: v2_toggle_shuffle and v2_clear_queue now
use RequiresUserSession like other queue commands (consistency)
- Prefetch now gets upcoming tracks from CoreBridge, not legacy queue
- Updated tests to match V2 behavior (v2_toggle_shuffle, available field)
Add get_discover_albums to qbz-core, CoreBridge, and commands_v2.
Supports endpoints: newReleases, idealDiscography, mostStreamed,
qobuzissimes, albumOfTheWeek, pressAward.
Migrate DiscoverBrowseView to use v2_get_discover_albums.
Add missing API methods to QbzCore, CoreBridge, and V2 commands:
- Playlist management: create, delete, update, search
- Catalog: genres, discover index/playlists, playlist tags, featured albums
- Artists: artist page, similar artists, artist with albums pagination
- Labels: label details
- Tracks: batch fetch
All methods follow the V2 architecture with RuntimeManager requirements.
- Add playlist methods to QbzCore (get_user_playlists, get_playlist,
add_tracks_to_playlist, remove_tracks_from_playlist)
- Add playlist methods to CoreBridge
- Create V2 Tauri commands for all playlist operations
- Update frontend to use V2 playlist commands
- Support both playlistTrackIds and trackIds in remove command
(with automatic resolution like legacy)
QbzCore::init() was creating the QobuzClient but not calling init()
to extract bundle tokens. This caused "Client not initialized" errors
for all V2 API commands.
Core playback now flows through V2 architecture:
- Added get_stream_url to QbzCore and CoreBridge
- Implemented v2_play_track with full cache support:
- Offline cache (persistent disk)
- Memory cache (L1)
- Playback cache (L2 - disk)
- Downloads from Qobuz via CoreBridge.get_stream_url()
- Plays via CoreBridge.player() (qbz-player crate)
- Respects quality limits and streaming_only settings
Still uses AppState.audio_cache for caching (to be migrated later).
No dependency on legacy player module for playback.
- QbzCore now accepts Player in constructor and owns playback
- Added playback methods to QbzCore (pause, resume, stop, seek, volume)
- CoreBridge loads audio settings and creates Player from qbz-player
- V2 playback commands use CoreBridge instead of AppState
This closes the "operational playback core" gap identified by Codex.
V2 commands now flow: frontend -> commands_v2 -> CoreBridge -> QbzCore -> Player
Legacy player in src-tauri/src/player/ remains as emergency fallback.