Commit Graph
85 Commits
Author SHA1 Message Date
vicrodh f539149a30 fix(playback): retain acquisition quality across bounded audio storage
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
2026-09-14 13:44:13 -06:00
vicrodh c60cdec064 feat(qconnect): the controller half of the official LAN wire
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
2026-09-13 13:29:35 -06:00
vicrodh 903dfbefc6 feat(qt): every name on the artist line is its own link
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
2026-09-13 09:09:29 -06:00
vicrodh a29eb624ce feat(qobuz): derive a track's featured artists from its performers credits
cc
2026-09-13 03:48:02 -06:00
vicrodh 87410667ea fix(qobuz): keep signed CDN URLs out of CMAF fetch errors and redact CDN signature params
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
2026-09-13 03:36:50 -06:00
vicrodh 279a6fe847 fix(auth): bound saved-token restoration through the response body
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
2026-09-06 05:05:28 -06:00
vicrodh 316a0c8f46 fix(qt): replace unavailable library favorites
cx
2026-09-05 17:31:17 -06:00
vicrodh 561b3c25c7 feat(purchases): gate album actions by entitlement
cx
2026-09-03 04:32:23 -06:00
vicrodh 2e34112868 feat(app): subscription grace clock and offline purge in the shared activation
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
2026-09-02 07:41:09 -06:00
vicrodh 2b7d804f09 feat(auth): member accounts are valid sessions; typed entitlements
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
2026-09-02 07:27:38 -06:00
vicrodh 89efde792a feat(qobuz): read-only member-login probe and raw OAuth helpers
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
2026-09-02 07:23:06 -06:00
vicrodh d5f4ced66f feat(purchases): entitlement-driven formats and a DSD-safe download path
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
2026-09-01 23:40:33 -06:00
vicrodh b4404f45ba fix(qconnect): enforce shared lifecycle authority
cx
2026-08-30 22:22:08 -06:00
vicrodh 74a41150e9 feat(qconnect): add transactional LAN delegation
cx
2026-08-30 15:33:23 -06:00
vicrodh 9493e96e05 fix(qconnect): align auth and playback wire
cx
2026-08-30 13:08:58 -06:00
vicrodh 9e1f2c5559 fix(playback): recover stalled streams
cx
2026-08-29 14:55:21 -06:00
vicrodh 388a3591e2 fix(qobuz): integrate atomic bundle cache replacement (#707)
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
2026-08-26 20:50:33 -06:00
vicrodh 9aeee886db feat(qobuz): integrate configurable bundle cache directory (#708)
cx
2026-08-26 17:42:23 -06:00
Afonso Jorge Ramos 008bb31579 feat(qobuz): let an embedder choose the bundle cache directory
`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.
2026-08-21 00:58:08 +02:00
Afonso Jorge Ramos 5bebbf0d91 fix(qobuz): replace the bundle token cache atomically
`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.
2026-08-21 00:53:06 +02:00
vicrodh 8e52157299 feat(qobuz): add the updateTracksPosition verb
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
2026-08-17 14:41:05 -06:00
vicrodh 475f261dc1 feat(purchases): shared backend for the Qt port, with tags, covers and goodies
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
2026-08-16 11:53:37 -06:00
vicrodh 9c67be4dcd fix(qobuz): give the offline-gate tests a rustls crypto provider
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
2026-08-16 11:53:08 -06:00
vicrodh 63b4082e6a feat(qobuz): split CMAF fetch timing into send vs body
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
2026-08-10 17:04:51 -06:00
vicrodh accdef5a02 fix(qobuz): merge the translation-only v10 lyrics document with the original
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
2026-07-22 21:34:39 -06:00
vicrodh 7b447fb7e4 feat(qobuz): expose the account language code on the client
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
2026-07-22 19:59:47 -06:00
vicrodh 8c2dfd5727 feat(models): capture account country and language on login
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
2026-07-22 19:11:52 -06:00
vicrodh 072223078e feat(qobuz): send the language param on track/lyricsUrl
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
2026-07-22 19:11:41 -06:00
vicrodh 9d7f9c6c17 feat(qobuz): parse the v10 embedded lyrics translation block
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
2026-07-22 19:11:28 -06:00
vicrodh 075582920a fix(qobuz): 403 circuit breaker + prefetch backoff; header tooltips
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
2026-07-19 10:01:37 -06:00
vicrodh 1f608cddc9 fix(qobuz): back off between bundle-extraction retries
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
2026-07-17 19:36:49 -06:00
vicrodh 6aa314b8ef fix(discover): sub-genre filter sends raw ids to the server (Tauri parity)
/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
2026-07-16 16:30:12 -06:00
vicrodh e73dcf65da fix(parse): lenient per-item parsing everywhere a bad item blanked a list
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
2026-07-08 11:00:15 -06:00
vicrodh 26b6480ff1 feat(purchases): add purchase HTTP + getFileUrl + CDN fetch
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
2026-06-24 02:08:08 -06:00
vicrodh 391cbb86b9 feat(slint): ArtistView overhaul — server-driven buckets, dedicated discography, sort/load-more, Magazine, image fixes
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
2026-06-21 21:42:22 -06:00
vicrodh fdf2fec3bb feat(core): shared external-stream asset resolver for Cast (Phase 0)
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
2026-06-18 09:19:38 -06:00
vicrodh ee83734fba feat(discover): add get_dynamic_suggest_full carrying track_to_analysed
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
2026-06-15 15:50:41 -06:00
vicrodh 4e8703d660 feat(slint): Track Info + Album Info (Credits/Review) modals
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
2026-06-12 19:20:55 -06:00
vicrodh 46c15f507b feat(qobuz): track lyrics endpoint with live-verified wire DTOs
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
2026-06-10 16:03:44 -06:00
vicrodh c207f232c5 fix(offline): logout reopens the Qobuz gate; auth endpoints exempt from it
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
2026-06-10 01:32:14 -06:00
vicrodh 35403028da fix(qobuz): correct stale doctest import path in performers
The doc example still imported crate::api::performers from the pre-extraction
src-tauri layout; the doctest never compiled in the extracted crate.

cc
2026-06-09 20:14:35 -06:00
vicrodh ba702bc3b8 feat(qobuz): route every client HTTP request through the offline gate
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
2026-06-09 19:55:37 -06:00
vicrodh edae9d2463 feat(app): shared offline-mode engine with connectivity actor and grace gate
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
2026-06-09 19:48:13 -06:00
vicrodh 1072195740 merge: bring v1.2.15 Tauri fixes (shared HTTP client / fd exhaustion, qobuz bundle-token cache + bounded fetch) into slint-mvp
cc
2026-06-05 15:11:48 -06:00
vicrodh 0cefe1ee80 fix(qobuz): cache bundle tokens and bound the fetch to stop startup hangs
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
2026-06-05 01:09:54 -06:00
vicrodh 022b3e46e6 Merge pre-release into slint-mvp (1.2.14 backend catch-up)
One-way backend catch-up bringing the 1.2.14 maintenance work into
slint-mvp: audio (#470/#449/#466), player/offline/prefetch (#467),
qobuz retry/backoff, credential-store + image-cache refactor, dep
bumps (cpal 0.17.3 / alsa 0.11 / tokio / ashpd / tower-http), and the
qbzd daemon removal.

Conflicts resolved:
- qbz-audio settings.rs: keep slint-mvp stream_first_track/buffer
  defaults (1/2, opt-out); adopt #470's CREATE-TABLE seed removal
  (row is seeded with backend_type below).
- crates/Cargo.toml: keep qbz-slint workspace member (qbzd removed
  upstream).
- crates/Cargo.lock: regenerated (no ratatui/qbzd; cpal/alsa bumped).

cargo check --workspace green.

cc
2026-05-25 15:12:58 -06:00
vicrodh bdaee1af1a fix(qobuz): retry transient fetch failures with backoff instead of skipping (#467)
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
2026-05-22 21:46:33 -06:00
vicrodh 3b8f0a4597 feat(slint): Qobuz mix detail views (DailyQ / WeeklyQ / FavQ / TopQ)
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
2026-05-21 08:16:31 -06:00
vicrodh c19a5b84dd feat: For You — radio, album suggestions, rediscover, more-from-library
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
2026-05-21 06:43:53 -06:00
vicrodh 6f05fd3069 fix(qobuz): chunk get_tracks_batch into 50-ID windows (#419)
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
2026-05-13 11:37:33 -06:00