Commit Graph
5042 Commits
Author SHA1 Message Date
vicrodh 89734ae3ab fix(slint): instant local queue, mouse nav buttons, narrower toolbar controls
- Local Library Tracks: build the play queue from the already-loaded rows
  (instant) instead of re-querying the DB + filling covers on every click.
- Mouse back/forward buttons now drive history nav over track rows and album
  cards (a TouchArea swallows them, so each interactive surface forwards them;
  the AppShell catcher only saw the gaps).
- ExpandableSearch open width trimmed 20px (no longer rides over left controls
  on small screens).
- QbzSelect toolbar (Group/Sort/filter) trimmed 40px total.

cc
2026-05-30 16:53:11 -06:00
vicrodh 13f43b802b fix(slint): flyouts/idle, header nav layout, toasts, settings, search
Flyouts & idle (main effort):
- Header dropdowns now glue under the tab: open-menu resets from-sidebar +
  title (a stale sidebar flyout state was flinging the panel far away).
- Idle-close now pauses while the cursor rests on the TRIGGER too (full
  header tabs gained the trigger-hovered logic the others already had).
- Dropped the dropdown caret (saves width on small screens).

Header nav layout:
- Section nav moved INTO the left-controls cluster, after the three sacred
  buttons, so it never overlaps them. Full text tabs when nav-in-sidebar off
  and sidebar not closed; compact icons when closed — mutually exclusive (no
  more duplicate/cluttered header).
- Search field gives up 60px (animated) when the header hosts the nav.

Settings made functional:
- Toast component mounted in AppShell (it was never rendered) + the in-app
  toasts toggle drives ToastState.enabled.
- "Show album artwork in Local Library track list" now drives the Tracks/
  Folders rows. Both rows un-flagged as unsupported.

Collapsible search (ExpandableSearch, app-wide):
- Smooth open AND close (width animates both ways; magnifier fades).
- Reliable auto-focus on open (deferred via a one-shot timer).

cc
2026-05-30 15:59:25 -06:00
vicrodh 682719cab0 feat(slint): library/sidebar UX overhaul + theme switching + fixes
Shell & navigation:
- Three-state sidebar (open/mini/closed); collapse cycles via header button.
- Section nav as right-flyouts (sidebar) + compact header nav (closed) +
  full header tabs (nav-in-sidebar off), via a shared HeaderMenuOverlay.
- Collapsed-folder right-flyout popup (max 4 + scrollbar), Rust-populated.
- Thin auto-hiding sidebar scrollbar pinned to the edge (Flickable, drag-safe).
- Slack-style bezel: rounded inset content panel over surface-card chrome.
- Header restructure: nav buttons, window-centered search, app menu.

Themes & components:
- Live theme switching: Dark / OLED Black / Tokyo Night / System (std-widgets).
- SegmentedTabBar (Discovery-style) with count badges for Library + Local Library.
- MP3 quality-badge tier (format-first classification) across badges.
- QbzSelect (Group/Sort) trimmed 20px.

Behavior fixes:
- Artist + Local Library Folder cover art now reach the rendered (visible/
  grouped) models, not just the source set.
- Favorites Playlists/Labels random buttons.
- Playlist track click plays from the clicked track in visible order.
- Dropdown idle-close pauses while the cursor rests on the menu.

cc
2026-05-30 15:40:35 -06:00
vicrodh 201082d4a1 feat(locallibrary): complete Albums 1:1 + dedicated local album view with versions
Albums tab — full 1:1 with Tauri in one pass:
- Switch from the chunked-paginated grid to a full-load + in-memory derive so
  grouping/filtering span the whole set. derive_albums = search + sort + group
  (off / by letter with A-Z jump / by artist, both with section headers) + the
  quality/format/source filter (1:1 with matchesQualityFilters: OR within a
  group, AND between groups; empty group passes).
- Grid/list view toggle; the list view shows a SOURCE column (Local/Offline/
  Plex), placed after TYPE. Filter popup now FLOATS (sibling of the content
  layout, not a layout slot) with an active-count badge. Fixed the year sort
  (sort_album_items maps year-asc/desc — was also silently broken in Folders).
- Album covers dual-set by id across the full set + visible + grouped sections.

Dedicated local album view — SEPARATE from the Qobuz AlbumView:
- New ContentView::local-album + NavEntry::LocalAlbum + ui/album/LocalAlbumView.slint,
  reusing TrackRow/NavButtons/CircleAction/QualityBadge internals but ONLY local
  actions (play all / shuffle / edit tags / add to playlist) — no Qobuz chrome.
- Versions: an album's tracks are split by source directory into versions, so
  multiple copies of the same album no longer merge into a duplicated track
  list. Best-quality version is the default; a source-icon version picker
  (hard-drive / Qobuz / Plex glyph in both the selected display and the options)
  switches the shown copy in place. The album cover is the best-quality version
  that has one, falling through versions, else a placeholder (covers backfilled
  from disk via fill_missing_covers). Header padding-top 11px matches the app.

Fix: Artists tab deadlock — fetch_missing_artist_images snapshotted names with a
blocking rx.recv() called from inside an event-loop closure, freezing the UI
(static spinner, no error). Names are now snapshotted on the UI thread and
passed in; the worker never blocks the event loop.

cc
2026-05-29 19:16:09 -06:00
vicrodh 3c2f46b756 feat(locallibrary): tag editor remote lookup (MusicBrainz + Discogs)
Complete the tag editor with remote metadata auto-fill.

- qbz-integrations: add frontend-agnostic `discogs` (client + DTOs, Tauri-free)
  and `remote_metadata` (unified DTOs + the pure MusicBrainz/Discogs ->
  RemoteAlbum* converters). Copied from src-tauri so the Slint frontend can do
  remote lookup without depending on the Tauri binary; src-tauri is left fully
  untouched (the production build keeps its own copy — no Tauri-build risk).
- tag_editor.rs: search_remote (provider QbzSelect; MusicBrainzClient::new
  search_releases_extended / DiscogsClient search_releases, generation-guarded),
  select_result, apply_remote (get_release_with_tracks / get_release_metadata ->
  full metadata; overwrites album fields + positional per-track title merge;
  track-count-mismatch + rate-limit toasts), open_in_browser.
