Pair cached audio with the requested and resolved quality, reject incompatible or unverified entries, and enforce disk budgets at startup and admission. Reuse the same policy for local, gapless and external playback while assembling CMAF segments in order with bounded concurrency.
cx
qconnect-lan gains what QBZ calls on another receiver: a
_qobuz-connect._tcp browser (own device_uuid filtered, Electron's SDK
floor), the get-display-info / get-connect-info probe (IPv4 first, 10 s,
Connection: close) and the connect-to-qconnect handoff body — the
controller's session uuid, the delegated API token and the delegated QWS
token under its LAN name jwt_qconnect, become_active. qbz-qobuz mints the
delegated pair with POST /qws/delegateAuth (q_app_id), the step every
official controller takes before the handoff. The receiver DTOs learn to
deserialize (unknown enum values collapse to Unknown). Pure helpers tested.
cc
With featured performers shown, the line is one elided StyledText whose
names are separate links: the main artist opens its page as before; a
featured name opens its page when the album credits carry its id and the
search for that name otherwise. Rows send the names with ids resolved.
cc
classify_reqwest formatted the raw reqwest error, whose Display embeds the
request URL with its hmac= token; the string is logged on every retry and
in the player's CMAF-failed warning. It now uses the URL-free helper the
legacy stream path already had, moved to qbz-qobuz::net_diag so both paths
share it. The log redaction layer additionally scrubs hmac=, Signature=,
Policy= and Key-Pair-Id= values.
cc
Apply a request-local 15-second deadline without limiting shared streaming requests or canceling store activation. Preserve saved tokens on network failures and release the splash into the existing login/offline route with the error visible.
Verify header stalls, partial and trickling bodies, cancellation, late responses and account retries with local HTTP fixtures plus boot-controller tests. All 103 Qobuz and 733 Qt tests pass.
cx
The 30-day offline grace (D4) was produced by the Qt shell alone, and only
when a login was REJECTED; its purge predicate had no caller in the live
tree, so past the window the CMAF cache was blocked from playing but never
removed (Tauri purged it; Slint recorded the gap). With member accounts now
signing in, the clock has to key on the entitlement, not on a rejection,
and it has to run wherever a session activates.
`qbz_app::subscription_lifecycle` records the verdict from the core's
session (`offline_streaming` clears the clock; a member keeps it running
from its first observation) and purges the offline cache once the window
has passed, through the cache a host registers with
`AppRuntime::set_offline_cache`. `activate_at` runs it for every host (Qt
via `activate`, qbzd directly), a daily ticker per session re-checks it,
and a cache registered after activation is enforced against on
registration. qbzd registers no cache and keeps the verdict. The Qt
producers are gone; `offline_playback_allowed` stays the read gate.
cc
Qobuz answers `/user/login` with 200 for an account without an active
subscription and the web player signs it in as a "Qobuz member" with its
favorites, playlists, purchases and 30-second previews. QBZ threw that
session away: `parse_login_response` returned `IneligibleUser` whenever
`credential.parameters` was empty (verified 2026-09-02 with a lapsed
account). The gate was ours, not Qobuz's.
The parser now reads the entitlement flags (`lossy/lossless/hires/
hires_purchases/offline/mobile_streaming`) into a typed `Entitlements`
on `UserSession`, labels a member "Member", and keeps `subscription.
end_date`/`offer` as information (the embedded Qobuz fixture has a past
end_date with every flag true: the flags are the verdict, the date is
not). `StreamUrl` gains `sample`, the flag Qobuz sets when it serves a
preview instead of the track. `IneligibleUser` stays in the enum for its
existing matchers; nothing emits it from this path any more. Persisted
pre-member sessions still load (`serde(default)`).
cc
Qobuz answers 200 for an account without an active subscription, but the
login parser rejects it (IneligibleUser) before anything can be captured.
Add `exchange_oauth_code` (OAuth step 1, extracted from
`login_with_oauth_code` unchanged), `login_with_token_raw` and
`get_stream_url_raw`, which return status and body verbatim without
parsing or storing a session, and a probe example that signs in through
the same system-browser OAuth flow as the app (or a pasted token), then
prints the redacted login payload, favorites totals, playlists and the
getFileUrl responses at CD and Hi-Res quality. Nothing is written to
Qobuz or to disk.
cc
The purchases listing carries `downloadable_format_ids` per album, and
it is the only honest source for the download menu: a DSD64 purchase
streams as 16/44.1 (`hires:false`), so the catalog-derived ladder
(27/7/6/5) offered a CD FLAC the account never bought and could not
reach the DSD it did. Model the field (plus `hires_purchased`), build
the menu from it highest quality first, and keep the synthesis only as
the fallback for a listing without the field. Fixture from the first
populated capture, redacted.
The download grant is a distinct signed operation: `intent` is part of
the `getFileUrl` preimage, and a DSD entitlement answers 400 to
`intentstream` and 200 to `intentdownload` (measured live). Sign the
purchase grant with `download`, stream the body straight into the
`.part` instead of holding a 650 MB master in memory, take the file
extension from the served container (`audio/x-dsf` -> `.dsf`), verify
its magic before the rename, and leave neither `.part` nor registry row
behind on a truncated or foreign body. Tags stay FLAC/MP3-only.
The tab counters now come from `getUserPurchases?type=...&limit=1`;
`getUserPurchasesIds` counts album tracks as tracks (34 against 0 on
the smoke account). The list card and detail header badge what was
bought: 55/56 ride the hires tier with a DSD64/DSD128 detail.
cc
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