get_tracks_for_eviction had no caller; the resolve-path comment claimed
check_cache_limit evicts by last_accessed_at, but it only refuses new
downloads — downloaded albums are kept by design.
cc
Initialize the four new optional AlbumSummary fields in all six test fixtures. Preserve absent metadata semantics and restore workspace test compilation.
cx
Restore downward stream-format downloads while keeping purchased masters on the download grant. Make Purchases playback prefer the highest complete local copy, repair duplicate copy inventory, and move copy management into a bounded modal with safe library and record actions.
cx
The artist page's "In library" count was a snapshot of the Library feed
taken when the document was built, over a feed that only loads when the
Library view is opened and that never gains a row for a favourite created
afterwards. On a cold session, or after an account switch, every artist
opened with an invisible toggle; hearting an album on the page moved
nothing until the next session.
- library_qt: `artist_library_items` computes the tab's rows with the
Library > Albums/Tracks rule (favourites ∪ purchases, one row per
entity, purchase row representative, `is_favorite` = also hearted);
local/Plex and `following` rows never enter. A track is a member
through its performer OR its album's main artist (new
`FeedItem::album_artist_id`, fed by the new `AlbumSummary::artist`,
which the wire carries — search-results capture).
- library_qt: `reconcile_qobuz_favorite` inserts the row of a freshly
hearted track/album (one `/track/get` or `/album/get`, mapped by the
same producers the full load uses) and drops the `favorites` rows of a
cleared one, keeping a purchase row with its heart cleared.
- artist_qt: the rows ride inside the artist document (`libraryItems`),
and `refresh_library_items` / `apply_favorite_change` republish them
through `publish_patch` when the feed lands or a heart settles. A
debug-level log prints the membership split for the open artist.
- main: `settle_favorite` is the one settle point (reconcile, fan out,
republish on membership change) for the Library and queue-panel
hearts; `open_artist` warms the feed in the background; `reload_library`
hands the open page its rows; `warm_library` serves the toggle from
cache.
Tests: membership by performer or album artist, exclusion of local /
following, dedup on the purchase row, and the `top_queue` contract that
a foreign id starts the popular list at row 0 (the #737 defect class).
cc
The downward streaming ladder read `album.hires` to add 27/7, but the
live DSD album is hires:true with a 16/44.1 stream, so "[FLAC][24-bit,
192kHz]" was offered and the CDN filled a folder by that name with CD
files. Gate the two hi-res rungs on the catalog's `hires_streamable`.
cc
The Qobuz desktop client leaves `folder.jpg` and `large_cover.jpg` beside
a purchase and fetches its booklet with it; QBZ wrote a 600 px `cover.jpg`
and kept the goodies behind a separate control. Write `large_cover.jpg`
from the `_max` variant next to `cover.jpg`/`back.jpg`, and fetch every
goodie the catalog lists into the album folder as part of "Download all",
counted separately from the tracks as before. The "Download goodies"
control stays as the re-download for a folder that lost them.
cc
A downloaded purchase played nothing: Play on the purchase detail (and
every other Qobuz entry) streamed the catalog, so a DSD64 album bought
and saved to disk still came out of the CD stream with a "downgrade"
badge. Resolve the download registry at the audible step instead: the
best registered copy of the track that answers the bounded reachability
probe plays under the SAME Qobuz id (`play_id` = catalog id, so
now-playing, history, scrobbling and QConnect see the track they always
did), `.dsf`/`.dff` through the player's additive DSD ticket and PCM
through the file ticket. The gapless successor takes the same route, so
an album bought as DSD stays DSD across the track edge. A play that asks
for a start offset keeps the stream for a DSD copy (DSD cannot seek), and
anything the copy cannot do falls through to the tier walk unchanged.
Contract: qt-frontend/2026-09-01-purchases-local-playback §6, first
slice (implicit preference: the copy wins; no per-album select yet).
cc
The store grants `intent=download` for exactly the entitlement ids
(measured live: a DSD64 purchase gets 55 and nothing else), so a DSD
album could no longer be saved as a CD FLAC the way the Slint and Tauri
builds allowed. Those builds fetched every download through the
STREAMING grant, which serves any streamable album, bought or not.
Keep that route, but only downward: the menu lists the entitlement
first, then the catalog-synthesized 27/7/6/5 options ranked below the
purchase and not already owned, each marked `streaming` and fetched
through `intent=stream`. Nothing above the purchase is offered, and a
purchased id always goes through the purchase grant.
cc
The download path used the track performer as the artist folder — the
reference did the same — so a Various-Artists compilation landed as one
folder per performer with one track each (fourteen for "Audiophile
Analog Collection Vol.3", smoke 2026-09-01) and the cover only beside
the first track. Use the album artist from the download context for the
folder; the ARTIST tag keeps the performer.
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
rusqlite 0.40 removed ToSql/FromSql for u64 (SQLite integers are i64):
every u64 column now reads/writes through an explicit i64 cast. It also
rejects a multi-statement execute() instead of silently running only the
first statement — which is what catalog.rs's editions/logical_albums
prune had been doing all along; it is an execute_batch now, so the
logical_albums prune finally runs.
cc
`#[serde(default)]` on a bare bool means an absent key deserializes to FALSE,
so a catalog Track from any endpoint that omits `streamable` came back marked
unavailable. The models' own tests document this as a hazard rather than a bug
(the "streamable split": PurchaseTrack defaults true, catalog Track defaults
false, and the comment warns that a wrong default "inverts that whole screen's
behaviour"). The inferred OpenAPI disagrees with the code and documents
`default: true`.
That was survivable only because nothing read the field. It stops being
survivable the moment a frontend greys rows out and keeps them out of the
queue: one terse endpoint would blank an entire view, silently.
`None` now means the endpoint made no claim, and only `Some(false)` marks a
track pulled. `is_streamable()` is the ONE place absence is interpreted, and it
resolves to available: refusing to play a track because a response was terse is
strictly worse than playing one that turns out to be dead, which the reactive
skip path already handles. Album.streamable was already Option<bool> with a doc
comment naming the same causes, so this makes Track consistent rather than
novel.
Every consumer in the workspace moves to the helper: both matchers, the ISRC
validator, the daemon driver, the purchases service (which maps catalog Track
into PurchaseTrack and therefore feeds the very screen the split protects), the
radio pool, the mixtape resolver and the QConnect renderer. The replaced tests
pin both directions of the new contract.
Also here, because it lives in the same file as the matcher's streamable skip:
`rank_candidates`, an additive public entry point over the existing weighted
matcher (ISRC short-circuit, title/artist/album weights, duration bonus,
stop-word normalisation, quality tie-break). `match_tracks` is untouched.
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
the_layout_is_the_same_one_persist_wrote_to compared BundleLayout::new against
the layout persist_bundle returned, which persist_bundle had built from that
same pure function: a value compared with itself. It stayed green when
persist_bundle was made to write under the wrong track id. It now asserts the
derived paths point at files that exist, and fails under that sabotage.
resolution_looks_under_the_current_root_not_the_recorded_path matched only the
root prefix in the error message, so a resolution that found the right root by
the wrong subdirectory, or the right subdirectory for the wrong track, passed.
It now matches the whole derived path, and fails when resolution is pointed at
a sibling subdirectory under the same root.
Also corrects the doc on load_cmaf_bundle: offline_root_path was described as
only locating the secret vault's install UUID, which was true before this
branch and is now the opposite of the truth. It is what locates the bundle,
which is the most load-bearing argument in the function.
Playback rebuilt the bundle layout from the absolute paths recorded in
the row. Those agree with reality until the application directory moves,
and iOS moves it: an app's data container is reassigned on reinstall, and
can change on restore or migration. Every stored path then names a
directory that no longer exists while the bundle sits untouched under the
new one, so every download reports "Downloaded copy could not be opened"
with the bytes still on disk.
Deriving is safe because the index lives inside the root
(<root>/index.db), so a row can only ever describe a bundle beneath that
same root, and persist_bundle writes nowhere else. Removal and eviction
already resolve this way; this brings playback in line with them.
Every 0.23.x release of lofty is yanked on crates.io, so the version
requirement only resolves from the committed Cargo.lock. A fresh resolve
of qbz-library or qbz-offline-cache as a dependency fails outright.
0.24.0 needs no code changes across the 19 call sites.
Restores the purchase-album detail mapper that the Slice-9 detail load path
(qbz-slint purchases.rs:2270) already references but which was left uncommitted
in the working tree (staged-list miss when committing S9). build_purchase_album
maps a fetched Album + purchase listing + downloaded ids + format map into the
detail payload (per-track + album downloaded flags, synthesized format options,
page counts). 5 unit tests; 30 offline-cache purchases tests green.
cc
Slice 7/13. qbz-slint/src/purchases.rs: ports purchaseDownloadStore as a Send
OnceLock<Mutex<>> controller singleton (AlbumDownloadState keyed by albumId,
flattened allTrackStatuses, per-album abortFlags). Actions: start/execute album
download (sequential loop, abort-check, destination rewrite after first success),
start_track_download (merges into existing state, A.7), cancel, Add-to-Library,
folder picker, skip-if-remote.
1:1 fidelity fixes surfaced during review (verified vs Svelte source):
- Album loop marks complete THEN best-effort registry write (swallow failure)
like executeAlbumDownload .catch(()=>{}); only the single-track path propagates
a registry error to failed (B.1). Adds download_purchase_track_file_only to
qbz-offline-cache (download-only) beside the bundled download_purchase_track.
- apply_cancellation cancels only tracks with NO status entry (verbatim Svelte).
cargo check + 20 qbz-slint / 25 offline-cache purchases tests green.
cc
Slice 3/13. New qbz-offline-cache/src/purchases_service.rs (pagination glue,
get_purchase_total two-call per-type-totals primitive, byte-for-byte
filter_purchase_response). New qbz-slint/src/purchases.rs controller load shells
(get_downloaded_track_ids -> HashSet, load_purchases_metadata with TWO separate
ids-totals calls, load_purchases_by_tab with total backfill + error mapping).
Replicates Tauri's 'Failed to fetch ... purchases' wrapper so network errors map
to purchases.loadFailed 1:1 (TDD-caught). 10 unit tests green. Lazy/cache/force
gating deferred to UI slices 8/9 by design.
cc
Upload to Qobuz now attaches Plex sidecar rows via the existing
add_plex_track_to_playlist helper (positions after the local block), and
the local entity is deleted only when the sidecar attach succeeds - a DB
failure keeps the playlist with an upload-incomplete toast instead of
silently dropping it.
Playlist manager: local playlists interleave into the active sort with the
same comparators as the Qobuz set (name/tracks real interleave; playcount
sorts locals as zero, custom as after-the-positioned-set, recent after the
API order - documented). u64-keyed mutators verified safe with local: ids.
Offline covers: cached_tracks.artwork_path turned out to have zero writers
in history, so CachedTrackInfo gained artwork_path + file_path and a
frontend-agnostic resolve_cover_path chain (index path, CMAF cover.jpg,
v1 sibling cover.jpg). The offline manager album rows and the local-
playlist cached rows now resolve covers through it.
cc
The CMAF download opened the SecretBox (OS keyring -> secret-service -> a
synchronous zbus D-Bus round-trip) directly on a tokio async worker, which
panicked ("Cannot start a runtime from within a runtime") and poisoned the
vault mutex — every later attempt then failed and fell back to the legacy
plain-FLAC path. Playback never hit this because load_cmaf_bundle already runs
inside spawn_blocking.
- downloader: wrap the vault open + key wrap in tokio::task::spawn_blocking
(off the async pool), mirroring the playback decrypt path.
- secret_vault: get_or_init recovers a poisoned mutex (into_inner) instead of
wedging the vault for the rest of the session.
Verified at runtime: CMAF bundle now persists (tracks-cmaf/<id>/) and plays
back decrypted; no panic, no legacy fallback.
cc
Completes Slice 0. src-tauri now depends on the qbz-offline-cache crate; the
old src-tauri/src/offline_cache/ implementation files are deleted. What remains
in that module is a thin shim: a re-export of the crate plus the Tauri glue.
- offline_cache/mod.rs: pub use qbz_offline_cache::* + tauri_cache_sink (maps
CacheEvent -> the exact legacy offline:caching_*/offline:unlock_* app.emit
shapes, so the Svelte frontend is unchanged) + core_client (resolves the core
qbz_qobuz::QobuzClient from the CoreBridge for the download pipeline).
- Cache/redownload/migration commands: drop the legacy crate::api::QobuzClient
(state.client) + the CoreBridge double-plumb; pass one core client + an
app.emit-backed CacheEventSink. Unused `state: State<AppState>` params dropped.
- Playback + library cached-resolution call sites: pass &tauri_cache_sink(..)
to load_cmaf_bundle_with_ui_events instead of &app_handle.
- session_lifecycle: apply_persisted_limit now reads the persisted value here;
purge takes &library.db directly.
- migration: qobuz_client is the Option-wrapped core client (consistent with the
downloader); None mid-loop is recorded as a per-track failure.
Full src-tauri cargo check passes; behaviour-preserving for the Svelte frontend.
Backend extraction (ADR-006) done — next is Slice 1 (shared tier-walk in core).
cc
Completes the crate extraction (Slice 0 backend half):
- state: OfflineCacheState holder (db/fetcher/cache_dir/limit/semaphore/
library_db + dir-layout helpers + new/init_at/teardown). apply_persisted_limit
now takes Option<u64> (the caller reads it from offline_settings) instead of
borrowing src-tauri's OfflineState, so the struct is frontend-agnostic and
Slint can own one too.
- purge: purge_all_cached_files takes &Mutex<Option<LibraryDatabase>> directly
instead of src-tauri's LibraryState wrapper.
The crate is now self-contained (13 modules, no Tauri). Next: re-wire the
Tauri commands onto it with an app.emit-backed CacheEventSink and delete the
old src-tauri/src/offline_cache/ module.
cc
Moves the download pipeline into the crate and removes its Tauri coupling:
- event: new CacheEvent enum (Started/Progress/Completed/Processed/Failed/
UnlockStart/UnlockEnd) + CacheFormat + the CacheEventSink callback type.
- downloader: StreamFetcher + try_cmaf_offline_download + spawn_track_cache_download
now report through a &CacheEventSink instead of tauri AppHandle::emit. The
CoreBridge param is gone — both the CMAF raw download, metadata fetch, and the
legacy stream-url fallback unify on a single core qbz_qobuz::QobuzClient handle.
TLS uses the workspace rustls (same backend qbz-qobuz uses for all CDN traffic).
- playback: adds load_cmaf_bundle_with_ui_events that emits UnlockStart/UnlockEnd
through the sink (the pure load_cmaf_bundle is unchanged).
Crate compiles headless; 5 unit tests pass. Remaining for Slice 0:
OfflineCacheState + purge, then re-wire the Tauri commands with an
app.emit-backed sink and delete the old src-tauri module.
cc
Continues Slice 0. Moves the near-pure modules, re-pointing the src-tauri
client/library types to the core crates (the make-or-break for a mechanical
extraction — verified the field shapes match):
- metadata: fetch_complete_metadata now takes &qbz_qobuz::QobuzClient (was
crate::api::QobuzClient); body compiles unchanged against qbz_models.
- migration: crate::api::QobuzClient -> qbz_qobuz::QobuzClient,
crate::library::database::LibraryDatabase -> qbz_library::LibraryDatabase,
super::metadata -> crate::metadata.
- playback: the PURE load_cmaf_bundle fn (the UI-events wrapper stays out —
it becomes a CacheEventSink call in the next commit).
Deps added: qbz-models, qbz-library, tokio, reqwest, lofty. Crate compiles.
purge + OfflineCacheState + downloader (CacheEvent) remain.
cc
Slice 0 of the offline-cache port (extraction per ADR-006). Creates the new
qbz-offline-cache workspace crate and moves the tauri-free leaf modules out of
src-tauri/src/offline_cache/ verbatim:
- types: the shared serde DTOs (was inline in offline_cache/mod.rs)
- db: the SQLite cached_tracks index (super:: -> crate::types)
- secret_vault, cmaf_store, path_validator: moved verbatim
- maintenance: super:: refs re-pointed to crate::
src-tauri is untouched and keeps its own copy, so the production build stays
green; the re-point + delete happens at the end of Slice 0. Crate compiles
in isolation and all 5 unit tests pass.
Remaining for Slice 0: playback pure-fn split, metadata/migration/purge
(re-point crate::api/crate::library -> qbz_qobuz/qbz_library), the downloader
+ CacheEvent/CacheEventSink abstraction, the OfflineCacheState struct, then
re-wire the Tauri commands and delete the old module.
cc