- TagEditorModal: a remote-lookup box (provider + Search, a horizontal result
  card strip with select highlight, Apply + open-in-browser). ADR-008: square
  badges, no pills.
- state.slint: RemoteResultItem struct + remote fields/actions on TagEditor*.

LocalLibrary Slint port is now feature-complete (scan/folders, artists images,
tag editor manual+remote, tracks bulk). BUILT BLIND — runtime smoke-test pending.

cc
2026-05-29 15:32:16 -06:00
vicrodh 5a076187e7 feat(locallibrary): Tracks tab — group-by + multi-select + bulk actions
Bring the Tracks tab up to the Favorites precedent.

- Group-by (Off/Album/Artist/Name) with visible album/artist header rows + an
  A-Z jump strip for Name (over the loaded set); show-album hidden under album
  grouping. Group mode persists via a new locallibrary_prefs.rs (favorites_prefs
  precedent; default "off"), loaded in navigate_local_library before the tab
  derives.
- Multi-select toggle (toolbar) + per-row checkbox (TrackRow multi-select-mode)
  + MultiSelectBar with the Tauri Tracks subset: Select all / Add to queue /
  Play next / Add to playlist / Clear (no make-offline, no remove).
- derive_tracks (reduced port: local folder_alpha_key, no genre filter) +
  TRACKS_CURRENT selection-source cache (cloned in apply/append before the move)
  so bulk resolves ids -> LocalTrack in DISPLAY order with no DB round-trip.
- playback::enqueue_local_tracks (source-aware via local_queue_track; reversed
  for play-next); per-row play-next/queue wired in on_track_action.
- Add-to-playlist for local + qobuz_download via PlaylistPickerState.local-mode
  + on_pick i64 branch -> db.add_local_track_to_playlist; Plex rows skipped
  (decided on the underlying LocalTrack.source, not the remapped TrackItem one).
- No-match empty state added.

cc
2026-05-29 15:18:09 -06:00
vicrodh 4e79386daf feat(locallibrary): tag editor modal — manual edit + sidecar/direct save
The local-album tag editor (no remote lookup yet — that lands with the
remote-metadata crate move). Opens from a pencil on the local album detail.

- TagEditorModal.slint: album title/artist + year/genre/catalog + per-track
  title/track#/disc# table (indexed model write-back) + persistence QbzSelect
  (sidecar default / direct opt-in) + save-with-progress. Mounted last in
  AppShell (ADR-009 by mount order). ADR-008: dropdown, no pills.
- tag_editor.rs controller: open_tag_editor (off-thread pre-fetch via
  fetch_album_tracks_blocking + D1 year seed + can-direct-write CUE guard),
  save_tags (validate, is_dir gate, CUE guard, one-time direct-write confirm
  via rfd + library_kv ack, sidecar/direct branch on spawn_blocking with
  per-file progress, update_album_group_metadata via new with_db_mut, post-save
  refresh of the album + browse models), close.
- AlbumPageView: edit pencil CircleAction, visible is-local && source != plex;
  AlbumState gains `source`, set in apply_local_album.
- library_db: with_db_mut for the transactional metadata update.
- main.rs: on_media_action("album","edit") + TagEditorActions wiring (close/
  save/set-persistence with the CUE gate).

cc
2026-05-29 15:03:58 -06:00
vicrodh 7e0d970fd5 feat(locallibrary): tag-editor core — lofty writer + kv + artist-match (qbz-library)
Frontend-agnostic foundation for the LocalLibrary tag editor (consumed by the
Slint modal + controller in a follow-up slice; the Tauri direct-write command
can later fix-forward onto this):

- tag_writer.rs: write_album_tags_to_files() ports the Tauri lofty loop 1:1
  (dedup by file_path first-wins, per-file on_progress closure instead of a
  Tauri event, destructive remove-on-None for album-artist/year/genre/catalog,
  honest partial-failure error wording) + compute_track_artist_match().
- database.rs: a small library_kv (key,value) table + get_kv/set_kv (upsert) to
  persist the direct-write one-time acknowledgement (replaces Tauri localStorage).

cc
2026-05-29 13:36:09 -06:00
vicrodh 7bef91b3d7 feat(locallibrary): Artists rail portraits + capped Qobuz photo fetch
Render artist portraits on the two-column Artists rail and fill missing ones
from Qobuz in the background.

- qbz-library: new get_all_artist_image_urls() bulk reader (custom path else
  cached Qobuz url, keyed by artist_name) so previously-fetched portraits
  reappear on revisit without re-hitting Qobuz. (The Tauri batch command was
  never registered; this is the corrected one-pass reader.)
- state.slint: LocalArtistItem gains a decoded `image` field; LocalLibraryState
  gains the fetch toggle + progress props. merge_artists now returns a Send-safe
  ArtistRow (no slint::Image) across spawn_blocking; apply_artists builds the
  LocalArtistItem with the (default-empty) image on the UI thread.
- ensure_artists_loaded: seeds the rail from get_all_artist_image_urls, spawns
  decode jobs by scheme (local path -> spawn_local_loads, http -> spawn_loads),
  and kicks the capped fetch.
- fetch_missing_artist_images: sequential worker, max 50/session, 1s apart,
  generation-guarded, exact-normalized match only (no wrong-artist persist),
  paints each portrait immediately, persists via cache_artist_image_with_canonical.
- artwork.rs: Copy-safe LocalArtistRowImage { index, gen } target + apply arm
  that resolves index -> name and routes through the new name-keyed dual-setter
  set_artist_row_image (flat master + every grouped section), so portraits
  survive a derive_artists re-run.
- LocalArtistRow renders the decoded image (placeholder until it lands).

cc
2026-05-29 13:31:58 -06:00
vicrodh aa7c320b94 feat(locallibrary): library scan engine + progress (Slice B)
Lift the Tauri scan-orchestration loop into the core crate and drive it from
Settings > Local Library with live progress + cancel.

