Preserve Afonso’s same-directory temp-file replacement on top of the configurable cache root from #708. Cover the complete explicit-root round trip and document the exact process-crash and concurrent-writer guarantees.
cx
`cache_path` asks `dirs::cache_dir()` where the token cache belongs, so the
location comes from process-global ambient state and a host that already
knows where its cache goes has no way to say so.
That is fine on the desktop. It is not fine everywhere `dirs` resolves: on
Android it takes the XDG implementation, so the path is `$XDG_CACHE_HOME`
or `$HOME/.cache`, and an app process has neither. The cache then either has
nowhere to go at all, taking the existing "No cache dir available" branch,
or names somewhere the process cannot write, so the write fails and is
logged. Either way the tokens never persist and every launch pays a full
live extraction of a ~7 MB bundle.js.
`QobuzClient::with_cache_dir` takes the root instead. `QobuzClient::new`,
`load_cached_bundle`, `extract_and_cache_bundle_tokens` and
`refresh_bundle_if_changed` all keep their signatures and their behaviour:
passing no directory still resolves through `dirs`. The `qbz/` namespace is
kept inside whatever root is supplied, so the file has the same name and
shape wherever it lands.
`save_cached_bundle` wrote in place with `std::fs::write`, which truncates
the file before the first byte of the replacement is written. Any kill in
that window leaves an empty or half-written `bundle_tokens.json`, which
parses as nothing, so the tokens are gone and the next launch pays a full
live extraction: a ~7 MB bundle.js fetch behind a 45s timeout, up to three
attempts.
The window is not rare. `refresh_bundle_if_changed` rewrites the whole file
on every launch that finds the version unchanged, purely to touch
`fetched_at`, so a warm cache is destroyed and rebuilt each time the app
starts.
The bytes now land in a sibling temp file that is flushed and renamed over
the target, so a reader sees either the previous cache or the new one. The
replacement is owner-only where writing in place left it at whatever the
umask allowed; these are app-level values scraped from a public bundle
rather than user credentials, so that is tidiness rather than a fix for a
vulnerability.
Reordering tracks inside a playlist had no client verb: addTracks takes only
[playlist_id, track_ids, no_duplicate] and appends, and updatePlaylistsPosition
reorders the user's PLAYLISTS, not the tracks within one.
The endpoint exists and is documented from a desktop web-player capture --
{playlist_id, playlist_track_ids, insert_before} -- not from the decompiled
Android route tables, which are not a valid source for this. Its exact argument
encoding is the one part no capture in the repo pins down, so every caller must
treat a failure as recoverable rather than fatal.
Written for the replacement flow, which needs the repaired track to land where
the dead one was instead of at the end of the playlist.
cc
The frontend-agnostic half of Purchases. Ported against the Tauri reference,
which is the only implementation ever validated by a real user; the Slint
controller was read for divergences only and never as a source of truth.
Context that sets the bar for all of it: Qobuz does not sell Purchases in this
account's region, so no populated screen and no download can be exercised here.
Anything merely plausible ships unseen. Hence the tests below are the deliverable
as much as the code.
Behaviour ported or corrected:
- Album "downloaded" now comes from the LOCAL REGISTRY. The reference derives it
from `album.tracks.items`, but `getUserPurchases?type=albums` carries no nested
tracks page (measured against a live account), so that predicate is
unsatisfiable: no album card can ever mark as downloaded and "Hide downloaded"
can never hide anything. `get_downloaded_purchase_album_counts` answers it
instead, giving a reader at last to the `album_id` column and to the index that
has always sat on it.
- `album_id` is written by every download path. It used to be set only by the
album loop, so a release downloaded track by track produced rows the rule could
never attribute. The `/track/get` payload each download already fetches carries
the id.
- `remove_downloaded_purchase` keys on `(track_id, format_id)`, the table's actual
primary key; erasing every format is now a separate, named call.
- `sanitize_filename` no longer panics. The 200 cap is in BYTES and `truncate`
panics off a char boundary — reachable because the mapping keeps Unicode
alphanumerics. Measured: the crash belongs to the THREE-byte class (CJK), where
200 % 3 lands mid-character; two-byte scripts happen to land on a boundary. The
cap stays in bytes so no already-downloaded path moves.
- The purchase path sends `/album/get` and `/track/get` UNSIGNED, matching both
the reference and Qobuz's own desktop client, whose request helper takes a sign
flag and passes it false for these and true only for `getFileUrl`. That
signature is the entitlement proof and is untouched.
- The purchase-type guard runs before any I/O; a bogus type no longer issues a
live authenticated GET before failing.
- Track subtitles survive. `PurchaseTrack` gained `version` and
`build_purchase_album` maps it from the catalog track, closing a latent
regression where every purchased track lost its edition.
- Downloads write tags, `cover.jpg`/`back.jpg` and goodies. All three are
best-effort: the audio file is the deliverable and is already on disk and
registered before any of it runs.
- `goodies` is deserialized leniently. Its populated shape has never been
observed — it comes back empty on every album nobody owns — and `/album/get`
is parsed with the strict structs, so an unexpected shape would take the whole
album down rather than merely hide a booklet.
Riding along, both found while building the above and both unrelated to
Purchases:
- The metadata EDITOR silently discarded saves. `write_album_tags_to_files`
picked its write target as "the primary tag, or else the first one I can find",
and lofty refuses to WRITE a tag type a container only supports READING —
silently, skipping it rather than erroring. A FLAC carrying an ID3v2 tag has no
Vorbis comments, so every edited field went into that ID3v2 tag, the save
dropped the lot, and the call returned Ok. The user pressed Save, saw a success
toast, and the file never changed. Same defect, same fix, in the new purchase
tagger.
- `fetch_asset_bytes` does not reuse the audio downloader. That one sizes its
buffer from the response's own Content-Length and sets no total timeout, which
is fine for a track from a trusted CDN and wrong for an arbitrary goodie URL on
a machine that hard-freezes under memory pressure.
Tests: 277 across the four crates, covering every acceptance item the contract
set. The signature preimage character for character, the pagination loop
including the zero-total case, the sanitizer against the inputs that used to
panic, every deserializer default, and the wire envelopes against real bytes
captured from a live account. A download harness stands up a local HTTP server
and drives the pipeline end to end — real bytes over a real socket, a real FLAC
tagged and read back, a real database — and the two tag regressions were both
verified to FAIL against the old code before being accepted.
cc
Both `client::tests::offline_gate_*` failed on every run, not intermittently:
reqwest is built with `rustls-tls-webpki-roots-no-provider`, so BUILDING a
client panics with "No provider set" until a process-level CryptoProvider is
installed. The application installs one at startup via
`qbz_app::ensure_crypto_provider`, but a test binary has no startup, and this
crate sits below `qbz-app` and cannot call it.
Confirmed pre-existing by stashing the unrelated work and re-running: same two
failures, same panic.
Worth knowing beyond these two tests: the panic fires at client CONSTRUCTION,
before any request and regardless of scheme, so it hits a plain `http://` URL
too. Any future tool or test that reaches for the download path without an
application around it will land on it.
cc
Diagnoses where slow init-segment fetches lose their time:
send(dns+connect+tls+ttfb) vs body. Landed the 2026-08-10 proof that
the multi-second shuffle-start stall sat entirely in the connection
phase (send=5449ms, body=0ms).
cc
Live wire (verified 2026-07-22, track 266725027 with language=es): when a
translation is requested and available, lyrics_url points at the TRANSLATED
document (<id>-<lang>.json) carrying ONLY 'translation' — no 'original' —
and 'translation_requested' is absent. get_lyrics rejected such documents
('without original content -> no lyrics'), so every translation toggle
ended in the unavailable toast. Now get_lyrics fetches the original-only
document in a second round trip and merges both into one bilingual doc
(merge_translation_into); requested-but-unavailable still 403s the CDN
URL -> typed miss (fail soft).
km
Read-only accessor over the session lock — the lyrics translation "Auto"
resolution reads it through the shared client handle. None pre-login and
for pre-v10 persisted sessions.
km
parse_login_response now reads user.country_code and user.language_code
(snake_case wire names, verified against Qobuz's own embedded login
fixture) into two new serde-defaulted Option fields on UserSession, so
pre-v10 persisted sessions still load with both as None (translation
stays Auto-off). qbzd's test construction updated for the new fields.
km
get_lyrics_url_raw/get_lyrics_url/get_lyrics take an optional ISO 639-1
language, appended to the signed param list only when Some (the existing
sign_request sort covers it). None keeps the original-only default flow.
When a translation was requested but the document does not embed one,
the envelope's translation_requested.lyrics_url is fetched as a fallback
translation source (spec section B.3); every gap degrades to
original-only. lyrics_probe gains --lang=<iso639-1> for parity.
km
QobuzLyricsDocument gains the optional translation member (same content
union as original, new in API 10.0), the content DTOs become
Serialize+PartialEq so the lyrics cache can persist a translation
verbatim, and a lang() accessor exposes the content language. New
fixtures cover embedded translations of each type (wsync/lsync/plain),
the lyricsUrl envelope with translation_requested, empty
translation_langs (feature off), and 9.x documents (no translation).
km
Issue #637: a Qobuz outage + forced re-login left a user's account
transiently 403'd, and the no-backoff prefetch retry storm escalated it
into a per-IP edge/WAF block (the "error decoding response body" lines are
that non-JSON WAF body hitting a bare .json()). The bundle bump b019->b034
is exonerated: extraction yields byte-identical app_id/secret/private_key.
- ForbiddenBreaker: after repeated 403s the hot streaming/favorites paths
short-circuit without touching the network, with an exponential cooldown
and a half-open probe; success resets it.
- get_stream_url: typed 401/403 + body preview; abort the quality fallback
loop on 403/401/breaker-open instead of firing all five qualities.
- CMAF session/start + file/url: feed the breaker, map 403.
- get_favorites + qws/createToken: check status before .json() so a 403
surfaces as a 403, not a misleading "error decoding response body".
- Prefetch: per-track failure cooldown (negative cache) so a failed track
is not re-hammered every queue tick.
- Audible path: a 403/back-off no longer counts as an "unavailable" skip;
stop cleanly with a toast.
- QBZ_FORCE_403 test hook to smoke the back-off path on a healthy account.
- Header action-button tooltips for AlbumView/ArtistView/PlaylistView,
translated across all 8 locales.
cc
The 3 bundle-extraction attempts fired back-to-back, so a brief network hiccup
(DNS blip, dropped connection, captive-portal redirect) failed all of them in a
few ms and the retries were effectively useless — surfacing as "Failed to
extract bundle tokens: Network error" at sign-in. Add a short growing delay
(600ms, 1200ms) between attempts so a transient failure has time to clear.
cc
/discover/index honors sub-genre ids server-side, so send the raw selection
(parent or sub-genre, exactly as toggled) instead of mapping to the top-level
ancestor and narrowing client-side by genre.path — the previous hybrid widened
sub-genre selections back to their parent and dropped albums tagged only at top
level. Removes the client-side narrowing and its now-dead helpers; keeps a
diagnostic log of the outgoing genre_ids.
cc
Extends the #556/#565 fix (one malformed item in a JSON array nuked
the whole list via from_value::<Vec<T>>().unwrap_or_default()) from
favorites to every remaining silent-blank site found by audit: the
search parse_page (EVERY search tab — scalars now parse separately,
items per-item), For You fav-albums/fav-artists rails, the daily-mix
favorite_tracks source, and qbz-qobuz get_dynamic_suggest. The helper
moves to qbz-models::lenient (the one crate all consumers reach) with
unit tests; favorites re-uses it via alias, local copy deleted. The
~30 endpoints whose parse errors PROPAGATE stay as-is — honest errors
stay honest. New parse_page test covers a poisoned item surviving.
cc
Slice 2/13. New impl QobuzClient block in qbz-qobuz/src/purchases.rs ported
1:1 from src-tauri api/client.rs: typed/ids purchase listing (page_limit 500,
two-call per-type totals quirk preserved), get_track_file_url_by_format
(RPC-signed, intent=stream, empty-url -> TrackUnavailable), and download_audio
(http1_only, NO total timeout). Adds 2 endpoint consts; bumps
http()/secret()/authenticated_headers() to pub(crate) for the sibling module.
Offline-gated via self.http()?. Drops .use_native_tls() (crates/ workspace is
rustls-only; matches cmaf.rs build_cdn_client) to keep the standalone build green.
cc
Bucketing now trusts the server release_type: drop the foreign-artist filter
that hid band/guest releases (e.g. Vicky Psarakis' Albums were credited to
Sicksense), render every non-empty bucket, hide 'Purchase Only' (download),
and move 'Other' to the end, collapsed by default. Drop the misleading
per-section item counts.
New 'Latest release' highlight (last_release) and 'Appears On' track section
(tracks_appears_on, previously mislabeled as Compilations).
Per-section persisted 5-mode sort (artist_prefs) + per-section load-more
capped at 4 pages, reusing get_releases_grid. New dedicated discography page
(ArtistReleasesView + artist_releases controller) with sort + infinite scroll,
reached via each section's 'See discography' link.
Magazine/Stories sidebar tab (artist/story) with 1:1 thumbnails and a
Network|Magazine tab switcher; hide the empty similar-artists block to remove
the 'no similar' over populated-discovery incoherence. Header Share action.
Per-release award ribbons.
Fix custom artist AND album images: decode + apply immediately on upload and
resolve the local override on navigation, so a custom image now shows for
artists/albums with no Qobuz artwork.
Data layer: type last_release as PageArtistRelease, add /artist/story endpoint
+ core/client methods + ArtistStory* models, export ArtistReleases* globals.
cc
Add a frontend-agnostic API to resolve a track's bytes + MIME + actually-
delivered quality for an external renderer (Chromecast / DLNA), so the Slint
Cast port does not need an improvised downloader (ADR-006).
- qbz-models: ExternalStreamAsset { bytes, content_type, quality, duration,
origin }, StreamQualityInfo (kHz-normalized via from_raw, handles the
kHz-vs-Hz endpoint ambiguity), AssetOrigin.
- qbz-qobuz/cmaf: download_full_with_quality(_progress) returns the resolved
format_id/sampling_rate/bit_depth alongside the FLAC bytes; download_full is
now a thin wrapper (no duplicated segment assembly).
- qbz-player: Player::fetch_for_external_stream — CMAF-first (FLAC, accurate
quality) with legacy stream-URL fallback; never reads the local playback
cache (casting attempts the best tier and must report the real delivered
quality); never serves an empty content type (external_content_type helper).
- qbz-core: CoreBridge::fetch_for_external_stream_resolved wrapper.
Decision D3 (attempt UltraHiRes first + surface delivered quality) is satisfied
by passing Quality::UltraHiRes and returning StreamQualityInfo. Unit-tested:
content-type fallback by format id, sampling-rate unit normalization.
cc
Tauri's DailyQ/WeeklyQ /dynamic/suggest seeds a track_to_analysed payload
(~9 resolved {track_id,artist_id,genre_id,label_id} tuples) for the PRIMARY
mix algorithm; the empty-analysis call is only its fallback. The Slint port
only ever sent the empty-analysis path. Add a TrackToAnalyse model
(qbz-models) and a get_dynamic_suggest_full method on the qobuz client and
qbz-core that carries the payload; the existing get_dynamic_suggest now
delegates with an empty analysis (behaviour unchanged). Plumbing only — the
mix.rs build of the analysis tuples + the two-call fallback lands next (a2).
cc
1:1 port of the Tauri TrackInfoModal + AlbumCreditsModal to the Slint UI.
The Track Info invocations were already wired (TransportControls info
button, SongCard title, TrackContextMenu item) and only needed the modal
plus the fetch; the backend (core get_track/get_album, qbz-qobuz performers,
models) already carried all the data.
- qbz-qobuz/performers.rs: group_credits_ordered (matches the Tauri
getGroupedCredits ordering: Composer/Lyricist first, MainArtist last, rest
alphabetical, deduped) + format_role_label backed by the 147-entry English
performerRoles map.
- qbz-slint/info_modals.rs: fetch + map + apply controllers (mirror of
navigate_album); track/album quality strings match Tauri exactly.
- state.slint: TrackInfoState / AlbumInfoState + InfoCreditRow / InfoCreditPair
/ AlbumCreditPerformer / AlbumCreditTrack + Actions; re-exported in app.slint.
- TrackInfoModal.slint + AlbumCreditsModal.slint: scrim modals (FocusScope +
Timer for Esc, ListScrollbar), 3-col metadata + 2-col credits grids,
expandable track rows with play-on-hover, Credits/Review tabs gated on a
review. Album modal is content-sized with a capped max height.
- AppShell mounts both; AlbumPageView (i) button wired (Qobuz albums only);
main.rs adds the ("track","track-info") + ("album","info") arms and the
modal action callbacks (close/tab/play/artist/label/musician).
cc
Add /track/lyricsUrl (signed RPC, method name "tracklyricsUrl") plus
the CDN document fetch through the offline choke point. Wire shapes
were validated against live captures (2026-06-10) and diverge from the
inferred yaml: numeric track_id in the step-1 envelope, CDN document
wrapped in {translation_langs, publishers, writers, original}, "wsync"
word-synced discriminator instead of "lsync" (per-word timestamps),
plain lines as objects, and miss = HTTP 404 error body mapped to a
typed Ok(None) instead of an error. Unit tests deserialize fixture
excerpts of the captures; examples/lyrics_probe.rs is the read-only
live probe used for the capture.
Also expose qbz_credentials::load_oauth_token_from_file so a stale
keyring entry cannot shadow the authoritative encrypted token file.
cc
Logging out of an offline session left offline_session set and the gate
closed, refusing the very OAuth exchange that could end the offline state
(user-reproduced: sign-in failed with OfflineMode after logout). teardown()
now ends the session-scoped offline state (offline_session and the cached
induced flag reset; the persisted preference reloads on the next
activation) and recomputes, reopening the gate when connectivity allows.
Two regression tests added.
The three login methods (login, login_with_oauth_code, login_with_token)
are exempt from the offline gate: user-initiated sign-in is explicit
intent to reach Qobuz; the gate governs services. Verified none of them
reach gated sub-calls; bundle cold-start stays gated on purpose. New unit
test pins the exemption. The recovery sign-in falls back to the full
browser OAuth flow when the saved token is gone or rejected, and
offline_session is lifted only on success paths.
cc
All 23 request sites in QobuzClient now obtain the reqwest client via the
private http() choke point, which fails fast with the non-transient
ApiError::OfflineMode while offline mode is active (D3: zero Qobuz services
offline). Warm-start init still serves cached bundle tokens offline and only
skips the background refresh; the cold bundle fetch fails fast instead of
waiting out network timeouts. Behavior with the gate open is unchanged.
Known bypass left for the QConnect slice: get_http() feeds the qws
createToken POST in qbz-slint; QConnect gets its own connect()-level gate
per D5 (never available offline).
cc
New qbz_app::offline_mode (frontend-agnostic, ADR-006): three-state mode
machine (Online / RealOffline session-scoped / InducedOffline persisted),
Tauri-compatible offline_settings.db store with the issue #279
stream_first_track snapshot, and a layered connectivity actor (default-route
signal on Linux, audio liveness window, IP-literal + vendor-diverse strict
probes, asymmetric hysteresis with confirmation burst, captive-portal and
suspend/resume handling).
qbz-qobuz gains the process-wide offline_gate and ApiError::OfflineMode (the
single Qobuz choke point; HTTP funnel wiring lands next).
SubscriptionStateStore gains offline_playback_allowed: binary 30-day-grace
playback gate for the offline cache, never a preview-degradation path.
cc
Startup blocked on Qobuz's ~7 MB web bundle download with no cache or
timeout, so a slow CDN left the app on a blank screen for 25-30s before
the login screen appeared. Cache the extracted tokens on disk (keyed by
bundle version), apply them instantly on warm starts and refresh in the
background, and bound the cold fetch with a per-request timeout + retry.
The legacy api client shares the same cache. Emits BundleFetchStarted so
the UI can show a connecting state on the first run.
cc
A single transient 5xx / timeout / reset on the next track's file/url or a CMAF
segment used to be terminal and skipped the track. Add a retry wrapper (3
attempts, exponential backoff + jitter) around get_file_url, the init segment,
and per-segment fetches. Retry only transient errors (network/timeout, 5xx, 429
honoring Retry-After); a real 404 is classified as TrackUnavailable and
propagates immediately to the bounded skip path.
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
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
The /track/getList endpoint caps at 50 IDs per call. Sending more in a
single POST either errors out or silently truncates, both of which take
the entire call site down.
For QConnect this surfaced as: pushing a >50-track playlist from the
Qobuz Android app to QBZ caused the materialization step (corebridge
get_tracks_batch hydration) to return an error, abort, and leave only
the 1-track placeholder queue from the earlier align-cursor pass. The
visible symptom was "only the first track loads on Connect playlists
above 50 tracks".
The fix is at the helper level rather than in QConnect: split inputs
larger than 50 into 50-ID windows fetched serially, preserve input
order in the returned Vec. Single-window path is unchanged for the
common case. Same return shape, same error type, so the four call
sites (corebridge.materialize_remote_queue, qbzd qconnect, V2
v2_get_tracks_batch, qbzd catalog) all benefit transparently without
edits to QConnect orchestration code (auto-advance, deletion timing,
queue ordering are untouched).
Serial loop instead of the concurrent fan-out used by get_playlist —
lower risk for the first fix; if QConnect playlists of 200+ tracks
start feeling slow we can revisit with try_join_all.
cc
CMAF decrypt was the bottleneck for offline-cache gapless. Live
testing showed a 208 MB HiRes bundle took ~22 seconds to decrypt end-
to-end, which made gapless for anything longer than a 3-minute track
impossible (the gapless pre-emptive window is ~5s before track end,
so a 22s decrypt can't land in time regardless of how early we start).
Two changes, complementary:
1. `.cargo/config.toml` with `target-cpu=x86-64-v3` (Haswell baseline).
The `aes` crate ships both a software implementation and an AES-NI
hardware path, runtime-dispatched via `cpufeatures` — but the
AES-NI path is only compiled in when the appropriate target-feature
is enabled at build time. Default dev builds compile ONLY the
software path, which hits ~10 MB/s on a modern CPU. Haswell+ (2013
and up, every realistic QBZ user) supports AES-NI and clears
400 MB/s+. For prebuilt distribution binaries we'll want runtime-
dispatch via subfunction build or dynamic linking, but for self-
built and release-source-from-tag scenarios this is the right
default.
2. `decrypt_segments_into` no longer allocates a Vec per frame. Was:
frame = seg_data[..].to_vec(); // alloc
decrypt_frame(&mut frame); // in-place
output.extend_from_slice(&frame); // copy back
Now:
let start = output.len();
output.extend_from_slice(&seg_data[..]); // single copy
decrypt_frame(&mut output[start..]); // in-place
For a HiRes FLAC with ~50K frames this saves 50K small allocs +
50K double-copies. Output Vec is already pre-sized via
with_capacity(total), so no reallocation growth during extend.
Combined expected impact: 22s decrypt on a 208 MB track → under 500ms,
which fits inside the 5s gapless window with headroom to spare.
cc
The CMAF session cache is an Arc<RwLock<Option<CmafSession>>>. The old
ensure_cmaf_session did a read-check then dropped the guard then did
its POST unlocked, which meant two concurrent callers would each see
"no session", each POST /session/start, and each receive a different
session_id + infos. The second to finish stored its session as the new
cache, clobbering the first.
That was a problem because file/url wraps the per-track AES key against
whichever session_id the X-Session-Id header carried. If task A got its
wrapped key under session X but the cache got overwritten to Y before
decrypt time, the HKDF over Y-infos produced the wrong session_key and
the AES-CBC unwrap failed with "Unpad Error" — which is exactly the
warning that showed up in prefetch runs during parallel downloads and
caused the >3s gap between offline-cached tracks when the next track's
prefetch had silently fallen back to legacy.
The symptom in logs:
[CMAF] Starting new session ← task A
[CMAF] Starting new session ← task B (same ms)
Session started: id=bHoSYBFx...
Session started: id=bJ-w2wgm... ← cache now holds B
[PREFETCH] CMAF failed for track N: Content key unwrap failed:
AES-CBC unwrap failed: Unpad Error
Fix: double-checked lock pattern. Fast path stays on the read guard
for existing-valid sessions. Slow path takes the write guard, re-checks
under exclusive ownership, and only the first caller actually hits
session/start. Everyone else wakes up and returns the freshly-cached
value.
Compile check only — full behaviour validation in the running dev
session after this commit.
cc
The UI's offline-download progress ring listens to
'offline:caching_progress' events. The legacy StreamFetcher::fetch_to_file
emits those per HTTP chunk via app_handle. The new CMAF path went
silent because cmaf_download_raw lives in the qbz-qobuz crate with no
Tauri access — the user saw the completion toast but never saw the
ring fill.
Fix:
- Add an optional CmafProgressCallback to the qbz_qobuz::cmaf public
API. Typed as Arc<dyn Fn(CmafProgressUpdate) + Send + Sync>.
- New download_full_with_progress and download_raw_with_progress
variants; the callback-less versions delegate to them with None.
- fetch_all_segments fires the callback once per completed segment
with cumulative (k, n, bytes_this_segment). Callback is invoked on
the worker thread after the bytes land, before the rate-limit
cooldown.
- try_cmaf_offline_download passes a callback that emits
'offline:caching_progress' events in the exact shape the UI already
expects (trackId, progressPercent 0..100, bytesDownloaded,
totalBytes: null, status: 'downloading'). Also emits one
zero-progress event up front so the ring doesn't sit at "queued"
while the first segment fetches.
Percentage is computed as (segments_completed / n_segments) * 100,
rounded and clamped. It's segment-granular rather than byte-granular
so the ring advances in N discrete steps (typically 8-20 for a 4-min
track) rather than smoothly — matching what the CMAF pipeline can
actually observe, which is segment-complete events.
cc
Prep work for offline-cache CMAF hardening. The full CMAF pipeline
(init-segment setup, full-download, decrypt) used to live in
src-tauri/src/commands_v2/helpers.rs — fine for playback but unreachable
from the offline-cache code without dragging Tauri deps around.
Moves:
- try_cmaf_full_download → qbz_qobuz::cmaf::download_full
- try_cmaf_streaming_setup → qbz_qobuz::cmaf::setup_streaming
- CmafStreamingInfo struct → qbz_qobuz::cmaf
Adds:
- qbz_qobuz::cmaf::download_raw + CmafRawBundle — fetches the same track
but returns the ENCRYPTED segments + init bytes + the unwrapped
content_key + the session infos, ready to be persisted to disk as-is.
The offline cache will use this so that cached files are bit-identical
to what Qobuz delivered (no decrypt-to-disk), with the content key
stored separately under an OS-keyring-wrapped blob. Coming in next
commits.
- qbz_qobuz::cmaf::decrypt_segments_into — shared decrypt helper used by
download_full and (in the next commits) by the offline-playback path
that reads encrypted segments from disk instead of CDN.
In-tree behaviour is unchanged: try_cmaf_full_download /
try_cmaf_streaming_setup in helpers.rs are now thin wrappers that hand
the CoreBridge's QobuzClient to the crate functions. Same API surface
for playback.rs callers, same log tags, same on-wire behaviour.
v2_cmaf_stream stays in src-tauri because it binds to qbz-player's
BufferWriter + qbz-cache's AudioCache — those are playback-only deps
that don't belong in qbz-qobuz.
Net: -100 LOC out of src-tauri, +390 LOC in a properly-layered crate
module with docs explaining the two variants and why the raw one exists.
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
/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
The log showed 'award=null' reaching /award/getAlbums and Qobuz
replying '400 Bad Request: Invalid argument: award_id (accepted type
are number)'. Root cause: AlbumAward.id was typed Option<u64> and
/album/get returns award ids as strings; serde dropped them to None,
frontend stringified null → 'null', we sent that as the query param.
Fix:
- AlbumAward.id becomes Option<String> with a custom deserializer
that accepts both string and integer JSON values.
- TS types + AlbumDetailView Award interface + pickAlbumRibbon all
use string id (compared as '88'/'151' now).
- Sidebar button is disabled when id is falsy; handleAwardClick
early-returns on empty id so we never navigate to award 'null'.
- Better body/status logging in get_award_albums stays so future
4xx responses are visible in the log.
Also switches /award/page and /award/getAlbums to signed_get_auth
(app_id + user token). Mobile does the same — catalog visibility is
region-scoped and the unsigned variant returns 400.
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
AlbumDto from Qobuz carries both legacy singular 'artist' and current
'artists[]' array. /favorite/getNewReleases omits the singular field
and only populates the array, so our Album struct (which has a default
serde Artist) deserialized an empty name — every Release Watch card on
Home read 'Unknown Artist'.
Pre-process the JSON response items before deserializing: when 'artist'
is absent or null, copy artists[0] into it. Keeps the rest of the
album-list plumbing untouched.
cc
The endpoint returns V2GenericListDto<AlbumDto> which is just
{has_more: bool, items: [...]} — no total/offset/limit. Our caller
wants SearchResultsPage<Album> to stay compatible with the rest of
the album-list flow, so map items through directly and synthesise
total from has_more + items.len() + the offset we asked for.
Endpoint itself returns 200 OK with a real feed now (confirmed in
logs with Godsmack etc. on the test 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
The debug-level log lines for status + response keys + raw body never
printed at default log level, leaving zero observability on the call.
Bumped to info and added body capture so a non-success status or a
deserialize mismatch dumps the first 400-600 chars, which will tell
us whether the endpoint returned {albums:{items:[]}}, {items:[]}, an
auth error, or something else entirely.
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
First iteration hard-parsed the /albums/releaseWatch response as
SearchResultsPage<Album>, which failed with 'missing field items'
because the wrapper key (albums / releaseWatch / release_watch /
bare items) differs across Qobuz API deployments and is not
publicly documented.
Now walks a candidate list of wrapper keys, accepts both
{items: [...]} objects and bare arrays, falls back to root-level
parsing, and finally logs the response shape + returns an empty
page rather than propagating an error that would kill the whole
Discover screen.
cc