Commit Graph
40 Commits
Author SHA1 Message Date
vicrodh aff24e598b chore(offline-cache): drop the never-called LRU evictor and correct the comment that described it
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
2026-09-13 03:36:51 -06:00
vicrodh 17e08e93de fix(library): restore offline visibility and unify filters and sorting
Recover missing offline library entries, recognize cached-only libraries, and refresh availability after cache changes. Unify per-tab filters, compact the toolbar, and add reversible sort criteria with complete available purchase metadata.

cx
2026-09-09 17:08:33 -06:00
vicrodh 9fd39edf5f fix(tests): update embedded album fixtures
Initialize the four new optional AlbumSummary fields in all six test fixtures. Preserve absent metadata semantics and restore workspace test compilation.

cx
2026-09-05 20:30:50 -06:00
vicrodh f01950571d fix(purchases): restore local copy and download semantics
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
2026-09-05 12:10:55 -06:00
vicrodh 40df0d83be feat(library): keep the artist page's in-library rows live
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
2026-09-04 10:41:51 -06:00
vicrodh c0a37e6b87 feat(purchases): manage independent download copies
cx
2026-09-03 05:13:05 -06:00
vicrodh 59bd3ab6b2 feat(purchases): add copy-aware download receipts
cx
2026-09-03 04:36:41 -06:00
vicrodh 561b3c25c7 feat(purchases): gate album actions by entitlement
cx
2026-09-03 04:32:23 -06:00
vicrodh 2d7a3ef722 fix(purchases): offer the hi-res streaming rungs only where hi-res streams
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
2026-09-02 01:42:01 -06:00
vicrodh 065d63369a feat(purchases): master-size cover and every goodie ride the album download
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
2026-09-02 00:18:31 -06:00
vicrodh 554d43cfdf feat(playback): play the purchased copy of a Qobuz track when it is on disk
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
2026-09-02 00:08:46 -06:00
vicrodh 7d302e0c04 feat(purchases): offer the streaming ladder below the purchased quality
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
2026-09-01 23:59:37 -06:00
vicrodh 66f49467f2 fix(purchases): file a downloaded album under its album artist
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
2026-09-01 23:42:14 -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 6d8c951a38 chore(deps): rusqlite 0.40 (u64 casts; a latent two-statement execute fixed)
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
2026-08-29 08:36:21 -06:00
vicrodh b6c69509df build(deps): unify lofty at 0.25.1
cx
2026-08-26 21:31:47 -06:00
vicrodh c039f0d0ee build(deps): integrate lofty 0.24 update (#679)
cx
2026-08-26 21:29:02 -06:00
vicrodh 0a934ae4a0 fix(offline-cache): integrate relocatable CMAF bundles (#696)
cx
2026-08-26 17:44:20 -06:00
vicrodh 69ed612077 refactor(frontend): retire Slint application
cx
2026-08-26 11:10:18 -06:00
vicrodh 371be04be8 feat(models): Track.streamable becomes Option<bool>, and absence stops meaning dead
`#[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
2026-08-17 14:40:57 -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
Afonso Jorge Ramos 88e9354b80 test(offline-cache): make the two path guards able to fail
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.
2026-08-12 12:45:00 +02:00
Afonso Jorge Ramos 3238244de5 fix(offline-cache): locate a CMAF bundle under the root the cache is open at
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.
2026-08-11 13:23:21 +02:00
Afonso Jorge Ramos 4e3638866e build(deps): bump lofty to 0.24
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.
2026-08-04 00:53:12 +02:00
vicrodh dfc74fb6d4 Merge branch 'pr/606' into integration/pr-review-smoke-2026-07-13 2026-07-13 14:27:23 -06:00
Afonso Jorge Ramos 61df66c529 fix(offline): purge only allowlisted cache layout directories
Stop recursive-deleting arbitrary siblings of the offline root so a
mis-set cache path cannot wipe unrelated folders on a shared mount.
2026-07-13 13:49:55 +02:00
Afonso Jorge Ramos 7e824bd65a fix(offline): place download size validator at module scope 2026-07-12 23:57:18 +02:00
Afonso Jorge Ramos f507b5c214 fix(offline): reject truncated downloads when Content-Length is known
Fail closed on short bodies and empty payloads so partial files never
become complete cache entries.
2026-07-12 23:56:59 +02:00
vicrodh eab178c1e3 feat(purchases): add build_purchase_album detail mapper (service)
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
2026-06-24 17:12:58 -06:00
vicrodh 44aa4e1462 feat(purchases): add download state machine + actions (controller)
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
2026-06-24 06:23:04 -06:00
vicrodh 9e676441e4 feat(purchases): add single-track download primitive
Slice 5/13. qbz-offline-cache/src/purchases_service.rs: download_purchase_track
(get_track -> getFileUrl(format) -> download_audio -> .part write -> rename ->
registry), target_path (sanitized {dest}/{artist}/{album [quality]}/{NN-title.ext}),
purchase_extension (keys off RESPONSE format/mime). Blind-test quirks 1:1:
records REQUESTED format_id while ext derives from RESPONSE (B.2), no collision
preflight / silent overwrite (B.3), restrictions ignored (B.5), file->rename->
registry ordering with orphaned-file-on-registry-failure (B.1). Tests green.

cc
2026-06-24 04:37:05 -06:00
vicrodh b87378349e feat(purchases): add format synthesis + download-flag annotation
Slice 4/13. qbz-offline-cache/src/purchases_service.rs: synth_formats(&Album)
ports v2_purchases_get_formats (ids 27/7/6/5, exact labels, highest-first order,
hires + sr>96 conditions, default index 0); apply_download_flags ports
v2_apply_purchase_download_flags (per-track downloaded + downloaded_format_ids;
per-album all-nested-ids-in-dlIds with empty->false page-mode gotcha). Frontend
overrides backend downloaded. Unit tests green.

cc
2026-06-24 04:25:03 -06:00
vicrodh bc44fe5e0c feat(purchases): add pagination + per-type totals service + controller load shells
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
2026-06-24 04:16:14 -06:00
vicrodh 63e0e386ba feat(slint): plex rows attach on upload, manager sort interleaving, offline cover chain
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
2026-06-10 01:51:51 -06:00
vicrodh 4041499114 fix(offline-cache): run the secret-vault open off the async pool (no zbus panic)
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
2026-05-28 08:28:46 -06:00
vicrodh c8158bf419 refactor(offline-cache): re-wire Tauri onto qbz-offline-cache; delete old module
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
2026-05-27 20:09:09 -06:00
vicrodh b17bbbd442 feat(offline-cache): move OfflineCacheState + purge into the crate
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
2026-05-27 19:50:23 -06:00
vicrodh fc6bb7c559 feat(offline-cache): headless download pipeline via CacheEvent sink
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
2026-05-27 19:44:21 -06:00
vicrodh 5f88201736 feat(offline-cache): move metadata/migration + playback pure-fn into the crate
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
2026-05-27 19:27:39 -06:00
vicrodh d6e9345f5a feat(offline-cache): scaffold qbz-offline-cache crate with the tauri-free core
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
2026-05-27 19:08:52 -06:00