Core (qbz-library/src/scan.rs): scan_with_progress(db, folder_ids, artwork_cache,
cancel, on_event) replicates v2_library_scan (full) and v2_library_scan_folder
(single) byte-for-byte: CUE files first, per-album sidecar override, embedded ->
folder artwork with a per-folder cache, insert, and missing-file cleanup (whole
DB for full, folder-prefix for single). Progress is pushed through a ScanEvent
callback (no IPC poll) and a cancel AtomicBool is checked at every file boundary
(no cleanup/scan-time on cancel, Tauri parity). Two verified improvements: the
full scan now stamps each folder's last_scan, and network status is re-detected
for every non-overridden scanned folder.

Slint: LibraryScanState global (single poll target; progress computed Rust-side);
a determinate 2-rect progress bar + counts + current-file + Stop button in the
panel; scan-all disabled while running. Controller run_scan() on spawn_blocking
with a ~100ms-coalesced sink (terminal/phase events bypass throttling), an owned
SCAN_CANCEL token, an outcome toast, and a post-scan reload (folder list +
reset browse models so the tabs re-fetch). scan-all/scan-folder/stop-scan wired.

cc
2026-05-29 13:16:21 -06:00
vicrodh 3826298f77 feat(locallibrary): Settings > Local Library — folder management (Slice A)
Port the Tauri folder-management surface to a new Settings > Local Library
sub-section (index 4); the browse-view gear routes here and pre-selects it.

UI:
- LocalLibrarySettings.slint: folder list (icon, display name, disabled/
  unavailable badges, multi-select checkboxes) + toolbar (scan-all/add/edit/
  remove icon buttons) + filter + Maintenance (cleanup missing) + two-step
  Danger Zone clear. ADR-008: plain bordered icon buttons + square badges.
- LibFolderEditModal.slint: alias, enabled, network override + fs-type,
  change-path, accessibility status, last-scanned, scan-this-folder. Mounted
  at the AppShell root (overlays the window, ADR-009 by mount order).
- SettingsView gains the section-4 sub-nav + panel; app.slint re-exports the
  new globals so the Rust layer can populate them.

Controller (local_library_settings.rs): load/add/remove/edit/save/change-path,
cleanup-missing (stats outside the DB lock), two-step clear, filter, select.
All DB access via qbz-library through library_db::with_db on spawn_blocking;
native dir picker + confirms via rfd. Selection preserved across reloads;
network folders get an async 6s-timeout accessibility check (exists() fallback
on timeout, mirroring Tauri).

Core (qbz-library): network_fs_label() for fs-type parity; separator-safe
delete_tracks_in_folder_prefixed() + remove_folder_with_tracks() (fixes the
prefix-collision bug without changing the legacy fn). Scan engine lands in
Slice B.

cc
2026-05-29 13:07:19 -06:00
vicrodh 5986779d85 feat(locallibrary): Artists tab rebuilt as two-column master/detail
Replaces the thin name+counts grid (which was built without a Tauri
review) with the real structure: a two-column master/detail, 1:1 with
the Svelte Artists tab.

Left rail: a merged/deduped, A-Z grouped master list of compact rows
(round avatar + display name + "N albums · M tracks") with a proportional
alpha jump strip and in-place search. The name-merge collapses
normalized-equal spellings into one canonical row (canonical = most
albums, tie by most tracks) and computes accurate unique album counts
from the album set's all_artists, mirroring Tauri's artistMergeResult.
Diacritics are folded best-effort (no unicode-normalization dep).

Right pane: the selected artist's albums, filtered IN PLACE from the
loaded album set by primary/all_artists/multi-credit match (no new
backend call) and rendered via the shared AlbumCollectionView; covers
load through a new LocalArtistAlbumCard artwork target. Clicking an
album opens the local album detail; the play action queues the album.

Custom DB artist images are resolved into the rail model; rendering them
(plus the capped/sequential Qobuz photo fetch) is the remaining image
follow-up — the rail shows the placeholder avatar until then.

cc
2026-05-29 09:40:52 -06:00
vicrodh d676a2f5a4 feat(locallibrary): Folders tab complete — flat toolbar + tree two-pane
Flat mode now has the full toolbar: search, sort (artist/title/year),
group-by (off/letter/artist) and a grid/list view toggle, driven by a
derive_folders() pass that mirrors favorites::derive_albums over the
directory-grouped album set (folders-visible / folders-grouped).

Tree mode is a new two-pane filesystem browser: a flattened FolderNode
list in the left rail (lazy one-level expand via list_folder_children,
collapse drops the descendant block, recursive track counts gate the
chevron) and a detail pane on the right showing the selected folder's
direct tracks plus immediate subfolders, with Play all (recursive) and
per-track play scoped to the folder's direct list. A flat/tree segmented
toggle switches modes; the tree loads its roots lazily on first switch.

Slint has no native TreeView, so the tree is a depth-indented ListView of
visible nodes spliced on expand/collapse. No pills (ADR-008): underline
tabs, small-radius rectangles and an accent selection bar.

