Commit Graph
827 Commits
Author SHA1 Message Date
vicrodh 4f008cd219 refactor(qbz-app): scaffold app crate 2026-05-14 11:40:19 -06:00
vicrodh 921f8e1a49 fix(session): resume Qobuz streams at saved position without losing seek (#315)
Three bugs were stacking up to make session resume drop the saved offset
for streamed Qobuz tracks:

1. Position save was debounced, not throttled. Each playback tick reset
   the 5s timer, so the save never fired while a track played
   continuously — the persisted position froze at whatever value got
   written during the first quiet window after track start (typically
   ~2s). Switch to throttle: fire the first call immediately, then once
   per 5s window, with a tail call to flush the latest value when the
   window ends.

2. togglePlay tried to apply the restore offset via a separate cmdSeek
   right after cmdPlayTrack. For Qobuz streaming the audio thread's
   Seek handler bails when current_audio_data is unset — streaming
   uses current_streaming_source instead — so the seek was silently
   dropped and the track started at 0:00. Pass start_position_secs
   directly to v2_play_track. The streaming backend (legacy + V2
   player) waits for enough buffer to cover the offset plus 8s
   headroom, then eagerly decode-and-discards decoder output up to
   the offset before engaging the audio engine — no race, no
   first-pull underrun. Plex and local tracks keep the old play+seek
   path; they load full audio_data so Seek works as-is.

3. The seekbar showed 0:00 from app launch until the user hit play,
   even when a saved position was queued. setPendingSessionRestore
   now pre-positions currentTime when positionSecs > 0 so the UI
   reflects "paused at X" the moment the app opens.

Other plumbing:
- AudioCommand::PlayStreaming carries start_position_secs and
  content_length (used to estimate bytes-per-second for the resume
  buffer target).
- Player::play_streaming{,_dynamic} grow a start_position_secs
  parameter; QConnect and api_server callers pass 0 (remote-driven
  playback always starts fresh).
- cmdPlayTrack forwards startPositionSecs as a 5th optional arg.

cc
2026-05-14 02:18:59 -06:00
vicrodh b5918e1cbb fix(library): recover Plex album format + bit-depth when codec is missing
The Plex aggregation CTE used MAX(codec)/MAX(bit_depth) directly,
which produced two bugs that surfaced in the Albums grid for many
Plex-only libraries:

  - format = "Unknown". Plex frequently omits the stream-level codec
    when its analyzer hasn't seen the file yet. MAX(NULL) collapsed
    the row's format to NULL, which the frontend parser mapped to
    AudioFormat::Unknown — the Library grid then showed UNKNOWN even
    though the file's container was clearly "flac" / "mp3".
  - bit_depth = NULL. Plex commonly omits bitDepth on older releases
    entirely; the album row inherited the gap and the UI rendered
    "--/44.1" next to an otherwise valid quality string.

Two fallbacks applied to the plex_aggregated CTE only — the local
CTE is untouched because local indexing always writes a non-null
format string and a real bit depth via Symphonia:

  - format: COALESCE(MAX(codec), MAX(container)). The `container`
    column is populated for the same media and typically carries the
    same value ("flac", "mp3"), so it's a safe fallback when codec
    is fully NULL across the album.
  - bit_depth: COALESCE(MAX(bit_depth), CASE WHEN <lossless> AND
    sample_rate <= 48000 THEN 16 ELSE NULL END). Lossless formats
    at CD-range rates are virtually always 16-bit redbook, so we
    default to 16 in that narrow window. Higher rates stay NULL so
    we don't lie about 24 vs 32. The per-track view shows the real
    value when the user clicks in (lazy hydration via
    plex_get_track_metadata).

cc
2026-05-13 22:22:57 -06:00
vicrodh 6f05fd3069 fix(qobuz): chunk get_tracks_batch into 50-ID windows (#419)
The /track/getList endpoint caps at 50 IDs per call. Sending more in a
single POST either errors out or silently truncates, both of which take
the entire call site down.

For QConnect this surfaced as: pushing a >50-track playlist from the
Qobuz Android app to QBZ caused the materialization step (corebridge
get_tracks_batch hydration) to return an error, abort, and leave only
the 1-track placeholder queue from the earlier align-cursor pass. The
visible symptom was "only the first track loads on Connect playlists
above 50 tracks".

The fix is at the helper level rather than in QConnect: split inputs
larger than 50 into 50-ID windows fetched serially, preserve input
order in the returned Vec. Single-window path is unchanged for the
common case. Same return shape, same error type, so the four call
sites (corebridge.materialize_remote_queue, qbzd qconnect, V2
v2_get_tracks_batch, qbzd catalog) all benefit transparently without
edits to QConnect orchestration code (auto-advance, deletion timing,
queue ordering are untouched).

Serial loop instead of the concurrent fan-out used by get_playlist —
lower risk for the first fix; if QConnect playlists of 200+ tracks
start feeling slow we can revisit with try_join_all.

cc
2026-05-13 11:37:33 -06:00
Afonso Jorge Ramos 5865c16624 docs(player): explain set_stream_error(false) message swallow
Clearing the error flag also drops any pending stream_error_message.
A fast record_stream_error -> set_stream_error(false) -> polling-tick
sequence will silently swallow the toast — that is the intent (a
recovered error is not user-actionable), but it surprises future
readers, so document it inline.
2026-05-13 18:07:54 +01:00
Victor RH 0daca399cd Merge pull request #420 from afonsojramos/refactor/internal/macos-shared-mode-followup
refactor(audio): macOS shared-mode polish + review follow-ups from #410
2026-05-13 10:56:38 -06:00
Afonso Jorge Ramos 04e17cc449 feat(player): expose user-readable stream_error_message channel
Pairs the existing stream_error AtomicBool with an Option<String> that
the audio thread can populate when a stream init failure has a
meaningful cause to surface to the user (currently: the macOS
CoreAudio rate-mismatch path).

set_stream_error(false) also clears the message; record_stream_error
sets both atomically; take_stream_error_message drains so the UI
fires its notification exactly once per error.
2026-05-13 13:09:18 +01:00
Afonso Jorge Ramos a7b40bf6bb perf(player): cache CoreAudio nominal rate query for 750ms
evaluate_stream_recreate runs on every track change on the audio
thread, and each call issued two HAL syscalls
(resolve_output_device_id + get_nominal_sample_rate). Cache the result
per-thread for 750ms — long enough to absorb the bulk of repeats
within an album, short enough to notice a system rate change.

Cache invalidates on device-name change, so switching the output
device still triggers a fresh query.
2026-05-13 09:23:39 +01:00
Victor RH da3c1e86ee Merge pull request #410 from Vudgekek/fix/internal/macos-shared-mode
fix(audio): fix macOS Shared Mode playback from changing speed
2026-05-12 23:51:44 -06:00
vicrodh bdf6ca30e5 fix(local-library): prefer album metadata over folder-derived title (#411)
User-reported: Local Library Albums view showed the folder name as
the album title even when the album tag in the track metadata had
a different value — e.g. folder `Alle Songs` shadowed metadata
`ALBUM.`. Cause: the three metadata-grouped album queries (full-load
`get_albums_metadata_grouped`, chunked `get_albums_metadata_page`,
and `count_albums_metadata_for_page`) projected the title via
`COALESCE(album_group_title, album, 'Unknown Album')`.

`album_group_title` is the snapshot the scanner wrote — it
contains the tag's album when the tag was present at scan time,
otherwise the folder name. So whenever a tag was missing or
changed after scan, the folder name would win over the live
`album` column.

Fixed the priority by querying `album` first and falling back to
`album_group_title` only when the live metadata is empty / NULL /
'Unknown Album':

  COALESCE(
      NULLIF(NULLIF(TRIM(album), ''), 'Unknown Album'),
      album_group_title,
      'Unknown Album'
  )

Folder-grouped queries (which use `album_group_key` first and
intentionally prefer folder-derived titles) are unchanged.

Closes #411

cc
2026-05-12 18:10:59 -06:00
vicrodh 447344c8c0 fix(local-library): plex id double-prefix + scrim z-index over buttons
Two bugs from the same session:

  - The plex_aggregated CTE prefixed `group_key` with `'plex:'`, but
    `plex/mod.rs::plex_album_key()` already returns `"plex:<hash>"`.
    Result: album.id was `"plex:plex:<hash>"` from the chunked path,
    which `handleAlbumClick` couldn't resolve, so opening an album
    showed NO TRACKS. Fixed: use `COALESCE(album_key, 'plex:' ||
    rating_key)` so populated rows pass through untouched and only
    the rating_key fallback gets the prefix added. GROUP BY mirrors
    the same expression for consistency.

  - On AlbumCardLibraryLite the scrim (`.cover-wrap::after`) and the
    overlay container (`.overlay`) were both at `z-index: 2`. Pseudo-
    elements paint after all DOM children at the same z-index, so
    the scrim ended up ON TOP of the buttons, hiding them on hover.
    Re-laid the layering so it's monotonic top-to-bottom:
        1  cover img
        2  scrim
        3  overlay (action buttons)
        4  select-checkbox + source-badge-slot

Plus: restored the dark `rgba(0,0,0,0.85)` hover scrim itself,
removed the `::after` accent-border ring + title-colour cue. Per the
2026-05-12 user call ("Hasta quisiera el overlay de regreso") — the
Plex thumbnail downscale (9f417999) made the scrim's opacity
transition cheap again since the underlying paint is now 220x220
not 1000x1000. Buttons go back to opacity 0 → 1 on hover with a
single transition on `.action-buttons`. White-outline circles
(transparent fill) since the scrim provides contrast.

cc
2026-05-12 16:40:45 -06:00
vicrodh 1aea33cf19 feat(local-library): Plex consolidation in chunked albums page query
Plex cache lives in a separate SQLite database
(`<data_dir>/qbz/plex_cache.db`), which is why `v2_library_get_albums_page`
previously only saw local_tracks and the LocalLibraryView chunked
path had to fall back to the legacy virtualizer when Plex was on.
That fallback meant Plex-dominant users (which is the common case)
got none of the chunked-store / recycling-pool perf work.

Now consolidated server-side via SQL `ATTACH DATABASE` + `UNION ALL`:

Backend (qbz-library):

  - `get_albums_metadata_page` accepts `plex_cache_path: Option<&Path>`.
    When the file exists it `ATTACH`es the plex DB to the connection,
    runs a CTE that aggregates `plex_cache.plex_cache_tracks` by
    `album_key` into the same column shape as local_aggregated, and
    UNIONs both before the filter/sort/pagination step. DETACHes at
    the end so subsequent queries don't see stale attachment.
    Plex group_key prefixed with `'plex:'` so it doesn't collide with
    local group keys; `'plex'` literal in the `source` column drives
    the frontend's SourceBadge mapping.
  - Schema differences smoothed via `CAST` in the plex arm:
    `duration_ms / 1000 → total_duration` (matches local seconds),
    `sampling_rate_hz → sample_rate` (INTEGER → REAL).
  - `count_albums_metadata_for_page` mirrors the same UNION so total
    is consistent across empty-page edge cases.

Backend (Tauri command):

  - `v2_library_get_albums_page` accepts an `include_plex` bool.
    When true, resolves the plex cache path the same way
    `plex/mod.rs::open_plex_cache_db` does and passes it through.

Frontend:

  - Chunked store's `LocalAlbumsQueryParams` carries an `includePlex`
    flag, included in the `paramsEqual` check so toggling Plex
    visibility invalidates the chunk cache.
  - LocalLibraryView drops the `!isPlexLibraryEnabled()` guard from
    both the `$effect` that calls `setParams` and the render
    condition that picks chunked vs legacy. The new path serves
    Plex-only, local-only, and mixed libraries the same way.

cc
2026-05-12 15:19:18 -06:00
vicrodh 8f2cd965b4 feat(local-library): server-paginated chunked store for Albums grid
Phase A of the Plex-style port: the Albums grid no longer keeps the
full collection (1200+ rows on the user's library) in reactive Svelte
state. Instead, it pulls a single chunk at a time from a new Tauri
command, with the recycling-grid pool firing `requestIndex` for
indices whose data hasn't arrived yet.

Why this matters: Phase B (recycling pool against an in-memory array)
removed mount/unmount churn during scroll but left two costs in
place — every reactive tick still ran the `$derived` filter/sort
pipeline over all 1200 records, and the records themselves still
sat in Svelte's reactivity graph. Under software compositing the
combined cost was still the dominant scroll-time spike.
Server-side pagination eliminates both: sort + filter run in SQL,
only the visible chunk's records are in memory, and the per-tick
$derived work disappears.

Backend:

  - `qbz-library/src/models.rs` — new `AlbumsMetadataPage` struct
    (`{ albums, total }`) returned by the paginated query.
  - `qbz-library/src/database.rs` — `get_albums_metadata_page`:
    reuses the existing track-aggregation CTE from
    `get_albums_metadata_grouped`, adds a HAVING-style search filter
    over the aggregated title/artist (mirroring the legacy
    `matchesAlbumSearchFast` semantics), validated allowlist-based
    `ORDER BY` for artist/title/year × asc/desc, and
    `LIMIT ?/OFFSET ?` on the filtered set. Total comes from
    `COUNT(*) OVER ()` in the same query for one round-trip; a
    fallback `count_albums_metadata_for_page` covers the empty-page
    case where the window function has no row to attach to.
  - `src-tauri/src/commands_v2/library.rs` — new
    `v2_library_get_albums_page` Tauri command, registered in
    `lib.rs`. `include_qobuz_downloads` is read from
    `DownloadSettingsStore` to match the legacy
    `v2_library_get_albums_metadata` behavior.

Frontend:

  - `src/lib/discovery-v2/localAlbumsChunkedStore.svelte.ts` — new
    class-backed store with `version` + `total` as `$state`, sparse
    `Map<chunkIdx, LocalAlbumLite[]>`, in-flight `Set<chunkIdx>`
    dedup, and a generation counter that drops stale responses if
    the user switches filters mid-flight. `getAlbum(globalIdx)` is
    sync (null for missing chunks); `requestIndex(globalIdx)` fires
    the fetch.
  - `src/lib/components/views/LocalLibraryView.svelte` — when the
    Albums tab is in grid mode AND grouping is off AND no quality
    filters are active (the common case), render through
    `AlbumGridPool` bound to the chunked store. Other paths
    (grouped, filtered, list mode) keep the legacy
    `VirtualizedAlbumList` so we don't lose features we haven't
    server-side yet. A `$effect` mirrors search/sort/source state
    into `setParams` whenever they change; the store no-ops on
    unchanged params.

Plex consolidation (other source listed in the audit) deferred to a
follow-up: `plex_cache.db` is a separate SQLite file, so unifying
needs `ATTACH DATABASE` + `UNION ALL` from the same aggregation,
which is a clean delta once the local-only path is verified.

cc
2026-05-12 13:25:47 -06:00
Afonso Jorge Ramos 1edde8a28a fix(audio): rephrase CoreAudio rate-mismatch errors for end users
The error strings flow up through stream_error and end up in user-
visible logs. Replace the internal-shaped messages ("opened at XHz
while device nominal rate is YHz") with text that names the cause,
the remediation, and the reason the fallback path is refused.
2026-05-11 15:19:30 +02:00
Afonso Jorge Ramos 9e632386b9 fix(audio): retry CoreAudio shared-mode open on rate mismatch
CPAL caches device configs and can briefly return a stale sample rate
right after CoreAudio changes the device nominal rate. Before this
change, opening at the stale rate failed the post-open validation and
returned Err, leaving the user with no audio on the first track after
a rate change.

Drop the bad sink, re-resolve the device, re-query nominal rate and
try once more with a 50ms backoff. Two attempts in total. Turns the
racy window into transparent recovery.
2026-05-11 15:16:46 +02:00
Afonso Jorge Ramos c408ea73cb test(player): cover compute_needs_new_stream decision rule
Extract the boolean decision out of evaluate_stream_recreate into a
pure function so the truth table can be tested without faking a
MixerDeviceSink or AudioSettings mutex. Eight cases cover the matrix:
no stream, unchanged format, format change with and without each
bit-perfect backend, and the CoreAudio shared-mode rate-mismatch path.
2026-05-11 15:15:57 +02:00
Afonso Jorge Ramos ac60113cdd refactor(player): extract StreamRecreateDecision helper
The rate-mismatch detection block was duplicated between the Play and
Stream handlers, and each handler acquired thread_settings.lock() three
times in a row. Extract into one helper that reads settings once and
returns every flag the recreate decision needs.

Also drops a dead else-branch that logged "Creating initial audio
stream" — unreachable inside if stream_opt.is_some() — and the
redundant info-log that re-announced the rate-mismatch reason at
recreate time (the warn-level log inside the helper already says
"Recreating stream to avoid wrong-speed playback").
2026-05-11 15:14:54 +02:00
Afonso Jorge Ramos 3b34a734b4 refactor(player): rename current_sample_rate to current_track_sample_rate
The field tracks the decoded source format, not the output stream's
hardware rate. The names are now explicit so future readers don't
conflate the two — on macOS shared mode these can legitimately differ
since Rodio resamples internally.
2026-05-11 15:13:29 +02:00
Vudgekek 7bad704e21 fix(audio): fix macOS Shared Mode playback from changing speed 2026-05-09 23:43:26 -05:00
vicrodh 2f2dbe626b perf(local-library): push sort into SQL and drop localeCompare from groupTracks
Two cooperating changes that flatten the long sort tail in the Tracks tab:

1. search_with_filter now ORDER BYs (album, COALESCE(album_artist, artist),
   disc_number, track_number, title) COLLATE NOCASE — matching the default
   album-grouped browsing. SQLite handles 100K rows in well under 100ms;
   the JS sort took several seconds for the same volume.

2. groupTracks replaces the chained localeCompare with decorate-sort-
   undecorate using precomputed composite keys (lowercase strings with a
   sentinel separator and zero-padded numeric segments). The Intl collator
   instantiation per comparison was the bulk of the per-pass cost.

Both changes preserve the existing ordering semantics for the album, artist
and name modes; locale-specific accent ordering becomes lowercase ASCII
order, which is acceptable for library browsing.

Tier 3 of three.

cc
2026-05-08 23:04:36 -06:00
vicrodh 87fbc1bc4e feat(qbz-library): add count_folder_tracks_recursive
Lightweight COUNT(*) helper for the tree-mode scan-root rows that
needed a recursive descendant count. Same network-folder filter as
the listing primitives.

cc
2026-05-08 17:23:21 -06:00
vicrodh 1d68f7516e feat(qbz-library): network-folder filter on folder-tree primitives
list_folder_children, list_folder_tracks, list_folder_tracks_recursive
now apply the same network-folder exclusion that flat-mode search
honors, so the tree rail and recursive playback see the same source
of truth. Tests cover the on/off cases.

cc
2026-05-08 16:58:59 -06:00
vicrodh 5e2670f59f test(qbz-library): empty-folder case for list_folder_tracks_recursive
Verify the recursive listing returns an empty Vec (not an error) when
the requested folder path has no descendants — frontend treats empty
as "nothing to play/queue" and skips the toast.

cc
2026-05-08 16:37:08 -06:00
vicrodh 93a242e0d0 feat(qbz-library): add list_folder_tracks_recursive primitive
Returns full LocalTrack rows for every descendant under a folder path,
filtered by COALESCE(source, 'user') = 'user' so qobuz_download rows
never leak. Ordering is file_path ASC for stable on-disk reading
order across heterogeneous subtrees. Reuses escape_like_pattern so
literal '_' / '%' in path segments don't behave as LIKE wildcards.

Used by the tree-mode multi-select to populate selectedTrackIds when
the user ticks a folder-row checkbox; returns the full record (not
just IDs) so the frontend can build queue items without a second
round-trip.

Adds three unit tests covering the recursive walk, file_path
ordering, and special-character escape handling.

cc
2026-05-08 16:17:21 -06:00
vicrodh 1bb353429c chore(qbz-library): address Task 1 review nits
- Replace the false "SQLite ORDER BY fragility" rationale on the
  Rust-side sort in list_folder_children with the actual reason
  (rows already in memory; Rust case-insensitive compare clearer
  than COLLATE NOCASE on a CASE-derived column).
- Drop the redundant ESCAPE '\\' clause on the literal '%/%' pattern
  in list_folder_tracks. The pattern contains no escape character;
  the clause was a copy-paste artifact from the user-input LIKE
  patterns where ESCAPE is required.

No behaviour change.

cc
2026-05-08 14:05:49 -06:00
vicrodh 8481f7e8b3 feat(qbz-library): add list_folder_children + list_folder_tracks queries
Two new SQL primitives that walk local_tracks.file_path to reconstruct
filesystem hierarchy without schema changes. Both filter
COALESCE(source, 'user') = 'user' so Plex and Qobuz downloads are
excluded from the eventual tree-mode view in the Folders tab. Both hit
idx_tracks_file_path for the LIKE prefix% portion; per-row level
extraction is in-memory over the prefix-narrowed candidate set. New
FolderTreeEntry enum carries the child rows. Unit tests cover folder/
track interleaving, source filter, special-character paths, and
direct-children vs recursive scoping.

cc
2026-05-08 13:48:26 -06:00
vicrodh a67e3c9217 Merge PR #391 (Vudgekek): macOS CoreAudio Exclusive Mode
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
2026-05-07 19:09:37 -06:00
Vudgekek 5f10b4baa4 fix(audio): check real CoreAudio device rate with macOS Shared Mode 2026-05-07 14:51:51 -05:00
vicrodh d2185f7a6b fix(qbz-audio): resolve ALSA card by id, not by long name
The CARD= parameter in ALSA device strings refers to the card's short
id (e.g. "C20", "HDMI", "Generic"), not its long descriptive name
("Cambridge Audio USB Audio 2.0"). The previous resolver called
Card::get_name() and silently swallowed errors, so every CARD=<id>
lookup failed and acquire() degraded gracefully -- meaning the
reservation has been non-functional for users with named-card device
strings since Task 3 landed.

Reported by the maintainer on Cambridge DacMagic Plus exposed as
front:CARD=C20,DEV=0, 2026-05-07. Smoke-test scenario 2 had been a
false positive: audio worked because we degraded silently, not because
the reservation succeeded.

In alsa-rs 0.10, Card::get_name() wraps snd_card_get_name and despite
the name returns the LONG descriptive string. The short id (what
aplay -l shows in brackets and what CARD= references) is exposed via
ctl::CardInfo::get_id() (snd_ctl_card_info_get_id). Open a Ctl per
card, read the id from its CardInfo, and surface ALSA enumeration
errors as ReservationError::AlsaError instead of unwrap_or_default()
into an empty string.

cc
2026-05-07 12:07:03 -06:00
vicrodh a4eb441c4e fix(qbz-audio): accept all ALSA plugin prefixes; degrade on parse failure
The previous parse_card_index only accepted hw: and plughw: prefixes,
which broke playback for users whose ALSA device string uses front:,
plughw:, surround*:, iec958:, hdmi:, etc. The plugin prefix is
irrelevant for D-Bus reservation purposes - what matters is the card
identifier in the args. Generalise the parser to extract the card
from any plugin prefix.

Defence in depth: when parse_card_index fails (unrecognised string,
no card identifier extractable, etc.), acquire() now logs a warning
and returns a degraded guard instead of propagating the error.
This preserves playback for non-card-targeting names like 'default'
and 'pulse' that are not meaningful for per-card reservation.

Reported on Cambridge DacMagic Plus exposed as front:CARD=C20,DEV=0,
2026-05-07.

cc
2026-05-07 11:10:12 -06:00
vicrodh 6b7ed75cc9 chore(qbz-audio): polish AlsaDirectStream reservation wiring
- Skip the 50 ms PipeWire-vacate margin on the degraded D-Bus path;
  no reservation transition happened, so no settle delay is needed.
- Add TODO(Task 5) breadcrumb for the app_device_name placeholder
  so Task 5's UI surfacing wires it to the user-facing DAC name.
- Soften the timing-claim comment on the sleep; the specific "~2-4 ms"
  was spec-cited, not measured in this commit.
- Lift the 50 ms literal into a named PIPEWIRE_VACATE_MARGIN const.

No behaviour change for the active-reservation path.

cc
2026-05-07 10:19:59 -06:00
vicrodh 92633dcf12 feat(qbz-audio): hold device reservation for AlsaDirectStream lifetime
Acquire org.freedesktop.ReserveDevice1.Audio<N> before opening the PCM,
hold for the entire stream lifetime, release on Drop after the PCM
closes. Closes the path where exclusive open would silently fight an
active PipeWire route.

Field order in AlsaDirectStream is significant: _reservation is the
last field so it drops AFTER the PCM closes, ensuring the kernel-level
exclusive lock is released before the D-Bus bus name is freed and
PipeWire reclaims the device.

This is the canonical Lifetime-A consumer the device_reservation
acquire doc-comment's tight-coupling rule allows: a DeviceReservation
created immediately before a real PCM::new() and held for as long as
the PCM is open. A 50 ms defensive sleep gives PipeWire time to vacate
the device after releasing the reservation.

cc
2026-05-07 10:09:07 -06:00
vicrodh 41b844cfa8 chore(qbz-audio): document tight-coupling rule and refactor contention proxy
- Document the load-bearing tight-coupling rule on acquire(): never
  construct a DeviceReservation without an immediate device consumer.
  Cambridge DacMagic Plus was stranded by a standalone reservation
  churn during initial Task 2 smoke testing on 2026-05-07.
- Document the Connection lifecycle: zbus does not pool session-bus
  connections, so acquire() pays a SASL handshake per call. Task 5
  will add acquire_with_connection() to reuse a long-lived bus.
- Refactor resolve_contention to open a single holder Proxy and read
  Priority + ApplicationName through it, instead of opening two.
- Specificity polish on the QBZ_APPLICATION_NAME dead-code comment;
  defensive-InQueue comment; release_name From-chain comment;
  clippy doc-list-overindentation fixes.

No behaviour change.

cc
2026-05-07 10:00:57 -06:00
vicrodh bc5d316e5a feat(qbz-audio): wire zbus client for ReserveDevice1 acquisition
Implements the full priority-resolution algorithm: request bus name
with DO_NOT_QUEUE; on EXISTS look up holder Priority via the
Properties interface; if our priority (5) is higher, call
RequestRelease and retry with REPLACE_EXISTING; if equal-or-lower,
return HigherPriorityHolder. AlreadyOwner is treated as success for
same-connection idempotency. Drop releases the bus name and logs
the ReleaseName reply variant.

D-Bus failure paths degrade to a no-op guard so playback continues
exactly as before in environments without a session bus.

Smoke-tested against PipeWire/WirePlumber via dbus-monitor.

cc
2026-05-07 09:43:37 -06:00
vicrodh f0ea0c88ec chore(qbz-audio): address Task 1 review feedback
- Add ReservationError::AlsaError variant; stop conflating ALSA
  enumeration errors with DbusError.
- Mirror ReservationError variant set on the non-Linux stub so cross-
  platform pattern matching compiles on every target.
- Add empty Drop to stub for RAII symmetry with the Linux variant.
- Collapse ReservationState to its real Task 1 shape (Degraded only);
  Task 2 reintroduces Active when it has real fields.
- Rewrite acquire() doc-comment to honestly describe Task 1's behaviour
  and hint at Task 2's full failure-mode set.

No behaviour change. Foundation commit for Task 2.

cc
2026-05-07 09:18:19 -06:00
vicrodh 4bc4213b97 feat(qbz-audio): add device-reservation module skeleton with parsers
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
2026-05-07 08:57:31 -06:00
Afonso Jorge Ramos 88e072a4ac fix(macos): harden CoreAudio exclusive stream handling
Address review feedback from the macOS Exclusive Mode PR.

Pin the resolved CoreAudio output device before acquiring Hog Mode so
System Default cannot drift between Hog Mode acquisition, sample-rate
switching, and stream creation.

Fall back to software volume when CoreAudio hardware volume is unavailable,
which keeps the volume slider usable on knob-only DACs.

Snapshot and restore CoreAudio hardware volume around Exclusive Mode so
leaving Exclusive Mode does not leave the output device at QBZ's last
hardware volume.

Refuse shared-mode fallback when macOS Exclusive Mode stream creation
fails, so the app does not silently play in non-exclusive mode while the UI
still reports Exclusive Mode.

Clean up Linux unused-variable warnings from the macOS-only guard plumbing.
2026-05-07 09:36:20 -05:00
Vudgekek 59b1ceec2e fix(macos): align exclusive device and hardware volume 2026-05-06 23:26:15 -05:00
Vudgekek d4a51a5c63 fix(player): recreate CoreAudio exclusive streams 2026-05-06 23:26:15 -05:00
Vudgekek e0151af6d9 feat(audio): add CoreAudio exclusive mode 2026-05-06 23:26:15 -05:00
Victor RH ecb8f96fa1 Merge pull request #390 from afonsojramos/bugfix/internal/track-change-click
fix(audio): eliminate macOS track-change crackle
2026-05-06 22:09:41 -06:00
vicrodh e8d58196c5 feat(album): plumb featured artists and parental warning
Add Album.parental_warning and Album.artists (with the new AlbumArtist
struct) to qbz-models so the /album/get response surfaces both fields.
Mirror them in the QobuzAlbum / AlbumDetail TypeScript types and
populate featuredArtists (filtered to non-main contributors) and
parentalWarning in convertQobuzAlbum. Wire onFeaturedArtistClick from
the route so featured artist names route to their discography.

cc
2026-05-06 22:06:54 -06:00
Afonso Jorge Ramos e93fcaec99 fix(audio): defer host-stream drop on Stop to reuse device across track changes 2026-05-07 02:32:39 +02:00
vicrodh 2b8e1a66e1 Merge branch 'feature/internal/local-library-tab-order' into pre-release
Includes Phase 2 (rename Albums to Folders + per-user tab order
preferences) and Phase 3 (new metadata-grouped Albums view + V2
commands + frontend wiring) of the Local Library design.

cc
2026-05-06 17:35:08 -06:00
vicrodh 54074a5580 feat(library): add metadata-grouped album queries
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
2026-05-06 17:10:51 -06:00
vicrodh 7eba2749f5 fix(library): preserve per-track artwork during scan, cache folder lookups
The scan loop called update_album_group_artwork after every successful
track insert, which propagated one track's artwork to every track in
the same group. Tracks with unique embedded covers ended up sharing
whichever cover was processed last. Drop the call; per-track artwork
is resolved correctly by extract_artwork (embedded) + find_folder_artwork
(cover.jpg fallback) before insert_track.

Replace the propagation's caching effect with an in-memory per-folder
HashMap that memoizes find_folder_artwork for the duration of the scan
run, avoiding repeated folder walks for tracks without embedded art.

Mark update_album_group_artwork #[deprecated] so no future caller
reintroduces the bug. Keep it compilable.

Existing libraries already have collapsed artwork paths in the DB and
need a re-scan to recover per-track covers; called out in release notes.

cc
2026-05-06 16:32:27 -06:00
vicrodh 6afbc94171 fix(qconnect): honor peer-controller seeks when qbz is the active renderer
Fixes #387: seeks issued from the official Qobuz mobile app while qbz
acts as the active renderer were silently dropped — audio kept playing
from the original position while the controller's progress bar locked.

Two independent gates were swallowing the command between the
WebSocket and the audio thread; either alone is enough to break the
flow, so both move together.

1. Upstream filter (crates/qconnect-app/src/app.rs)

   The is_echo guard treated every SET_STATE with playing_state=None
   AND current_track=None as a pure server echo and skipped emitting
   RendererCommandApplied. iOS sends a real seek as exactly that
   shape (only current_position_ms set, the rest unchanged), so the
   command never reached the bridge handler.

   Add current_position_ms.is_none() to the guard. Pure echoes still
   match (server echoes carry only next_track); position-only seeks
   pass through.

2. Downstream gate (src-tauri/src/qconnect/corebridge.rs)

   The seek branch was conditioned on peer_renderer_active — i.e. it
   only acted when qbz was the controller observing a peer renderer,
   never when qbz was the renderer itself. Removed; the existing
   is_echo_reset and abs_diff > 2 checks already filter the redundant-
   seek case the gate was originally added to defend against (147bcbd7).
   Drop the now-unused is_peer_renderer_active import.

Verified manually: seeks from iPhone (renderer_id=3) targeting qbz
(renderer_id=2) at ~1/4 and ~5/6 of a 150s track now translate to
local audio jumps to the correct positions.

cc
2026-05-06 14:21:27 -06:00
Afonso Jorge Ramos 468a421e26 fix(audio): gate 50ms post-stop sleep behind cfg(target_os = "linux") 2026-05-05 17:11:28 +02:00
vicrodh 1b01c95898 feat(qconnect-app): add QconnectStartupMode + compute_effective_startup
Pure enum + decision function for QConnect auto-connect-on-startup.
Persistence and CLI parsing stay in the Tauri adapter; this crate
remains UI-agnostic.

cc
2026-05-01 12:12:09 -06:00
vicrodh 43ac3e18fa fix(player): order gapless slot reset before track_id swap to close race
The audio-thread iteration that swaps in the next gapless track
mutates several atomics in qbz-player::player::SharedState. The
polling loop in lib.rs reads those atomics independently (one load
per field) at ~250 ms cadence and emits a PlaybackEvent that the
frontend handlePlaybackEvent then classifies. When the polling load
falls inside the swap window, it can observe an inconsistent mix:
current_track_id already advanced to the new track, but
gapless_next_track_id still pointing at that same track because the
reset hasn't run yet.

The frontend's isGaplessTransition predicate
(playerStore.ts) requires both `event.track_id !== currentTrack.id`
AND `event.gapless_next_track_id === 0`. The mid-swap event
satisfies the first and fails the second, so the event is mis-
classified as an external track change. The "Track changed
externally" branch then asks the queue manager for the current
queue track (still the previous track because the gapless flow
hasn't called v2_next_track yet) and prints the now-familiar
"Queue track mismatch or null: 212171 vs event: 216597" warn.

In 1.2.8 this race was self-recovering: subsequent ticks observed
the consistent post-swap state and isGaplessTransition fired one
tick later. dee987c1a tightened the gate with
`gaplessAttemptedForCurrent`, which gets reset to null by the
mid-swap event's external-track path; subsequent ticks then fail
the new gate and the misclassification becomes permanent for the
remainder of the track. UI stays on the previous title while the
audio plays the new one, and the queue cursor never advances —
visible to the user as "the player says it's still on Viento but
Afuera is playing".

Fix the race at the producer side: in both transition swap paths
(mainline pos>=dur detection and ALSA Direct take_source_transition),
clear gapless_next_track_id and gapless_ready BEFORE storing the
new current_track_id. With SeqCst ordering, any reader that
observes the new track_id is guaranteed to also observe the
cleared gapless slot — eliminating the inconsistent intermediate
state by construction. Cheaper than coordinating reads on the
consumer side and keeps the polling loop's lock-free design.

cc
2026-04-28 23:47:39 -06:00