Phase-2 Slice 2 of the MyQBZ port. Wires the mixtape DB + the first
visually-reachable surface in Slint (read-only):
- Migrations: run qbz_mixtape::schema::run_mixtape_migrations in enter_shell
(via library_db with_db -> with_connection), best-effort, on session open —
same per-user library.db the Tauri build migrates.
- Glue: new src/myqbz.rs mirrors playlist_manager.rs — list_collections via the
shared crate (items hydrated), model/sort/filter builders, mosaic artwork jobs,
navigate. NavEntry::Mixtapes/Collections + back/forward + scroll-restore scopes.
- UI: ui/myqbz/{MixtapesView,CollectionsView,CollectionMosaic,MyQbzShared}.slint,
1:1 with specs 10/11 (208px grid, mosaic custom->2x2/3x3->kind-glyph, toolbar,
both empty states, ICU plural). MixtapeCardItem + MyQbzState/MyQbzActions in
state.slint; mounted in AppShell. My QBZ sidebar entry routed to the two grids
(branded mask-tinted icons), matching the port's existing flyout-nav idiom.
Read-only boundary: create/open-detail are logging stubs; titlebar My QBZ
dropdown and the Settings>Appearance rename (DQ3) are deferred to later slices.
Backend crates untouched. cargo check -p qbz-slint green.
cc
New crate crates/qbz-mixtape holding the Mixtapes+Collections backend, ported
1:1 from src-tauri/src/mixtape and re-typed against the real shared crates so it
runs headless (no tauri::State, no #[tauri::command] wrappers) per ADR-005/006:
- schema.rs / repo.rs / shuffle.rs copied verbatim (SQLite schema + migrations,
CRUD repository, DJ-mix similarity sampler). enqueue.rs re-typed to
qbz_qobuz::QobuzClient, qbz_library::LibraryDatabase and qbz_plex, dropping the
src-tauri crate::api / crate::library shims.
- ItemResolver is frontend-agnostic: ProdItemResolver takes a caller-supplied
sync local-resolution closure so &LibraryDatabase (!Sync) never crosses an
.await. resolve_local_item centralizes the full Local dispatch matrix
(Album / Track i64-parse / Playlist-unsupported) so frontends cannot drift.
- src-tauri left untouched: Tauri is removed at the end of the Slint port, so the
temporary duplication is intentional and zero-risk to the working Tauri build.
Adds qbz-mixtape to the workspace members + rand/strsim workspace deps.
Phase-2 Slice 1 of the MyQBZ Slint port. 57 crate tests pass.
cc
Plex cover images were fetched full-res on every surface (Slint built the raw
/library/.../thumb URL), where Tauri requests a server-transcoded thumb sized to
the render target. On a real library this made the views feel heavy and starved
the LAN bandwidth that track playback needs.
- qbz-models: ArtworkRef::PlexThumb gains a size: Option<u32>; new plex_thumb_url()
emits the Tauri-format transcode URL when Some
(/photo/:/transcode?url={enc}&width=N&height=N&minSize=1&X-Plex-Token=), raw
tokenized path when None (album/MPRIS full-res preserved). urlencoding dep added
to match encodeURIComponent. Tests extended.
- qbz-slint: each PlexThumb construction site passes its surface decode size
(grid/detail 264, queue/now-playing-item 96, now-playing bar 160, MPRIS full-res);
fetch_and_decode_ref uses plex_thumb_url.
- artwork.rs: static LazyLock<reqwest::Client> reused in fetch_cached_http instead
of reqwest::get (per-image client) — keep-alive + no fd churn (EMFILE smell).
Audio path untouched; local/Qobuz artwork unchanged.
cc
Fresh Slint-only Plex integration foundation (Tauri untouched; the Tauri
code is slated for deletion at port end, so creds are not shared):
- slice 0: fix the false Plex docs in local_library.rs
- slice 1: add the qbz-plex dependency to qbz-slint
- slice 2a: qbz-app/settings/plex.rs per-user store (10 fields, idempotent
4->11 column migration keeping creds, 8 tests) - frontend-agnostic,
no tauri::State (ADR-006)
- slice 2b: Settings > Local Library Plex section, 1:1 with the complete
Tauri feature - master toggle + collapse, LAN-only address gate, PIN auth
(2500ms poll, self-stopping on section-leave/expiry/disconnect),
manual-token fallback, ping + machine-id capture, multi-section pick
(default-all), disconnect vs clear-cache; once-per-session background
refresh (no cache_clear churn on section re-entry)
cc
Port the Tauri desktop-notification path to a frontend-agnostic crate so the
Slint app (and any future TUI/CLI) fires it from native Rust instead of a
webview invoke.
New qbz-media-controls::notify module:
- Linux: XDG notification portal via ashpd (D-Bus); album art passed as
Icon::Bytes after center-crop + downscale to <=512px + PNG re-encode (<=4 MiB).
- macOS: notify_rust with image_path (cover cached to disk).
- Windows: no-op (parity with Tauri).
- Self-contained artwork download + md5 cache + resize, matching the Tauri
helpers, with no dependency on the frontend (ADR-006).
Wired in playback::refresh_now_playing_meta (the same point that pushes the
tray tooltip + OS media controls):
- De-dupe by track id so only an actual track change fires (the refresh also
runs on resume/seek).
- Skip while a remote QConnect renderer drives playback (matches the Svelte
skipIfRemote). Fire-and-forget on a tokio task.
Enabled by default, matching the Tauri default; a settings toggle is a
follow-up.
cc
Replace the tray-icon/muda macOS backend with a hand-rolled NSStatusItem +
NSMenu built directly on objc2 0.5 / app-kit 0.2 (the same objc2 generation
winit 0.30 uses), so the menu lives in winit's NSApplication runtime and the
target-action dispatches. tray-icon's bundled muda collided with the muda
Slint's winit backend already registers and never fired the menu items. This
drops the tray-icon dependency and its whole GTK tree from the lockfile.
- Menu-bar icon pinned to 18pt via NSImage::setSize; the 44px PNG was
rendering at native pixel size (giant icon).
- The status button gets its own target-action firing on left AND right
mouse-up: left-click toggles the window, right/control-click pops the menu
transiently (setMenu -> performClick -> clear, the non-deprecated
replacement for popUpStatusItemMenu:). The menu is no longer permanently
attached, so a left-click no longer opens it.
- Add the NSEvent feature to objc2-app-kit.
cc
Port the Tauri normalizeCoverUrlForMetadata workaround as ArtworkRef::to_mpris_url
(qbz-models): remote Qobuz covers pass through as-is, local-file covers become a
percent-encoded file:// URI (MPRIS clients cannot read bare paths or asset://),
Plex thumbs become a tokenized HTTP URL. Wired into the Slint now-playing push so
the media widget shows album art. The app icon was already fixed via DesktopEntry.
cc
New frontend-agnostic crate qbz-media-controls (ADR-006): one MediaIntegration
trait, cfg-split backends. Linux uses mpris-server and publishes the MPRIS
org.mpris.MediaPlayer2.DesktopEntry property as com.blitzfc.qbz — the only way
GNOME Shell resolves the app icon for its media widget (souvlaki, the old
cross-platform crate, never set it, so GNOME showed no icon; KDE is lenient).
macOS/Windows keep souvlaki (MediaRemote/SMTC), where the icon comes from the
app bundle, not a DesktopEntry. The Linux server runs on a dedicated thread
with its own tokio runtime; state arrives over an async channel.
Wired into qbz-slint via src/media_controls.rs: started on shell entry,
metadata/playback pushed from the playback paths (mirroring the tray), and
inbound control events (media keys, the desktop media widget, Now Playing)
dispatched to the player through the existing QConnect-aware helpers. Both
Linux and macOS compile clean.
cc
Add the NSStatusItem (macOS) / Shell_NotifyIcon (Windows) tray via the
tray-icon crate: the same 5-item menu, left-click window toggle, a themed
icon (auto uses a macOS template image that adapts to the menu bar), a
static QBZ tooltip, and live icon-variant switching. tray_icon::TrayIcon is
!Send and (on macOS) must be built on the main thread with NSApplication
running, so it lives in a thread_local created via invoke_from_event_loop;
its events are drained from a slint::Timer.
macOS close-to-tray reuses the cross-platform Slint hide/show path. The
opt-in Hide Dock icon toggle switches the NSApplication activation policy to
.accessory while closed to the menu bar (.regular by default, Spotify-style)
via objc2-app-kit.
cc
Port the Tauri tray to Slint on Linux via ksni: themed icon (auto/mono/
color), left-click window toggle, middle-click play/pause, scroll volume,
the 5-item menu, and a live tooltip reflecting the now-playing track. Tray
actions call the playback controller directly (QConnect-aware) instead of
emitting webview events. Settings reuse the shared qbz-app tray store
(per-user) wired through AppearanceState.
Window hide/show uses Slint window.hide()/show() (winit set_visible is a
no-op on Wayland) and the loop runs via run_event_loop_until_quit so a
tray-hide keeps the app alive. close-to-tray is honored on both the custom
titlebar close and the WM close (on_close_requested). minimize-to-tray is
hidden until custom window chrome exists.
cc
Shuffle and Repeat now go to the CLOUD whenever connected (renderer OR controller),
gating on transport_connected like Tauri's v2_toggle_shuffle / v2_set_repeat_mode,
instead of the old peer-active gate that fell through to a LOCAL reshuffle when QBZ
was the active renderer — that did nothing visible AND risked a divergent local
order. QBZ sends only the seed + pivot; the cloud owns the order; the toggle reads
the cloud-authoritative queue.shuffle_mode. The local shuffle path is now reachable
only when NOT connected.
- shuffle/repeat button reflects the cloud queue flag (queue.shuffle_mode for the
controller branch, core queue state via refresh_transport_modes for the renderer
branch), not the per-renderer field the cloud never populates for a peer — fixes
the iOS-reflects / WebPlayer-doesn't asymmetry.
- pause/play to a peer renderer: send a BARE SetPlayerState (playing_state only).
iOS reports current_queue_item_id=null while playing, so resolving the qid landed
on queue_item_id 0 — the protocol's "unknown" placeholder — and iOS could not
anchor the pause, auto-resuming. A bare transport toggle is spec-sanctioned: the
renderer pauses/resumes its own current item in place.
- skip_serializing_if on the SetPlayerState request structs so a None field is
OMITTED, not serialized as null; and the mapper now treats a null current_queue_item
as absent. Without this the bare command failed to encode and was never sent.
- log the outbound SetPlayerState payload for controller diagnostics.
cc
Startup blocked on Qobuz's ~7 MB web bundle download with no cache or
timeout, so a slow CDN left the app on a blank screen for 25-30s before
the login screen appeared. Cache the extracted tokens on disk (keyed by
bundle version), apply them instantly on warm starts and refresh in the
background, and bound the cold fetch with a per-request timeout + retry.
The legacy api client shares the same cache. Emits BundleFetchStarted so
the UI can show a connecting state on the first run.
cc
Ports the Tauri qconnect/transport.rs config layer to Slint: resolve_transport_config
(env overrides -> qws/createToken auto-discovery -> WsTransportConfig with require_jwt
+ 60s idle-retry + default channels) and fetch_qconnect_transport_credentials (POST
/qws/createToken with X-App-Id / X-User-Auth-Token), plus the device-identity helpers
(uuid via shared qbz_app::qconnect_identity, hostname, friendly name, persisted
device-name in qconnect_settings.db) and the hex-channel parsers.
Frontend difference handled: the Qobuz client is reached via runtime.core().client()
(Arc<RwLock<Option<QobuzClient>>>, may be None before API init) and is gated/ok_or'd,
unlike Tauri's always-present AppState.client. Uses qbz_qobuz::endpoints::build_url
(same base) instead of the Tauri-local one.
Deps added: qbz-qobuz (build_url), qconnect-transport-ws (WsTransportConfig /
NativeWsTransport). allow(dead_code) until the service wires it. cargo check
-p qbz-slint green (only the pre-existing unrelated rename_folder warning).
cc
Phase S steps 9-10. Makes qbz-Slint a QConnect renderer: SlintRendererEngine
implements qconnect_app::QconnectRendererEngine over runtime.core(), so the Slint
frontend inherits the shared echo/cursor/materialize/shuffle orchestration in
qconnect_app::renderer instead of re-deriving it.
- Deps added to qbz-slint: qconnect-app, explicit qbz-player, futures-util, uuid.
- 16 trait forwards through runtime.core() (CoreError mapped to String) +
current_output_format reading player().state.
- start_track_stream + the HTTP feeder (probe FLAC -> play_streaming_dynamic,
fallback download -> play_data) ported from the Tauri CoreBridge impl. The
protected bit-perfect seams are only called, never modified; the probe-derived
sample_rate/channels/bit_depth flow straight through.
- TLS deviation: the crates-workspace reqwest ships rustls-tls (not native-tls),
so .use_native_tls() is dropped. TLS is transport-only; audio bytes identical.
allow(dead_code) until the Slint QConnect service + event sink wire it (next
Phase S step). Verified: cargo check -p qbz-slint green (only a pre-existing
unrelated rename_folder dead-code warning).
cc
The renderer-engine seam: a 16-method trait both frontends implement with
zero-cost forwards to their QbzCore/Player, so the hard-won renderer
orchestration (echo-seek rejection, cursor align, queue materialize, shuffle
deferral, load dedup window) can move UP into qconnect-app and be written once
against the trait instead of re-derived per frontend (slice 6, Phase T 1-2).
- Adds qbz-player as a qconnect-app dep, for PlaybackState (read by the echo
gate + should_reload; it is not in qbz-models and not re-exported). qbz-player
does NOT depend on qconnect-app -> no dependency cycle.
- The two protected bit-perfect seams (play_data / play_streaming_dynamic) are
reached ONLY through the high-level start_track_stream method; BufferWriter +
reqwest + the detached feeder spawn stay impl-side and never cross the crate
boundary. play_data / play_streaming_dynamic are never modified.
No impls, no logic moved yet -- pure foundation. qconnect-app 38 green, src-tauri
cargo check green.
cc
Relocate the persistent QConnect device-identity resolution out of the
Tauri adapter into the frontend-agnostic qbz-app crate
(qbz-app/src/qconnect_identity.rs) so both the Tauri and a future Slint
frontend resolve the SAME uuid for the same install.
Moved: resolve_qconnect_device_uuid, device_uuid_from_db,
load/persist_device_uuid, qconnect_settings_db_path.
Byte-identical persistence contract preserved: DB path
(<data_dir>/qbz/qconnect_settings.db), the 'device_uuid' key, and the
QBZ_QCONNECT_DEVICE_UUID env override are unchanged, so existing installs
keep their uuid. The Tauri transport.rs::resolve_qconnect_device_uuid is
now a one-line delegate; the device-NAME persistence shares the same DB
file via the qbz-app path helper.
qbz-app gains a uuid dependency (it already had rusqlite + dirs, so no
SQLite dep was added to qconnect-app per R6). The 2 device_uuid tests
move to qbz-app.
cc
Move the frontend-agnostic pure functions and enums out of the Tauri
qconnect adapter into the shared crates, with zero behavior change. The
Tauri side keeps thin re-exports so all existing call sites compile
unchanged.
Into qconnect-app (new `session` module):
- should_arm_renderer_watchdog
- compute_connection_state + ServerActiveState + ConnectionDecision
- deferred_join_reason, should_reask_queue_state
- RendererStatus + from_wire
- quality_from_max_audio_quality
- QCONNECT_RENDERER_LOST_TIMEOUT_MS + JoinSession reason consts
Into qconnect-core (next to evaluate_remote_queue_admission):
- validate_track_origins_for_admission (pure over TrackOrigin); the
Tauri command wrapper stays thin and only maps wire origins to core
origins.
The 6 pure-fn unit tests move into the destination crates' test
modules. R5 honored: compute_connection_state matrix cells are relocated
verbatim, not changed.
cc
Decodes PlaybackErrorMessage off the InboundPayloadBytes batch and emits a
PlaybackError app event; the Tauri event sink forwards it to qconnect:event
via its existing catch-all emit.
cc
is_qobuz_streamable was dead code. Add is_castable_to_qconnect as the
single source of truth and depend qconnect-core on qbz-models so the
admission gate and the cast predicate derive from one enum.
cc
Completes the JACK output on top of the foundation (fbc03f41):
- StreamType::Jack + the player dispatch (backend==Jack -> JackStream).
- PlaybackEngine::Jack: a feeder thread (mirrors AlsaDirect) that resamples each
source to the JACK graph rate (UniformSourceIterator, stereo) and writes it to
a lock-free f32 SPSC ring (ringbuf); wired at the 4 engine-creation sites.
- jack_backend.rs: f32-native ring (fixes a byte-alignment white-noise bug) plus
auto-connect of qbz:out_FL/FR to the physical playback on activation, so audio
reaches the hardware without a patchbay (a user may re-patch freely).
- Re-enabled the available_backends push (JACK is now offered in the selector).
Verified on hardware: select JACK -> QBZ registers as client 'qbz' with stable
ports qbz:out_FL/FR (client.api=jack) -> music plays through the DAC, resampled
to the graph rate (NOT bit-perfect; BitPerfectMode::Disabled, as designed). The
ALSA-exclusive / DAC-passthrough bit-perfect paths are untouched.
Slice 3.2 (pending, separate): JACK<->passthrough/exclusive mutual-exclusion UI,
the bit-perfect-OFF note, runtime graph-rate-change callback, mono/multichannel.
cc
Backend-side foundation for a native JACK output. The hard, RT-sensitive part
is done and compiles: jack_backend.rs holds a JackStream that opens a JACK client
'qbz' with stable ports qbz:out_FL/FR, a lock-free SPSC ring buffer, and an
allocation-free process callback (de-interleaves stereo f32 to the ports). The
feeder pushes interleaved f32 via write_f32().
Also: jack 0.13 dep (linux), AudioBackendType::Jack + its factory arm + the
qbz-slint label/bit-perfect match arms. NOT yet offered in the selector
(available_backends push commented) because the player wiring is still pending:
StreamType::Jack + the dispatch branch (backend==Jack -> JackStream) + a
PlaybackEngine::Jack feeder thread that resamples each track to the JACK graph
rate. JACK mode will be opt-in and NOT bit-perfect (one fixed graph rate); the
ALSA-exclusive / DAC-passthrough bit-perfect paths are untouched.
TODO(distribution): feature-gate jack for Flatpak/distro builds without libjack.
cc
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
- 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
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
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
Album-card hover overlay now shows the full release date as "MMM D, YYYY"
(e.g. "Nov 6, 2025") instead of the year alone. New dates::release_label
helper keeps the month/day/year order fixed but localizes the month via
chrono format_localized (the app ships 5 languages; current_locale() is the
single hook to thread the real UI language once Slint wires translations —
English for now). Applied to the Discover and Favorites album mappings.
Also fixes Favorites > Albums cards, which hardcoded an empty genre and a
year-only label: they now read album.genre and the full date, matching the
Discover cards 1:1. The data was already in the getUserFavorites response
(qbz-models Album parses genre + release_date_original) — no backend change.
cc
The catch-up merge staged the pre-release lock (--theirs) before cargo
regenerated it, so the committed lock dropped qbz-slint's GUI dependency
tree (slint / accesskit / winit / skia / ab_glyph, ...). Commit the
regenerated lock so the checked-in slint-mvp lock matches the workspace.
cc
qbzd was a premature daemon experiment that never shipped (gated off
behind QBZD_ENABLED, no user access). Its ratatui 0.29 dependency pinned
unicode-width =0.2.0, which conflicts with slint's annotate-snippets in a
shared workspace. Remove it entirely; it will be reimplemented from
scratch later on the clean, UI-agnostic shell.
- Delete crates/qbzd daemon crate + workspace member (drops ratatui,
crossterm and the other qbzd-only deps from the lock).
- Delete src-tauri qbzd_discovery (mDNS) + its commands/state wiring in
lib.rs; drop the direct mdns-sd dep (Chromecast keeps its own via
qbz-cast).
- Delete remoteApi/remoteEvents/playbackTargetStore; neutralize
commandRouter to local-only dispatch (isRemote/skipIfRemote are now
always-false no-ops so the ~100 guard call-sites keep compiling).
- Strip qbzd branches from playbackService and the remote banner from
NowPlayingBar; excise qbzd from CastPicker (Chromecast/DLNA kept).
- Remove qbzd i18n keys (5 locales), README daemon node, gitignore note.
cargo check (crates + src-tauri) and svelte-check all green.
cc
cpal 0.17.3 requires alsa 0.11, which in turn pulls alsa-sys 0.4. The
`links = "alsa"` constraint permits only one alsa-sys in the dependency
graph, so qbz-audio's direct alsa stack is raised in lockstep:
alsa 0.10 -> 0.11 and alsa-sys 0.3 -> 0.4. Both workspace lockfiles
(crates/ and src-tauri/) are updated to cpal 0.17.3 / alsa 0.11.0 /
alsa-sys 0.4.0.
No source changes required: alsa_direct.rs uses only the stable
high-level alsa API (pcm, HwParams, Format, Direction, ValueOr, mixer),
and the snd_lib_error_set_handler FFI in alsa_error_handler.rs is
unchanged in alsa-sys 0.4. Both workspaces pass cargo check.
cc
Extracts two self-contained, framework-agnostic modules out of
src-tauri so they can be reused by other clients without duplicating
logic:
- ImageCacheService / ImageCacheStats move into the qbz-cache crate
(LRU disk cache, MD5-keyed, SQLite index).
- The credential store (keyring + AES-256-GCM encrypted file for Qobuz
credentials and the OAuth token) moves into a new qbz-credentials
crate.
src-tauri/image_cache.rs and src-tauri/credentials/mod.rs become thin
re-exports, keeping their Tauri managed-state wrappers. The Tauri app
is behavior-identical; both modules were already self-contained (no
Tauri and no crate:: references).
cc
Adds a framework-agnostic composition root for non-Tauri UI shells
(Slint, TUI, headless) as Task 1 of the Slint POC readiness audit
(session-20-slint-poc-readiness-audit).
AppRuntime<A> owns an Arc<QbzCore<A>> plus the runtime state machine,
built with the same recipe as the Tauri CoreBridge but with no Tauri
dependency. Scope is composition and accessors only; per-user store
ownership and session activation are deferred to Task 2.
qbz-app now depends on qbz-core, qbz-player, qbz-audio, and qbz-models.
The Tauri app does not consume this module; it is purely additive.
cc
Adds CoreAudio Hog Mode integration on macOS so Exclusive Mode actually
takes the device exclusively (previously the macOS path used the
Linux-shaped logic without any kernel-level lock). Pins the CoreAudio
device id through Hog Mode acquisition, sample-rate switching, and
stream creation so System Default doesn't drift mid-session. Linux
behaviour is unchanged.
Review asks (all four resolved by Vudgekek + afonsojramos):
- set_coreaudio_hardware_volume falls back to software volume on Err
rather than reporting success, so knob-only DACs keep a working
volume slider via engine.set_volume().
- CoreAudioExclusiveGuard snapshots the device's hardware volume on
acquire and restores it before releasing Hog Mode, so leaving
Exclusive Mode returns the DAC to the user's pre-Exclusive level.
Guard is constructed before the FFI call so partial-acquire failures
still trigger Drop and release.
- Linux unused-variable warnings on stream_opt and exclusive_guard
cleaned up via cfg_attr(allow(unused_variables)) on the macOS-only
guard plumbing. cargo check is clean.
- Silent shared-mode regression on macOS when backend init fails:
init_device now sets_current_device(None) and returns None on
exclusive_mode==true so the user sees an error instead of
unknowingly playing in shared mode.
Bonus: 5f10b4ba adds real CoreAudio device-rate detection for macOS
Shared Mode, +90 lines in backend.rs.
Conflict resolution: single textual collision in
crates/qbz-audio/src/lib.rs (both branches added a pub use line).
Resolved by keeping both, alphabetical order — coreaudio_direct
re-export from pr-391, device_reservation re-export from pre-release.
All other auto-merges verified line-by-line: AlsaDirectStream's
DeviceReservation::acquire flow (alsa_direct.rs:60-97), the macOS
exclusive_mode guard (player/mod.rs:1077-1085 in qbz-player and the
mirror in src-tauri:1024), and CoreAudioExclusiveGuard's volume
snapshot/restore (coreaudio_direct.rs:351,369,373,388) all coexist
correctly because each branch touched different functions.
cargo check clean across all four crates. 9 device_reservation +
3 reserve_dac + 214 src-tauri unit tests pass.
cc
Adds the new module that will host the org.freedesktop.ReserveDevice1
D-Bus client. This commit only ships the public surface, the parser
helpers (hw:N -> Audio<N> bus name, hw:CARD=name resolution) and unit
tests for them. acquire() currently returns a degraded guard; D-Bus
implementation lands in the next commit.
cc
New album_grouping module exports the SQL CASE-WHEN expression for the
metadata group key shared by both new queries. The expression has three
priorities: (1) usable album tag => album|artist key, (2) fallback to
existing folder-based album_group_key, (3) orphan bucket
'__unknown_album__'.
get_albums_metadata_grouped runs the grouped query and returns
LocalAlbum rows with directory_path empty and source_folders populated
with the comma-separated list of contributing folders. Pinned 'Unknown
Album' to the bottom via ORDER BY.
get_album_tracks_metadata fetches all tracks belonging to a metadata
group, ordered by disc/track number.
Adds idx_local_tracks_album_lookup index on (album, album_artist,
artist) to keep the GROUP BY index-backed at 16k+ track libraries.
LocalAlbum gains an optional source_folders field (None for folder-
grouped rows; Some(comma-separated) for metadata-grouped rows). The
existing folder-grouped query continues to populate it as None so no
downstream consumer breaks.
Hidden filter (album_settings.hidden) does not apply to metadata-
grouped rows in this iteration; revisiting requires extending
album_settings to support metadata keys.
cc
libasound's default error handler prints directly to stderr. During
device enumeration probes, PCMs that wrap dmix or surround routes emit
benign errors ("unable to open slave", "no matching channel map") that
cluttered the app log without indicating a real problem.
Install a process-wide snd_lib_error_set_handler shim from
AudioBackendFactory::create_backend (idempotent via std::sync::Once)
that forwards the file/line/function/err/fmt tuple to log::debug. The
messages stay available with RUST_LOG=debug and disappear during
normal runs.
Adds alsa-sys 0.3 as a direct Linux-only dependency on qbz-audio
(previously transitive via alsa/cpal). The handler is declared
non-variadic and transmuted into libasound's variadic function pointer;
ignoring the unused varargs is ABI-compatible on every platform we
support.
cc
Fixes GHSA-cq8v-f236-94qc (low: rand unsound with custom logger using
rand::rng()). Transitively bumped across both src-tauri and crates
workspaces via cargo update.
cc
Foundation for the offline-cache hardening story. The offline cache
needs to persist per-track AES content keys to disk, but storing them
plaintext would make copying the SQLite DB enough to decrypt the
cached audio on another machine.
qbz-secrets provides a uniform wrap/unwrap API backed by:
1. **OS keyring** (preferred): libsecret/gnome-keyring on Linux,
Keychain on macOS, DPAPI on Windows. A 256-bit master AES key
lives inside the OS secure store; all wraps go through it. This
is the gold-standard device binding used by commercial music
apps (Spotify, Apple Music, Tidal) for the same class of data.
2. **HKDF fallback**: when the OS keyring is not reachable (headless
setups — Raspberry Pi, Docker, the future qbzd daemon on a
server), the master key is derived on demand via HKDF-SHA256
over service_name + machine_id + a persistent per-install UUID.
Weaker than keyring (reversible by anyone with filesystem access
to both sources) but still device-bound against cross-machine
copy attacks, and lets daemon/headless variants work at all.
Every wrap uses AES-256-GCM with a fresh random 12-byte nonce. The
on-disk envelope carries a version byte + backend marker + nonce +
ciphertext+tag, so (a) we detect tampering via GCM, (b) we refuse to
silently unwrap a blob produced by a backend we can't reach, and (c)
we have a forward-compat path for future key rotation.
4 unit tests cover: small-secret round-trip, 16-byte content-key
round-trip (the exact CMAF use case), GCM tamper detection, and
nonce-randomness (two wraps of the same input differ).
This crate intentionally has zero QBZ dependencies — it's a generic
primitive. Next commits wire it into the offline cache schema + the
DB migration.
cc
The 0.10.x advisory was fixed in v1.2.7 by bumping 0.10.0 -> 0.10.1.
The same advisory also applies to 0.9.x with a separate patched
version (0.9.3). Applying the bump to both lockfiles so the alerts
for the transitive 0.9.x deps close too.
cc
Advisory: rand is unsound with a custom logger using rand::rng().
Affects 0.10.x only; 0.10.1 fixes the issue. The older rand versions
still present in the lockfiles (0.7.3, 0.8.5, 0.9.2) are transitive
dependencies that do not use the rand::rng() API surface introduced
in 0.10 and are not exploitable via this advisory.
The lru 0.12.5 advisory (GHSA-rhfx-m35p-ff5j) is a transitive dep of
ratatui 0.29.0 (used by qbzd). Can't be bumped without upstream
ratatui dropping the old lru; tracked separately.
cc