cc
2026-05-29 09:17:47 -06:00
vicrodh c36b99b8e8 fix(locallibrary): track-row source indicator (drop fav/offline) + AlbumView favorite item
LocalLibrary track rows (Tracks tab + local album detail) no longer carry the
favorite or offline-cache columns — they don't apply to local content. Instead
they show a subtle per-source indicator (local -> hard-drive tinted; qobuz
offline / plex -> their mark), small + flat rather than the bold album-card
badge (mirrors Tauri's track-row local indicator). TrackItem gains a `source`
field, set by every constructor (Qobuz surfaces -> "qobuz"; map_local_track ->
local/qobuz/plex); TrackRow gains `show-source`.

Also fixes Qobuz AlbumView: the track list had a Favorite column header but the
rows never rendered the heart (show-favorite defaulted false). AlbumPageView
now sets show-favorite + show-download for Qobuz albums (matching the header)
and the source indicator for local albums, with the column header switched to
match each.

cc
2026-05-29 08:43:26 -06:00
vicrodh 6e1009afd0 feat(locallibrary): Artists tab — name + counts grid
Fills the last empty tab: a full-loaded grid of artists (get_artists_with_filter)
with album/track counts, inline circular cards (placeholder icon for now). All
four Local Library tabs (Albums / Tracks / Folders / Artists) now browse. The
navigate match is exhaustive over LibTab. Artist images, click-to-detail, and
name-merge dedup (Tauri's artistMergeResult) are later slices.

cc
2026-05-29 07:44:40 -06:00
vicrodh df351ec6f6 feat(locallibrary): Folders tab (flat) — folder-grouped album grid
Fills the Folders tab: a full-loaded grid of albums grouped by directory
(get_albums_with_full_filter), via the shared AlbumCollectionView (grid +
source badge), covers loaded from disk, loaded once on first visit. Clicking
a folder album opens its detail + plays it — navigate_local_album and
play_local_album now resolve tracks via fetch_album_tracks_blocking, which
tries the metadata grouping (Albums tab) then the folder grouping (Folders
tab), so both album views work. New ArtworkTarget::LocalFolderCard applies
covers to the folders model.

Tree mode (two-pane lazy folder tree) + folder search/sort are later slices.

cc
2026-05-29 07:37:47 -06:00
vicrodh 79551cebb7 feat(locallibrary): local album detail (reuses AlbumPageView)
Clicking a local album (grid card or a track row's album link) opens its
detail in the shared AlbumPageView. navigate_local_album loads the album's
metadata-grouped tracks (get_album_tracks_metadata) + fills folder covers,
populates AlbumState (title / album-artist or "Various Artists" / cover /
quality / "N tracks · duration" / disc-ordered TrackRows) with a new is-local
flag, and resolves the cover from disk. Play routes to local: the media-action
dispatcher gains a guard — when the album view shows a local album, album play
-> play_local_album, a track row -> play the album from that track; Qobuz
album/track play is untouched (guarded by view==Album && is-local).

cc
2026-05-29 07:26:21 -06:00
vicrodh b9b6d141f8 fix(locallibrary): now-playing + queue cover art and current-track sync (local/offline)
Three playback-correctness fixes reported after testing:
- Album/offline cover art now reaches the now-playing bar AND the queue:
  fill_missing_covers fills a track's artwork from the folder cover when the
  index has no artwork_path (the offline-cache writes cover.jpg into the track
  folder but doesn't always backfill the index), applied when building local /
  album play queues. The cover that exists on disk shows, not just on the grid.
- Queue-panel art is source-aware: load_artwork routed every cover through an
  HTTP fetch, so local file:// covers silently failed. It now parses the url
  into an ArtworkRef (Remote vs LocalFile) and decodes local files from disk.
- now-playing/queue current-track mismatch: the poll loop treated ANY track-id
  change while playing as a gapless hand-off and advanced the queue pointer,
  which falsely fired on a manual new-track play and desynced now-playing from
  the queue (erratically, by timing). It now advances only when the new id is
  the expected upcoming track (a real gapless next); Qobuz gapless is unchanged.

cc
2026-05-29 01:24:52 -06:00
vicrodh 460f7659bd fix(locallibrary): align the Tracks scrollbar with the Albums grid (chrome-bezel standard)
The Tracks list scrollbar sat inside the 32px content padding (too far into
the content). Move it to the view's true right edge — the position the Albums
grid already uses, which is the standard for chrome-bezel views: a padded
VerticalLayout holds the ListView while the ListScrollbar floats at the
full-width edge.

cc
2026-05-29 01:24:52 -06:00
vicrodh bcfab8e078 feat(locallibrary): album/list play + source-aware auto-advance (playback Stage 2)
A Local Library album, or the Tracks list, now plays through with auto-advance
across mixed local/offline sources.

The one change to the shared playback path is additive and provably safe for
Qobuz: play_audible gains a guard at the top — when the CURRENT queue track's
source is "local" (and its id matches), it plays the file from disk via
play_local_file_audible (read -> player.play_data + CUE seek); otherwise it
falls through to the EXISTING offline/Qobuz tier-walk, byte-for-byte unchanged.
Qobuz/offline queue tracks never carry source "local", so their behavior cannot
change. Auto-advance, next/previous and play-all all route through play_audible,
so they become source-aware for free. current_track() releases the queue read
lock before play (no nested lock / deadlock).

- qbz-player: QueueManager::current() (additive).
- qbz-core: QbzCore::current_track() (additive).
- qbz-slint playback.rs: source-aware play_audible + play_local_file_audible
  (extracted) + play_local_tracks_now + play_local_album
  (get_album_tracks_metadata) + play_local_tracks_from (search_with_filter);
  replaces the Stage-1 single-track helper.
- main.rs: album card "play" -> play the album; Tracks row "play" -> play from
  that row down the searched list. (play-next / queue still stubbed.)

Built green (cargo check). Local-file gapless isn't wired (local tracks aren't
prefetched, so local/mixed transitions use the simple track-ended -> next path);
Qobuz gapless is unchanged. Runtime smoke-test pending: (1) Qobuz streaming
still plays + advances, (2) an offline album advances, (3) a local album/list
advances.

cc
2026-05-29 00:50:40 -06:00
vicrodh 5349c947a6 feat(locallibrary): play a track from the Tracks tab (local + offline)
First source-aware playback stage. Clicking a track in the Local Library
Tracks tab now plays it, mirroring Tauri's v2_library_play_track routing:
- offline-cached (qobuz_download) tracks reuse the shared tier-walk
  (after_track_change -> play_track_resolved -> offline resolve -> play_data),
  keyed by the Qobuz id;
- local user files are read from disk and handed to the player's play_data
  seam (which extracts the sample rate + drives the PROTECTED device init,
  untouched), with a CUE seek for virtual tracks.

Additive: play_local_library_track + local_queue_track (mirrors Tauri's
local_track_to_queue_track) are new; the shared Qobuz play_audible path is
unchanged (no regression risk to streaming). Single-track play (a 1-track
queue) — album/queue + auto-advance need a source-aware advancement handler
and land in the next stage. Built green (cargo check -p qbz-slint);
runtime smoke-test pending (a local FLAC + an offline track should play).

cc
2026-05-29 00:37:40 -06:00
vicrodh 56b3ca790b feat(locallibrary): Tracks tab — server-paginated flat list
Adds the Tracks browse tab. Each page is a search_with_filter_page query
(new paged variant of search_with_filter) appended on scroll-near-bottom, so
the list never materializes the whole table — the documented ~16K-row Tracks
freeze. Reuses the shared TrackRow in a virtualized ListView + ListScrollbar
(the Favorites Tracks pattern) and the Albums controller's generation-guard
(TRACKS_GEN) so a search/retry can't be clobbered by a stale in-flight page.

- qbz-library: search_with_filter_page(query, offset, limit, include_qobuz,
  exclude_network) — same ordering as search_with_filter, LIMIT/OFFSET.
- local_library.rs: map_local_track (LocalTrack is Send -> crosses
  spawn_blocking; TrackItem built on the UI thread), fetch_tracks_page,
  apply/append, reload/ensure/load_more (TRACKS_GEN-guarded).
- LocalLibraryView.slint: Tracks body (ListView + TrackRow, show-artwork off
  per Tauri's perf default, show-favorite/show-download off) + a search
  toolbar + loading/error/empty states.
- state.slint + main.rs: tracks state/actions + lazy load on first visit +
  search/load-more/retry wiring.

Group-by, multi-select/bulk, and per-row playback land with the source-aware
playback slice (track-action is a stub, like Albums). Built green
(cargo check -p qbz-slint); runtime smoke-test pending.

cc
2026-05-29 00:17:51 -06:00
vicrodh b412293f3c fix(locallibrary): make the album folder-cover fallback robust
Generalize resolve_album_cover_fallback beyond a single track + cover.jpg:
scan several of the album's tracks (CMAF keeps each track in its own folder,
only some of which may carry a cover), check both the track folder and its
parent (multi-disc layouts keep art one level up), and accept the common
local cover filenames (cover/folder/front, jpg/png). Resolves more on-disk
covers without touching the paged query. Albums whose offline bundle never
saved a cover (segments only) still need an offline-cache cover re-fetch.

cc
2026-05-29 00:03:48 -06:00
vicrodh 3fc724af84 fix(locallibrary): show offline/folder album covers + add source badge
Two fixes from the first Albums-grid test pass.

Offline/folder covers (the reported "cover exists in the folder but isn't
shown"): some albums — offline-cached CMAF bundles and folder downloads —
have no artwork_path in the index even though a cover.jpg sits in the track
folder (older offline-cache downloads didn't backfill it). Add a frontend-
agnostic LibraryDatabase::resolve_album_cover_fallback(group_key) that finds
a representative track, derives its folder (the path itself for a CMAF bundle
dir, else the parent), and returns <folder>/cover.jpg when it exists. The
Slint Albums mapper calls it whenever the album has no artwork. Verified
against the live index: it resolves the folder covers that exist on disk
(FLAC-layout + CMAF-with-cover). Older CMAF bundles that never downloaded a
cover (segments only) still show the placeholder — those need a cover
re-fetch (offline-cache maintenance), tracked separately.

Source badge: the Local Library album card now shows the always-visible
source badge inside the artwork (bottom-right) — a hard-drive glyph for local
files, the Qobuz logo for offline copies, the Plex logo for Plex. Added
AlbumCard.show-source-badge (default off; threaded through AlbumCollectionView
/AlbumGrid) + AlbumCard.source; album_map now carries the real source and
map_local_album maps user->local / qobuz_download / plex. Small rounded
square, not a pill (ADR-008). Logos copied into the Slint icon assets.

Built green (cargo check -p qbz-slint); runtime smoke-test pending.

cc
2026-05-28 23:40:58 -06:00
vicrodh 835a6cad85 feat(locallibrary): Albums tab — server-paginated chunked grid
First Local Library data slice. The Albums tab browses the metadata-grouped
local albums via the performant server-paginated path (the same query Tauri's
chunked store uses), reusing the shared AlbumCollectionView grid — no new
rendering. Scales past the documented ~16K-row Tracks freeze by never
materializing the full set.

- local_library.rs: map_local_album (LocalAlbum -> the plain album_map
  AlbumCard, Send-safe; the non-Send slint::Image conversion via to_item
  happens on the UI thread), local_quality (Hz -> kHz), sort_params; a
  blocking fetch_albums_page over LibraryDatabase::get_albums_metadata_page
  (spawn_blocking); apply/append + reload/ensure/load_more driven by an
  ALBUMS_GEN generation guard so a search/sort/retry can't be clobbered by a
  stale in-flight page.
- LocalLibraryView.slint: Albums body reusing AlbumCollectionView (grid,
  show-favorite: false), a search + sort toolbar (both server-side), auto
  load-more on scroll-near-bottom (DiscoverBrowse pattern), and loading /
  error+retry / empty states.
- Local covers: ArtworkTarget::LocalAlbumCard + spawn_local_loads resolve the
  on-disk cover path through the source-aware ArtworkRef::LocalFile pipeline;
  the target carries the generation so a cover decoded after a reload is
  dropped instead of landing on a replaced row.
- state.slint: LocalLibraryState album fields + LocalLibraryActions callbacks.
- main.rs: navigate_local_library lazily loads page 1 on first visit; the
  on_albums_* callbacks wire search/sort/load-more/retry (open-album /
  open-artist / album-action are TODO no-ops until the detail + playback slices).

List/group/A-Z/quality filters, album detail, and playback are later slices.
include_qobuz_downloads mirrors the show_in_library setting default (true).
Built green (cargo check -p qbz-slint); runtime smoke-test pending.

cc
2026-05-28 22:44:18 -06:00
vicrodh 6974721b7a feat(albumcard): add show-favorite flag to hide the heart in non-favoritable contexts
The shared AlbumCard always rendered the favorite heart (overlay button +
"Add to favorites" menu item). Local Library albums are not Qobuz-favoritable
(Tauri's AlbumCardLibraryLite hides favorite/genre), so the upcoming local
grid needs to suppress it.

Add an additive `show-favorite: bool` (default true) on AlbumCard, threaded
through AlbumCollectionView -> AlbumGrid. The fav overlay button uses
`visible:` (not an `if`) so it stays referenceable by `overlay-on` (keeping
the Qobuz hover-no-flicker behavior intact); the menu item is gated with an
`if`. All existing callers (Discover / Favorites / Label) default to true,
so behavior is unchanged.

cc
2026-05-28 22:44:04 -06:00
vicrodh 1dfc22dd37 fix(locallibrary): rebuild view chrome on the mandatory fixed-chrome nav header
The nav-scaffolding view (c50ae171) invented its own chrome: a title+stats
header with no NavButtons (so no back/forward history nav), plus a tab bar
of border-radius:18px pill chips — an ADR-008 violation. Rebuild it 1:1 on
the app's mandatory fixed-chrome header (the same FavoritesView/Discover
use):

- Row 1: NavButtons (back/forward) + "Local Library" title (left), the tab
  menu floated right. Tabs are underline tabs (LibTab/LibTabMenu mirroring
  FavTab/FavTabMenu) — no pills.
- The settings gear becomes a standalone icon-only circle matching
  NavButtons (the one circular shape ADR-008 permits), routing to
  Settings > Local Library.
- Row 2 (per-tab actions + toolbar) is established with vertical-stretch:0
  so it doesn't inflate while empty; filled per slice (Albums next).
- 1px divider, then a single scrolling content area with a placeholder.

Wiring (select-tab / open-settings, nav, mount) is unchanged — only the
.slint chrome was rebuilt.

cc
2026-05-28 21:48:06 -06:00
vicrodh c50ae17102 feat(locallibrary): nav scaffolding for the Local Library Slint view
Greenfield Local Library port, slice 1 — the view now mounts and navigates
end to end (empty placeholder body; per-tab data lands next):

- state.slint: `ContentView::local-library` + `LocalLibraryState`
  (active-tab / loading / load-error / per-category counts) +
  `LocalLibraryActions` (select-tab, open-settings). Re-exported from
  app.slint so the globals reach the Rust API.
- ui/locallibrary/LocalLibraryView.slint: header (title + stats + gear) +
  4-tab bar (Albums / Artists / Folders / Tracks) + placeholder body.
- shell: AppShell mounts the view on `local-library`; HeaderBar's
  Local Library menu items now carry routes (local-albums / -artists /
  -folders / -tracks) instead of being inert.
- nav.rs: `NavEntry::LocalLibrary { tab }` — per-tab history entry,
  mirroring Favorites / Discover (back/forward moves between tabs).
- src/local_library.rs: `LibTab` controller stub (route/tab-id parsing,
  round-trip tested).
- main.rs: apply_entry arm + `navigate_local_library` + header-route
  branch + in-view tab-bar (select-tab) and gear (open-settings ->
  Settings; management lives under Settings > Local Library) wiring.

qbz-slint cargo check green. Built blind — needs a runtime smoke test
(./scripts/slint-dev.sh): click the Local Library header menu, confirm the
view shows and the 4 tabs switch.

cc
2026-05-28 17:51:52 -06:00
vicrodh 827ba9d77a feat(playback): source-aware artwork resolution (ArtworkRef + PlaybackSource)
Add the frontend-agnostic contract for the source-aware playback context:

- qbz-models: new `source` module with `PlaybackSource` (Qobuz / OfflineCache
  / Local / Plex; parses the existing stringly-typed `source` field, defaults
  to Qobuz) and `ArtworkRef` (Remote / LocalFile / PlexThumb / Embedded /
  None). `QueueTrack::source_kind()` + `QueueTrack::artwork_ref()` derive both
  without changing the wire format. `PlaybackSource::is_qobuz_streamable()`
  seeds the Qobuz Connect queue gate (only Qobuz-sourced tracks are castable).

- qbz-slint artwork pipeline: `fetch_and_decode_ref(&ArtworkRef, ...)` resolves
  cover bytes per origin — HTTP/Plex through the disk cache, local files read
  directly, embedded bytes in place. This fixes the root cause of local/Plex
  artwork never reaching the UI (the loader was HTTP-only). The URL-only
  `fetch_and_decode` becomes a thin wrapper, so all existing card/row jobs are
  unchanged. The now-playing loader now takes an `ArtworkRef` derived from the
  queue track, so local-library/Plex covers reach the now-playing bar.

Groundwork for the Local Library Slint port (source-aware playback context
designed in from the start). qbz-models tests pass; qbz-slint cargo check green.

cc
2026-05-28 16:36:02 -06:00
vicrodh 2fe7008e23 refactor(plex): extract Plex integration into frontend-agnostic qbz-plex crate
Move the Plex LAN client, hand-rolled XML metadata parsing, and the
SQLite plex_cache.db logic out of src-tauri/src/plex/mod.rs into a new
qbz-plex core crate so it can be shared by every frontend (Tauri, Slint,
TUI) per ADR-006. Plex previously lived entirely in src-tauri (~1715
lines), which blocked the Slint Local Library port from shipping Plex.

The extraction is mechanical: the module was already almost entirely
plain Rust (reqwest + rusqlite + serde + hand-rolled XML). The only
Tauri coupling was a dead plex_play_track command bound to AppState
(unreferenced; the live path is commands_v2::v2_plex_play_track, which
calls plex_resolve_track_media + AppState.player itself). That dead fn
and the vestigial #[tauri::command] attributes are dropped.

src-tauri/src/plex/mod.rs becomes a `pub use qbz_plex::*;` re-export so
every existing crate::plex::* call site compiles unchanged. Full Tauri
workspace cargo check stays green; qbz-plex tests pass.

cc
2026-05-28 15:20:23 -06:00
vicrodh a2d8e43b1c fix(musicbrainz): query MusicBrainz directly per-IP instead of via the proxy
The Cloudflare proxy funnelled every user's MusicBrainz traffic onto a few
egress IPs, and MusicBrainz rate-limits 1 req/s per IP, so the budget was
shared across the whole userbase and produced 503s that worsen with growth.
MB read access needs no key and authentication does not raise the limit, so
the proxy added only the funnel; going per-IP makes the budget per-user.

- qbz-integrations MusicBrainzConfig default is now use_proxy=false (direct).
- client: handle HTTP 503 + Retry-After (not just 429, which the proxy used
  to translate); add parse_retry_after; route search_recording_by_isrc and
  search_artist through handle_response_status.
- src-tauri session_lifecycle: MusicBrainz V2 init/teardown pass use_proxy=false.

Slint inherits this via CoreBridge's default-constructed client.

cc
2026-05-28 14:12:12 -06:00
vicrodh 78b1a9c508 refactor(slint): Settings "Offline" panel matches the other sections' layout
The panel improvised its own title + free-form paragraph + ghost buttons,
breaking the established Settings structure. Rebuilt it like Audio/Playback/
Appearance: a GroupHeader ("OFFLINE CACHE") + SettingRow rows (label +
description + a SecondaryButton control on the right) — Manage offline cache /
Cache folder / Clear offline cache. No panel-level title (the left sub-nav
names the section). Keeps the layout consistent for the offline settings we'll
add here later.

cc
2026-05-28 13:18:03 -06:00
vicrodh 2b6c71000f feat(slint): offline manager — checkboxes + bulk, status to the end, track numbers
Per user feedback:
- Multi-select: a checkbox on every track row + "Select all"/"Clear" toggle +
  a bulk bar ("N selected" / Re-download / Remove). Selection is an in-place
  model edit (offline_manager::toggle_select / set_all_selected) with a live
  selected-count; bulk reads the checked ids and runs redownload_track /
  remove_cached per track.
- Moved the status glyph to the END of the row (albums and tracks) — it read
  as "missing art" at the leading edge under the album-grouping model.
- Track rows now show their position number in the leading slot (filling what
  looked empty). (Position within the album; the catalog track number isn't in
  the cache index.db — a follow-up if the real number is wanted.)

qbz-slint compiles clean.

cc
2026-05-28 12:23:46 -06:00
vicrodh db3d8491f5 feat(slint): offline manager 1:1 — covers, artist rail, sort, failed-only, persisted limit
Closes the v1 gaps in the offline manager toward Tauri parity:

- Album COVERS: rows load the on-disk cover.jpg (per album). Built Send-safely
  — the worker passes the cover PATH (string), the image is decoded on the UI
  thread (slint::Image is not Send).
- Two-pane layout with an A-Z ARTIST RAIL (left, "All artists" + per-artist
  "N albums · M tracks", selectable) + the discography list (right).
- SORT dropdown (A-Z / Recent / Largest / Smallest; Recent uses the DB's
  last_accessed_at DESC order) and a "Failed only" toggle. Filter state lives
  Rust-side (offline_manager) so rebuild can read it off-thread; mirrored back
  onto OfflineManagerState for the UI.
- Limit now PERSISTS to disk (<audio>/offline_limit), restored on activate via
  apply_persisted_limit; the manager's edit-limit writes it.
- Manager mutations (remove/redownload/clear) now rebuild on completion
  (offline_manager::rebuild made pub, called at the end of each cache op) —
  the 500ms timed reload hack is gone.

Deferred (user-accepted): the "track no longer available on Qobuz" case —
offline copy still plays, but warn it can't be re-downloaded / won't work on
Qobuz Connect.

qbz-slint compiles clean.

cc
2026-05-28 11:52:55 -06:00
vicrodh 3e0a718e0c feat(slint): offline cache manager + remove/redownload + Settings Offline page
Slices 3 + 4 of the offline-cache port.

Slice 3 (controller, offline_cache.rs):
- remove_album (maintenance::remove_album_cached_tracks + library rows + live
  row update), redownload_track / redownload_album(failed_only) (via
  select_redownload_targets + reset_track_for_redownload + spawn), clear_all
  (purge), open_folder (xdg-open / open the cache dir).

Slice 4 (manager + Settings):
- OfflineManagerView.slint: a standalone route (NavEntry::OfflineManager ->
  ContentView.offline-manager, mounted in AppShell, mirrors PlaylistManager).
  Stats bar (tracks count / size / limit / usage bar + edit-limit GB input +
  open-folder + clear-all) over a scrolling album-header + track-row list with
  a spinning/check/alert status glyph and per-row re-download / remove.
- offline_manager.rs: builds the album->track rollup + stats from index.db
  (get_all_tracks), pushes [OfflineRow] + stats to OfflineManagerState; set_limit
  (in-memory, session-scoped) + reload_soon (refresh after a mutation).
- OfflineManagerActions wired in main.rs (open/refresh/remove/redownload/
  set-limit/clear-all/open-folder/play).
- Settings gains an "Offline" section (OfflineSettings.slint): Manage offline
  cache (-> the manager), Open cache folder, Clear all. The Local-Library
  rows (show-in-library, repair) arrive with the Local Library port.

qbz-slint compiles clean.

cc
2026-05-28 11:23:55 -06:00
vicrodh 8c23aad8a8 chore: record qbz-offline-cache in src-tauri Cargo.lock
cc
2026-05-28 10:31:01 -06:00
vicrodh a7f2283c92 feat(slint): offline button on every Qobuz track row + cache-whole-playlist
- TrackRow show-download now defaults to true: every Qobuz track row (album,
  artist, label, playlist, mix, search, favorites) carries the offline
  cloud/check button. Non-Qobuz surfaces (future Local Library / Plex) opt out
  by setting it false.
- Playlist header gains a "make offline" CircleAction -> ("playlist","cache")
  -> offline_cache::cache_playlist (get_playlist -> batch its tracks), mirroring
  the album-header action.

qbz-slint compiles clean.

cc
2026-05-28 10:29:43 -06:00
vicrodh 6572d6f33f feat(slint): album-header "Make album offline" + direct button on favorites rows
- offline_cache::cache_album fetches an album's tracks and batches them; wired
  to a new ("album","cache") media-action arm + a cloud-download CircleAction in
  the AlbumPageView header action row.
- Favorites track rows now enable show-download, so the direct per-row offline
  button shows there too (not just album rows / cached tracks).

Rounds out Slice 2: trigger surfaces (per-row button on album+favorites, context
menu everywhere, bulk multi-select, album header), live status (spinner/check/
alert) + seeding, and the unlock padlock. qbz-slint compiles clean.

cc
2026-05-28 09:29:20 -06:00
vicrodh 6bea6a7a0a feat(slint): seed per-row offline status from the index.db on load
A cached track now shows its "ready" check when a view is built, persisting
across navigation within the session (was: only live downloads showed state).

- offline_cache.rs: a session-wide CACHED_IDS set (mirrors fav_cache), seeded
  from index.db ready rows via load_cached_ids() on login/restore, kept in
  sync as downloads complete (mark_cached true) and copies are removed (false).
  is_cached(id) reads it.
- The 7 TrackItem builders seed cache-status from is_cached (favorites/search
  clone/precompute the id to avoid moving it before the borrow).
- auth.rs calls load_cached_ids after the offline cache activates.

qbz-slint compiles clean.

cc
2026-05-28 09:01:06 -06:00
vicrodh 43c1599400 fix(slint): spin the downloading loader (animation-tick rotation)
The queued/downloading state showed a static loader-circle. Split it into its
own `if` arm that continuously rotates via animation-tick (same technique as
LoadingSpinner), isolated so the per-frame tick only drives a row while it is
actually downloading. The idle/ready/failed glyph stays static. Communicates
that the backend is working.

cc
2026-05-28 08:36:40 -06:00
vicrodh 3ffdca729d feat(slint): bulk "Make available offline" in the Library multi-select bar
Adds the 6th MultiSelectBar action -> offline_cache::cache_tracks(selected),
mirroring Tauri's bulk caching. Clears the selection after queuing. (Also
retires the cache_tracks dead-code warning.)

cc
2026-05-28 08:28:47 -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 d5f23c38d8 feat(slint): per-row offline affordance + the unlock padlock (Slice 2 UI)
TrackRow now renders the offline state the controller maintains:

- The offline button is stateful: cloud-download (none, click -> cache) ->
  loader-circle accent (queued/downloading) -> circle-check accent (ready,
  click -> uncache) -> triangle-alert red (failed, click -> retry). Shown on
  surfaces that enable it AND on any row with active cache state.
- The unlock PADLOCK (user-requested, back from Tauri): a pulsing lock glyph
  overlays the play cell while the track's CMAF bundle decrypts, driven by the
  `unlocking` flag the row_sink toggles on UnlockStart/UnlockEnd. New lucide
  lock.svg / lock-open.svg assets.

The context menu's "Make available offline" already routes to caching. Testable
loop: cache a track (album button or context menu) -> watch it download -> play
it offline -> padlock pulses during decrypt. qbz-slint compiles clean.

cc
2026-05-27 22:05:37 -06:00
vicrodh b65b402ff1 feat(slint): offline-cache controller + per-row status/unlock plumbing (Slice 2)
The Rust half of the trigger/status UI:

- offline_cache.rs: cache_track / cache_tracks / remove_cached + row_sink — a
  CacheEventSink that reflects download status (queued/downloading%/ready/failed)
  and the unlock padlock onto every visible row, plus terminal toasts. cache_*
  pre-flight the limit, insert the queued row, and spawn the shared
  spawn_track_cache_download with the core client.
- TrackItem gains cache-status / cache-progress / unlocking; set_row_cache_status
  + set_row_unlocking mirror set_row_favorite (walk the 6 track models + search
  hero). All 7 exhaustive initializers updated.
- media-action arms ("track","cache"|"download"|"uncache") dispatch to the
  controller; the play path now passes row_sink so UnlockStart/End fire the
  padlock state during CMAF decrypt.

Compiles clean. Next: the TrackRow.slint affordance (make-offline button + ring
/ check / warn) and the padlock glyph that render these fields.

cc
2026-05-27 21:45:26 -06:00
vicrodh 1413cec626 feat(slint): wire offline cache into the play path (Slice 1 Slint side)
- offline.rs: constructs + owns a per-user OfflineCacheState (same index.db +
  library.db paths as Tauri, so the offline store is shared across frontends);
  activated on login/restore, torn down on logout (hooks in auth.rs).
- playback.rs: play_audible + the gapless trigger now route through QbzCore's
  play_track_resolved / fetch_for_gapless_resolved, so an offline-cached track
  resolves from disk (decrypt -> play_data) before hitting the network. The
  unlock-animation sink is None for now (padlock lands with the Slice 2 UI).

qbz-slint compiles clean. Offline PLAYBACK now works in Slint; the "Make
offline" trigger UI + per-row status + the padlock are next (Slice 2).

cc
2026-05-27 21:22:16 -06:00
vicrodh 506ba5ef0f feat(core): QbzCore play_track_resolved + fetch_for_gapless_resolved
The shared tier-walk compose methods both frontends call (player untouched):

- play_track_resolved: offline-first (resolve_offline_bytes -> play_data, warms
  L1) else Player::play_track (which already does L1/L2 -> CMAF -> legacy).
- fetch_for_gapless_resolved: L1/L2 (player cache) -> offline -> network; the
  offline tier is only tried when the track is NOT in the player cache (the
  CMAF decrypt is slow, so a cached copy wins — matches the Tauri gapless order).

Both take the frontend's OfflineCacheState by ref (no duplicate DB handle) and
an optional CacheEventSink for the unlock animation. Compiles green.

cc
2026-05-27 21:05:30 -06:00
vicrodh d0a4a3299b feat(core): shared offline-tier resolver (Slice 1 foundation)
Adds qbz_core::offline_resolve::resolve_offline_bytes — the OFFLINE tier of
the playback tier-walk, frontend-agnostic and player-untouched (the user's
locked Slice-1 decision): resolve a track's offline-cached bytes by Qobuz id
(v2 CMAF decrypt via the secret vault, optionally emitting UnlockStart/End
through a sink; v1 plain-FLAC read; None on miss).

qbz-core now depends on qbz-offline-cache (aligns with the long-intended
`offline` feature seam). Both frontends will slot this in before the network
tier: Tauri keeps its hardware/quality gate around the returned bytes; Slint
calls it before Player::play_track. Player's L1/L2 + network path is unchanged.

Next: QbzCore compose methods (play_track_resolved / gapless), wire Slint's
session to construct + own an OfflineCacheState, route the Slint play path
through the resolver, and point Tauri's offline branch at it.

cc
2026-05-27 20:53:26 -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