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").
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.
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
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
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
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
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
- 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
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
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
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
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
- 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
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
- 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
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
- 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
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
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.
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
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
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
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
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
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
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
Two regressions ride together when gapless plays a chain of more than
two tracks:
- The pos/dur snapshot at the top of the audio-thread iteration is
captured BEFORE the gapless transition swap. After the swap
updates current_track_id and duration, the local pos/dur still
point at the outgoing track, and the "approaching end" check a
few lines below fires immediately for the new track with stale
299/299-style values.
- That spurious firing arms gapless_request_armed=true and sets
gapless_ready=true. gapless_request_armed only gets cleared again
on a future transition (which will never happen because the new
track has no prefetch queued) or on engine empty (i.e. when
playback has already stopped). gapless_ready also stays sticky-
true because nothing else clears it once the false-positive fires.
- End result: the new track plays through with the gate stuck
closed, the real "approaching end" check at ~T-10s never fires,
the frontend never gets prompted to call play_next_gapless for
track #N+1, and the audio thread reaches engine empty and stops.
Fix:
- Track whether a transition swap consumed pending in this
iteration via a local transition_consumed_pending bool. Skip the
"approaching end" check while that flag is set — the next
iteration will run with fresh pos/dur for the new track and
fire the trigger correctly.
- Reset gapless_ready=false in both transition swap paths
(mainline and ALSA Direct), mirroring the existing
gapless_request_armed=false reset, so neither flag survives a
transition.
cc
The marker becomes part of the queue state snapshot the frontend
already consumes via syncQueueState(). Single source of truth — no
parallel event channel, no separate store on the frontend.
Refs #356.
cc
Removes all tracks at indices > pivot, preserving the pivot itself.
Returns the count removed. Updates shuffle_order and history to
drop dangling indices. Auto-invalidates the stop-after marker if it
referenced any removed track.
Refs #356.
cc
set_queue, set_queue_with_order, and clear unconditionally drop the
marker (queue replaced/emptied → marked track may not exist anymore).
remove_track and remove_upcoming_track conditionally clear the marker
when the removed track matches it. move_track and add_* are unchanged
— a moved track is still the same track ID, and adding doesn't touch
existing tracks.
Refs #356.
cc
Auto-advance drivers (frontend natural-end handlers) call this with
the just-finished track ID. If it matches the marker, returns true
and clears the marker; the caller pauses instead of advancing.
Otherwise returns false — caller proceeds with normal advance.
Manual skip paths must NOT call this method.
Refs #356.
cc
Add `stop_after_track_id: Option<u64>` to InternalState plus three
methods on QueueManager: `set_stop_after(id)`, `clear_stop_after()`,
`get_stop_after()`. Setting on an ID not present in the queue is a
silent no-op so the marker can never reference a phantom track.
Refs #356.
cc
Qobuz returns a separate `version` field for tracks belonging to remix
albums, reissues, and deluxe editions (e.g. "Player's Ball Mix",
"Nine Inch Noize Version", "Remastered 2024"). The mobile app shows it
parenthesized after the title so remix albums are distinguishable from
originals; qbz only showed the bare title, making remix albums
impossible to navigate (issue #360).
Backend
-------
- Add `version: Option<String>` to qbz_models::Track (the V2 model
consumed by qbz-qobuz/qbz-library) and to api::models::Track (the
legacy struct still used by some V2 command paths).
- Add `version: Option<String>` to qbz_models::QueueTrack so the field
travels through the local queue, persistence, QConnect remote queue,
and frontend snapshots without being lost in conversion.
- Propagate `version` in every Track→QueueTrack converter:
qconnect::model_track_to_core_queue_track,
link_resolver::track_to_queue_track_from_api,
mixtape::enqueue::track_to_queue_track_from_api and the inline
builders. Plex/local builders set version=None (no edition concept
outside Qobuz).
- V2QueueTrack (the wire shape exposed to the frontend) gains the
field with serde default so older clients keep deserializing.
Frontend
--------
- New `formatTrackTitle(track)` helper in src/lib/utils/trackTitle.ts:
returns `title` when version is empty, else `title (version)`.
- Add `version?: string | null` to QobuzTrack, Track (UI), PlaylistTrack,
DisplayTrack, BackendQueueTrack, QueueTrack (UI), and PlayingTrack
so the field flows from the wire to the renderer.
- Propagate `version` through the four buildQueueTrackFrom* builders
in trackActions.ts and through the QobuzAlbum→AlbumDetail adapter.
- Use formatTrackTitle in the high-traffic renderers:
- NowPlayingBar (currentTrack derived in routes/+page.svelte)
- QueuePanel: now-playing label, upcoming-track rows, history rows
- VirtualizedTrackList: default getTrackTitle accessor
- AlbumDetailView: TrackRow title binding
- PlaylistDetailView: TrackRow title binding
- SearchView: popular-track card label and the two track-list
rows (Tracks tab + cross-search "All" view)
Tests
-----
- 196 lib tests pass (no behavior change for the no-version path).
- npm run check: 0 errors.
Follow-up: more views still pass `{track.title}` directly (HomeView,
ArtistDetailView, FavoritesView, ForYouTab, MixtapeCollectionDetailView,
LocalLibraryView, the various Suggest/Top/Label views, the immersive
panels, the miniplayer surface, and the Track*Modal stack). They all
render UI Track-shaped objects that now carry version, so each is a
mechanical "import formatTrackTitle + replace {track.title} with
{formatTrackTitle(track)}". Doing them in a follow-up commit to keep
this one reviewable.
cc
Path C: legacy CPAL output-sink enumeration was inlined directly in
commands/playback.rs (cfg-gated Linux + non-Linux variants), with
no equivalent in any domain crate. Extract a domain function
`qbz_audio::output_sinks::list_output_sinks` that returns the same
{name, description, volume, is_default} shape (mirrored as the new
public `OutputSinkInfo` struct) for both Linux and non-Linux
platforms, preserving the original logging cadence and the rule
that name == CPAL device name (so saved IDs reopen correctly).
Add v2_get_pipewire_sinks in commands_v2/audio.rs that calls into
qbz_audio directly — no `crate::commands::*` delegation. Updates the
two frontend call sites (AudioOutputBadges.loadStatus and
SettingsView.loadAudioDevices). Legacy command in
commands/playback.rs is preserved untouched; the audio-protected
files (pipewire_backend.rs, audio_settings.rs, player init_device)
are not modified.
cc
Path C: legacy command called client.get_releases_grid for the
artist detail view's release-type pagination grid. No existing
v2 equivalent, so add v2_get_releases_grid via QbzCore ->
QobuzClient::get_releases_grid directly.
cc
Path C: legacy command called client.get_artist_tracks (the
/artist/get?extra=tracks path) and returned a TracksContainer.
No existing v2 equivalent, so add v2_get_artist_tracks via
QbzCore -> QobuzClient::get_artist_tracks directly.
cc
Path C: legacy command called client.get_artist_detail with the
hard-coded mobile defaults of limit=1000, offset=0 to populate the
suggestions panel's playlists and tracks_appears_on. No close v2
equivalent existed (v2_get_artist_with_albums uses
extra=albums only), so add v2_get_artist_detail that goes through
QbzCore -> QobuzClient::get_artist_detail directly.
cc
Path C: legacy command projected `Artist.albums` from
client.get_artist_with_pagination(with_albums=true). The existing
v2_get_artist_with_albums returns the full Artist envelope, so
add a dedicated v2_get_artist_albums in QbzCore that performs the
same projection and exposes only ArtistAlbums to match what the
two callsites destructure.
cc
set_queue and set_queue_with_order unconditionally cleared history, which
wiped legitimate plays whenever QConnect echoed a queue version bump
(reorder, shuffle toggle, peer-driven snapshot) without changing track
identity. That is the missing-tracks half of issue #316.
Instead, remap history entries by track id: each pre-existing index is
resolved to its track id via the old tracks list, then looked up in the
new tracks list. Entries whose track id is no longer present are dropped.
When the new queue has zero overlap with the old, history clears
naturally; when the queue is a pure reorder, history survives intact.
Six unit tests cover pure reorder, additions, partial removals,
disjoint replacement, and verify the remapped indices (not just rendered
ids) point at the right new tracks.
cc
The MSG_TYPE_ERROR (cloud_type=9) arm previously logged only the payload
byte count, so when Qobuz cloud rejected a session (zombie session,
conflicting device, expired JWT, ...) we had no signal beyond an opaque
"bytes=13" TransportError. That hid the actual cause of the 2s flicker
loop seen after the app sat minimized for 1-2 hours (issue #358).
The qws.proto ErrorMessage (messageTypeId=9) carries:
uint32 msg_id = 1
uint64 msg_date = 2
uint32 code = 3
string descr = 4
Decode it via a local QwsErrorMessage prost struct, log a warn line with
the decoded code+descr, and emit a new TransportEvent::CloudError so
qconnect-app and qconnect_service can react. qconnect_service forwards
it as a "qconnect:cloud_error" diagnostic (level=warning) and logs it.
qconnect-app treats it as a no-op forwarder for now, matching the
existing pattern for other transport-only events. Decode failures fall
back to the previous TransportError path under a distinct stage name so
they are still visible.
Adds a tokio test that builds a real MSG_TYPE_ERROR frame and asserts
the transport emits CloudError (not the legacy TransportError).
Refs #358
cc
The native WS transport reset its reconnect counters as soon as the WS
upgrade succeeded. Qobuz cloud accepts the WS connection well before the
JOIN_SESSION exchange and rejects unhealthy sessions with MSG_TYPE_ERROR
right after, so the counter reset every cycle and the loop hammered the
endpoint at base backoff (~2s) forever (issue #358).
Move the counter reset to the moment the cloud emits a
MESSAGE_TYPE_SRVR_CTRL_SESSION_STATE event (the actual proof of a healthy
session-level join), and add a configurable maximum-attempts cap that
makes the transport stop and emit MaxReconnectAttemptsExceeded instead
of looping indefinitely.
- Add SessionEstablished and MaxReconnectAttemptsExceeded transport events.
- Add reconnect_max_attempts to WsTransportConfig (default Some(10)).
- handle_incoming_binary now returns whether SESSION_STATE was decoded.
- Reconnect helper returns a ReconnectOutcome enum (Continue/Shutdown/
Exhausted) instead of a bare bool.
- qconnect_service.rs logs the new events and emits a diagnostic when
the cap is exceeded so the higher layer can react (#358-fix-3 will
surface it in the UI).
- Unit tests cover the cap behavior and the unbounded fallback.
Refs #358
cc
Memory mitigations for issue #331 (Pi 3B / 1 GB hosts):
- qbz-core::system_capabilities reads /proc/meminfo at startup and
derives a MemoryProfile (Normal vs LowMemory). Cached in OnceLock.
- V2 prefetch (commands_v2/playback.rs) caps prefetch_count and
max_concurrent_prefetch from the profile, downgrades HiRes to
Lossless when allow_hires_prefetch is false.
- StreamingConfig::from_speed_mbps clamps the dynamic initial
buffer to a process-wide cap configured from the profile (256 KB
on LowMemory vs 2 MB historical max).
- AudioCache L1 cap also scales (50 MB on LowMemory vs 400 MB).
- Tokio watchdog spawned on LowMemory hosts polls MemAvailable
every 10 s; flushes audio caches when below 5 % to break the
swap-thrash loop codehd7 reported.
Includes the rodio 0.22 NonZero test fix (also in the bugfix
merge above) so the branch validates standalone.
cc