parse_login_response now reads user.country_code and user.language_code
(snake_case wire names, verified against Qobuz's own embedded login
fixture) into two new serde-defaulted Option fields on UserSession, so
pre-v10 persisted sessions still load with both as None (translation
stays Auto-off). qbzd's test construction updated for the new fields.
km
get_lyrics_url_raw/get_lyrics_url/get_lyrics take an optional ISO 639-1
language, appended to the signed param list only when Some (the existing
sign_request sort covers it). None keeps the original-only default flow.
When a translation was requested but the document does not embed one,
the envelope's translation_requested.lyrics_url is fetched as a fallback
translation source (spec section B.3); every gap degrades to
original-only. lyrics_probe gains --lang=<iso639-1> for parity.
km
QobuzLyricsDocument gains the optional translation member (same content
union as original, new in API 10.0), the content DTOs become
Serialize+PartialEq so the lyrics cache can persist a translation
verbatim, and a lang() accessor exposes the content language. New
fixtures cover embedded translations of each type (wsync/lsync/plain),
the lyricsUrl envelope with translation_requested, empty
translation_langs (feature off), and 9.x documents (no translation).
km
Integrates the 30 commits that landed after the version bump: Most Played
Albums, album-identity mode, multi-artist header, cross-tag scan (#447,
#507), local album play-next/queue (#636), Plex rating-key resolution,
ALSA unstick, gapless-late resume, DSD/hi-res DLNA (#646), media-server
token redaction, label web parity, macOS chrome/NPB work, qbzd/Connect
hardening, apt trigger (#648) and the flatpak twin talk-name drop. Also
includes the owner's latest TAG_DETAILS touch-ups.
km
Play-count album history store (album_play_history, WAL per ADR-002) hooked
into record_recent, Most Played Albums carousel on Home + For You (default
off), View-all page with search + per-card play count, i18n in all 8
locales.
Conflicts resolved against pre-release: kept the Slint-era Pinned section
alongside the new MostPlayedAlbums everywhere (DiscoverySectionId, prefs,
render kinds, nav, ContentView, HomeActions); AlbumCardItem carries both
is_pinned and plays (populated via card_to_item); .po files union-merged,
msgfmt clean; discover_prefs tests updated (home 15/8 ON, forYou 14/13 ON)
— 10/10 green. Release build green.
km
Session tokens in /audio/<token>/<id> must not appear in log bundles. Port
of the remaining delta of 9aab6233 (pr/605) onto current pre-release: add
redact_media_uri() to media_server.rs and redact the URL in the three
handle_request logs (request, 404 parse, 403 token mismatch). The device.rs
side and the CSPRNG tokens already landed via PR #586.
km
raw-window-handle 0.6 exposes only the content view; dereference the NSView
and get the NSWindow via -[NSView window] instead of reading a non-existent
ns_window handle.
km
The 2px .slint literal proved invisible (owner ask 2026-07-21, iteration
2026-07-22). The extra clearance is now set from Rust at startup
(ShellState.npb-small-extra, default 6px, macOS-only, QBZ_NPB_SMALL_EXTRA
env override) so tuning costs a ~2-minute qbz-bin rebuild instead of a
~2 h qbz-ui one on the 8 GB Mac. Linux stays pixel-identical (extra = 0).
km
The AlbumCard context menu on local albums (LocalLibrary grids, folder or
Plex source) routed play-next/queue to LocalLibraryActions.album-action,
whose handler only implemented play / toggle-select / favorite — the two
queue actions fell into a catch-all that logged "queue slice pending" and
did nothing.
Resolve the album's tracks with the same source-aware resolver play uses
(local_library::fetch_album_tracks_blocking — local folders AND Plex) and
enqueue them via playback::enqueue_local_tracks, mirroring the already-
working albums bulk-action arm. Track-level play-next/queue was never
affected. Port of d0f67149.
km
Port of 0c3b38f8 (bugfix/internal/local-library-tag-fallback) onto current
pre-release. The scanner read metadata from a SINGLE lofty tag
(primary_tag().or(first_tag())) with no per-key fallback to the file's other
tags. Old, repeatedly-retagged collections routinely carry the album /
Album Artist / date only in a secondary tag (ID3v1/APE/Vorbis) while the
primary tag lacks them:
- #447: the missed album tag let the folder-derived backfill land in the
album column and masquerade as metadata (folder-name albums, folder-level
grouping, MIN(year) across mixed folders).
- #507: the missed Album Artist left album_artist NULL, degenerating every
COALESCE(album_artist, artist) to the per-track artist (Various Artists).
extract() now falls back per key across tagged_file.tags() (primary first,
deterministic conflict policy) for title/artist/album/album_artist/genre/
catalog/track/disc/date. Folder backfills stay as-is for genuinely untagged
files. Tests adapted to the AlbumGroupMode signature; 75/75 pass. A full
library rescan picks the fix up (no migration).
km
The MyQBZ collections path stamps the per-item ALBUM boundary key
(plex:<hash>, for album-shuffle boundary detection) in source_item_id_hint,
but the Plex play path used that hint verbatim as the track rating key:
/library/metadata/plex:<hash> -> 404, the Plex track never started, and the
previous track kept playing while the UI showed the new one (continuous
playback Qobuz -> Plex broken, audio/UI desync).
Ignore any plex:-prefixed hint and fall back to the numeric queue id (=
rating key for the common numeric-key case). The LocalLibrary path (hint =
raw rating key, no prefix) is unchanged.
km
Docker/cross build of the slint-free daemon for the Pi streamer
(~90 s incremental, dist/qbzd-aarch64-linux); native path included for
aarch64 hosts.
km
The API serve loop is single-threaded; an inline block_on of the
load leg (next/previous/jump/play/cold-start) starved every route for
the whole fetch — clients read 'daemon not reachable' while the action
DID execute. The resolve+play leg now runs on the tokio runtime and
the routes ACK immediately; cheap gates (auth, 404, empty queue) stay
synchronous with their documented errors, and load failures latch into
last_errors.stream (visible via status). next/previous answer
{"queued":true,"direction":...} (CLI renders '-> queued (next)');
play/toggle cold-start report {"state":"loading"}; jump/play keep
their response shape. Live-verified: next returns in ~20 ms and status
answers during a load.
km
When a track ends before its gapless prefetch completes, 'track
finished (engine empty)' sets thread_state.is_playing=false, and the
late PlayNext still plays (the engine's append re-opens the writer
gate) but nothing restores the player-level flag: the position monitor
freezes, no next gapless arm, no QConnect reports, the queue cursor
stays on the finished track, and playback dies silently at the next
end. The PlayNext handler now captures engine.empty() before appending
and restores is_playing when the append landed on an empty engine —
a user-paused engine still holds its source (not empty), so manual
pause is never resumed by this.
km
Three transition killers on drivers that require an explicit drop
(snd-rpi-hifiberry/PCM5122; the desktop's USB DAC tolerated all three,
which is why only the Pi died):
1. stop() called prepare() without drop(): prepare from
RUNNING/DRAINING returns EBUSY there — every stop failed and left
the pcm in limbo. Now drop+prepare (drop EBADFD tolerated).
2. A write racing a still-draining pcm (natural end + late gapless
append) gets EBADFD, and snd_pcm_recover(77) is unsupported on this
stack, so the write died and the writer thread exited. New
recover_write_error(): normal recover first, then drop+prepare for
EBADFD (11 write sites via one helper).
3. snd_pcm_drain can block forever when the device stops clocking —
the writer wedged and no 'engine empty' ever fired. drain() is now
bounded: poll pcm.state() with a 10s deadline; the tail finishing
shows as XRUN (drop+prepare), and a stuck device is cancelled.
km
CPAL enumerates descriptions, never hw:CARD=... ids, so a playing
direct-hw stream read as 'not present'. device_present is now
device_open || enumeration-match.
km
The daemon was queue receive-only: a CLI/TUI/MPRIS/restored queue never
reached the cloud, so controllers rendered a different queue than the
one playing. New qconnect/publish.rs ports the desktop's
sync_local_queue_if_changed with its full gate set (active-renderer,
offline-only, echo-suppress vs last-applied, per-session pushed-ids
latch, all-or-nothing admission), triggered by a debounced
CoreEvent::QueueUpdated subscriber — which also covers queue edits
while paused/stopped, unlike a transition-only hook. Live-verified: a
CLI queue move produced exactly one QueueLoadTracks push (233 tracks)
and the cloud echo was suppressed.
km
Akamai answers small raw-url objects with ~106 headers, over hyper's
hard-coded 100-header h1 cap, so every reqwest fetch of those tracks
failed identically (streaming probe AND full download) and the track
never played — with the cause hidden by reqwest's Display. Now:
describe_reqwest_error() walks the source chain for logs; on the
header-flood signature the engine skips the doomed reqwest re-download
and falls back to play_track_resolved (CMAF, unaffected) plus an
explicit sync_current_to_id so the queue cursor follows the audio.
Any other failure keeps the previous behavior. Live-verified on the Pi
(DSOTM track 1 plays; gapless continues).
km
With a custom name set, the controller bootstrap announced it but the
deferred renderer join and resolve_local_identity used the DEFAULT name —
one device_uuid, two friendly names. When the server's ADD_RENDERER omits
the uuid (observed on the wire), the name-fingerprint self-match failed,
local_renderer_id stayed None and the is_local_renderer_active gate
dropped every renderer report (mute renderer, frozen controller seekbar).
default_qconnect_device_info() now resolves the persisted name on both
frontends, so all announce paths agree by construction. Wire-verified:
ADD_RENDERER carries the custom name and reports flow.
km
The token encryption key mixed in the XDG portal secret (session-bus
dependent): a login run in a graphical session wrote a token an
init-started/headless qbzd could never decrypt, reported as a bare
"not logged in". PortalKey policy: desktop root keeps Session, daemon
root uses Never; one-shot migration on read (decrypt with Session,
rewrite portal-free); oauth_token_file_present_at() +
latch_undecryptable_token() so status distinguishes "no token" from
"token I cannot decrypt". Live-verified 3x (OpenRC, headless Pi,
migration path).
km
Spec 2026-07-19-local-album-grouping-mode (B2). A compilation album (the
Saint Seiya case: 10 distinct track artists) now shows the single artist
line plus a '+N more artists' chevron that expands into the full
track-artist list, each entry clickable into the LocalLibrary Artists tab
(name-based open-artist). Distinct raw track artists in first-appearance
order, computed in apply_album_version into LocalAlbumState.all-artists.
Expansion is view-local and resets on album change; single-artist albums
never see the chevron. Track rows already showed their own artist
(verified, no change needed).
2 msgids ('+{} more artists', 'Show less') x 8 locales; slintcheck green.
km
Spec 2026-07-19-local-album-grouping-mode (A2-A5 + i18n). The Albums tab
gains a third header dropdown ('Albums by folder' / 'Albums by metadata')
and Settings > Local Library a matching 'Album grouping' row — both write
the same pref: LocalLibraryState.albums-id-mode, persisted in
locallibrary_ui.json (default 'folder': compilations and box sets stay
whole; the metadata split stays opt-in for #411's use case).
The group key changes with the mode, so switching reloads the Albums set
(reload_albums) and invalidates the Artists tab (its album cache groups
the same way — a folder-mode compilation cross-lists under every artist
in all_artists, the owner's 'appears in all artists' ask). Threaded
through spawn_albums_load, seed_counts, the Plex quality-hydration
aggregate lookup, and the artists feed; myqbz_builder pins Metadata
(artist-scoped candidates, not the Albums view).
5 msgids x 8 locales (msgfmt clean); slintcheck green.
km
Spec 2026-07-19-local-album-grouping-mode (A1 + C + D).
AlbumGroupMode { Metadata, Folder }: the Albums view can now group by the
album|artist metadata pair (today's #411 behavior) OR by the album-root
folder — a compilation with 10 track artists renders as ONE card, and
multi-disc boxes merge even when discs are tagged separately. Read-time
switch: no schema change, no rescan. Threaded through
get_albums_metadata_page / count / get_albums_metadata_grouped; the
cover-art lookup matches both key shapes. Title/artist derivation is
mode-agnostic (album tag preferred, album_group_title fallback, per-row
COALESCE(album_artist, artist) + COUNT(DISTINCT track_artist) > 1 ->
'Various Artists').
Untagged-root clamp (C): infer_artist_album no longer returns the library
ROOT's name as the artist when the album dir hangs directly off a root
(a tagless DSD set surfaced with artist 'Music'). extract_with_roots
threads the scan targets; plain extract keeps the legacy inference for
ephemeral/single-file paths.
Tests (66/66): folder mode compilation = 1 album + VA + all_artists,
album_artist respected, orphan bucket preserved, metadata split unchanged;
root-clamp cases incl. disc subfolders, 'Artist - Album' split, real
artist folder named like the root, and the no-roots legacy path.
km
LabelView parity with the revamped Qobuz web label page (semantics
verified live against play.qobuz.com via Playwright):
- Header: Follow + Shuffle (new play_label_top_shuffled, shuffle-then-
delegate to play_tracks_ctx) + overflow menu (Play next / Add to queue /
Share -> play.qobuz.com/label/<id>; no Song.link equivalent for labels).
No header Play by owner decision (the Popular Tracks play does the same).
- The Popular Tracks section '...' button was dead (fired an unhandled
'top-tracks-menu' action): now a popup with Play next / Add to queue
over all cached popular tracks. Section buttons wrapped so the 44px
primary and 32px secondaries share one vertical centre.
- Catalog/library toggle: the ArtistPage SegmentedTabBar verbatim, floated
right of the header cluster (visible only with library items; mirrors
the web's A/B-rolled 'Del catalogo / En la biblioteca'). Library tab
renders added tracks + albums on the label, backed by the new
library_by_label session index (AlbumCard.label_id / TrackCard.label_id,
seeded from the SAME two favorites fetches as library_by_artist).
- More Labels carousel now renders the Library LabelCard (new label-cards
mode in ArtistCarousel) instead of the legacy generic card.
- Artists carousel images: /label/page top_artists often carry no parseable
image, leaving placeholders forever. Restored the Tauri fallback wave:
custom artwork first, then v2/artist/get, fed through the stable
LabelArtist artwork targets.
i18n: zero new msgids (all reused across the 8 locales). slintcheck green.
km
With the custom chrome (titlebar_transparent + fullsize_content_view)
AppKit parks the native traffic lights at the stock ~28pt titlebar height,
visibly above the 42px header controls. macos_chrome.rs measures the close
button's centre in window coordinates and shifts the shared button
container by the delta to the 21pt header centre (custom chrome only,
one-shot after show; known gap: AppKit re-lays the titlebar on fullscreen
roundtrips). Deps: raw-window-handle direct + slint raw-window-handle-06 +
objc2-app-kit NSWindow feature, all macOS-target only.
NPB-Small on macOS gains 2px of bar height (ShellState.npb-small-height,
44px, growing upward from the bottom-anchored bar) so CJK/descender-heavy
meta lines stop clipping at the window's bottom edge. PlayerBarSmall pins
the song card to its old 39px top-anchored geometry: the art keeps its
exact top-relative position and the 2px land as bottom clearance. Linux
geometry is pixel-identical to before. slintcheck stub green.
km
The x86_64 workflow fires the repository-dispatch minutes after its own
publish, while the native arm64 build is still running — so qbz-apt's
update-repo downloaded the Release assets when only the amd64 .deb existed.
The arm64 .deb landed hours later with nothing re-indexing, leaving the
apt arm64 tree stuck at 1.2.15 (verified: QBZ_*_arm64.deb IS on the 2.0.x
releases). update-repo is idempotent (re-downloads every *.deb, rebuilds
both indices), so the second dispatch completes whichever arch the first
one missed, in either finish order.
km
DSD containers (.dsf/.dff) fell through to application/octet-stream in the
media-server MIME map, which strict renderers reject (AVTransport 714), and
the serve path read the WHOLE requested range into RAM before emitting a
single header byte — a 32/384 PCM or DSD track (0.7-1.4 GB) stalled the
response long enough for renderer fetch timeouts and blocked the
single-threaded server for every other request (the reported 'had to
restart QBZ' wedge).
- content_type_for_path + the cast_service UI mirror gain audio/x-dsf /
audio/x-dff (de-facto DLNA MIMEs; HQPlayer plays native DSF/DFF).
- open_range streams file entries straight from disk (seek + bounded Take);
headers go out immediately and RAM stays bounded. In-memory (Qobuz)
entries keep the bounded slice copy. 0-byte entries keep the old 500.
- DSD-aware cast quality label (was the nonsense '1-bit / 2822.4 kHz').
- Best-effort renderer stop() when DLNA load/play fails so it doesn't sit
half-loaded on a faulted URI.
cargo check + 7/7 qbz-cast tests (new streaming + MIME coverage).
km
The What's New / release-notes modal renders the GitHub release body through a
custom block model that stripped inline markup and had no link support, so a
markdown link showed as literal `[text](url)`. Now:
- inline links `[text](url)` collapse to just their text (clean attributions),
- a whole-line link becomes a KIND_LINK block rendered as a clickable accent
link opened in the browser (a new WhatsNewActions.open-url reusing open::that,
mirroring about.rs).
Adds a `url` field to WhatsNewBlock plus a small markdown-link parser. The 2.0.2
notes use it for the daemon-wiki link. Built + smoked at release (window ready,
ALSA 192kHz bit-perfect, playback OK).
cc
Workspace version 2.0.1 -> 2.0.2 (Cargo.toml + lock, workspace crates only —
no dependency re-resolution), a new metainfo <release> entry, and the release
docs: TAG_DETAILS.md (human-facing, About-modal/website format, 2.0.2 section
prepended above 2.0.1/2.0.0) + CHANGELOG.md (full detail since v2.0.1 — the
headless daemon, kiosk mode, dynamic background, cast/quality #638, audio,
rendering #617, library, themes and security work). Codename inherited on the
patch line; About-modal literal untouched.
cc
The `playback.mpris` field added to DaemonPrefs broke the qbz-app roundtrip test
(a `DaemonPrefs { .. }` literal missing the new field) — a compile error the
`test-crates` CI caught but `cargo test -p qbzd` didn't (it doesn't compile
qbz-app's test module). Add the field with a non-default value and assert it
survives the save/load roundtrip. Full non-UI workspace suite green (67 ok/0
failed) via scripts/cargo-test.sh.
cc
Integrates feature/qbzd-console (21 commits): the full headless-daemon CLI
parity surface (search/play/browse/discover/radio/reco/favorites/playlists/
queue/shuffle/repeat/lyrics/art/resolve), Last.fm/ListenBrainz scrobbling
(setup-TUI + CLI + scrobble-on-play + offline queue), MPRIS media controls
(TUI-toggleable, default on), the /api/events SSE stream + `qbzd watch`, GET
/api/artwork/current, and an init service-file generator (systemd/openrc/runit).
qbzd is a pure consumer of the audio crates: verified it still checks and passes
all 287 tests against pre-release's ALSA/DacCapabilities/models/player changes,
so the merge is textually and semantically clean (no conflict resolution needed).
cc
The bubble had a hardcoded 24px height, so the #638 Source/Output/cause tooltip clipped to its middle line. Height now follows the content: multi-line fits, a one-line tooltip stays compact.
cc
The no-art (number) variant drew a hardcoded #ffffff glyph with no backing, so it vanished on light themes (white-on-white). It now sits on a themed circular backing (polarity-baked alpha fill/border + shadow) and tints with Theme.text-primary; the row hover uses the themed alpha ramp too. Zebra left as-is to avoid a dark-theme shift. ADR-008 compliant (icon-only circle, not a pill).
cc
The delivered-first badge, amber downgrade arrow and Source/Output/cause tooltip only lived in AudioStamp (Small/Large/kiosk). The default New mode - and Classic - mount SongCard, whose badge was bound to the catalog max only, so a CD-capped stream showed '24-bit / 96 kHz' with no arrow. SongCard now consumes the same NowPlayingState delivered fields; no new strings (reuses AudioStamp's msgids).
cc
The per-renderer cap dropdown now uses the plain Settings product names (Hi-Res / CD Quality / MP3, looked up by key so they can't drift) instead of the misleading verbose 'CD - 16-bit / 44.1 kHz' form - a CD-tier track can be 48 kHz; the tier is a format_id, not a fixed rate. Removed the '...no 48 kHz option' caption. quality_label_from_track detail is now bit-depth-first to match every other badge.
cc
Learned per-tier ETA from the last successful build, a live elapsed/ETA/percent ticker while codegen is silent, and start/finish banners. Safe under zsh/oh-my-zsh (runs under bash, newline-terminated, TTY-gated colors). NO_TICKER=1 silences it.
cc
The cause-3 line "Limited by the quality cap for this device" only ever
fires while casting, yet it renders in the main window's badge tooltip
where its sibling cause-2 line uses "device" for the local DAC — same
word, two different devices. Interpolate the connected renderer's name
(CastState.device-name), the form the spec's 2.5 copy sketch specifies.
All 8 catalogs updated in place; the picker's "capped for this device"
suffix is unchanged (the device name sits directly above it).
cc
The hydration task's store order (values, then id) was documented as the
guarantee that a reader keyed on the id never sees another track's
params, but all three stores were Relaxed — program order alone does not
survive a weakly-ordered CPU (macOS ARM is a shipped target). Release
the id store and Acquire its load in hydrated_catalog_quality so the
claimed invariant actually holds; the value atomics stay Relaxed.
cc
delivered_tier_str's requested-mp3 shortcut is correct on the local
engine path (an MP3 stream decodes to 16-bit PCM, so the requested tier
is the only tell), but on the cast path a successful STREAMINFO parse
proves the served bytes are FLAC: an over-cap cache/offline serve under
an mp3 cap rendered "MP3" beside a measured "24-bit / 96 kHz" detail.
Derive the tier token from the probed bits whenever the probe read the
bytes; the shared helper still covers the probe-less fallback.
cc
after_track_change runs kick_prefetch even when play_audible routed the
track to a renderer, and the cast auto-advance funnels through the same
local advance. The prefetch fetched upcoming tracks at
local_playback_quality() — the DAC-capped tier — into the quality-blind
L1/L2 cache, and the cache-first cast resolve then served those bytes to
the renderer verbatim: the local device cap leaked into a signal path it
is not part of (precedence rule, owner decision), and the badge
misdiagnosed the resulting downgrade as catalog availability.
Resolve the prefetch tier once per kick_prefetch: the cast-effective
tier (preference clamped by the renderer's manual cap, via the new
casting_prefetch_quality accessor) while a renderer is connected, the
device-capped local resolve otherwise. The single resolve also keeps the
network-throttle bandwidth estimate agreeing with what the prefetch
actually requests. Skipping the prefetch during cast was rejected: the
cache-first cast resolve deliberately consumes it (fast advance, P1).
cc
The four msgids fix 1 left en-only — the streaming-quality-setting cause
line, the no-higher-quality cause line, and the offline/cached over-cap
picker suffixes — now carry real translations in es/de/fr/pt/ru/ja/nl,
inserted in the same tail-block order as en.po. Register and vocabulary
match each catalog's existing quality-cap block (es tu/neutral, de Sie,
fr vous, pt-PT, ru formal, nl je) and reuse the catalogs' established
terms (Calidad de streaming, Titel, piste, faixa, качество потока,
ストリーミング音質, nummer). Informative tone throughout — a downgrade
the user configured is stated as fact, never as an error.
With this, every user-visible string introduced by the four #638 fixes
exists in all eight bundled catalogs (verified against the full
pre-release..HEAD diff, 16 msgids x 8 locales).
cc
register_qobuz now resolves through effective_cast_quality: the user's
streaming preference clamped by the connected renderer's stored cap,
lowest tier wins, tie reports RendererCap (spec 2.2 - the more
specific, more surprising constraint). The request-time min is the
ENTIRE enforcement (owner decision C): no cache clear on cap change,
no bypass, no re-fetch, no conversion - locally-existing bytes above
the cap go out as-is and stay disclosed by the fix-1 surfaces (the
existing over-cap info log now names RendererCap-capped serves too via
the threaded request cause). The resolution NEVER consults the local
DAC cap: cast code keeps calling the pure playback_quality(), never
local_playback_quality() (precedence rule - the local DAC is not in a
cast's signal path; stated in the doc comments).
Connection state carries the renderer's stable identity: DLNA keys
dlna:<UDN> (always stable, F14); Chromecast keys chromecast:<id> only
when the mDNS TXT id record was present - the fullname fallback would
detach on rename, so those devices get no cap row (logged at connect,
which also logs identity + cap key so a 'my cap does not apply' report
is diagnosable from the log alone). Cap row state pushes on connect,
clears on disconnect/shutdown.
set_device_cap persists index 0..3 as remove/hires/cd/mp3 into
ui_prefs.cast_quality_caps with the friendly name; wired to the new
CastActions callback. push_cap_options seeds the option labels
(reseed_i18n_labels hook, so a live language switch re-translates
them) and the option-0 'Follow app setting' parenthetical, which the
Settings streaming-quality arm now refreshes on change.
No protected file touched: qbz-audio and qbz-player are not in this
diff; the audio path is untouched (cast bypasses the local backend
entirely).
cc
CastState gains the fix-4 cap-row surface (device-cap-available/-key/
-options/-index + global-quality-label) and CastActions the
set-device-quality-cap(cap-key, index) callback - property/callback
additions to existing globals only, no app.slint export change. The
connected panel gets one label-left / QbzSelect-trailing row per the
flyout alignment rule (explicit menu-width, popup wider for the long
option labels, no pills), visible only when the renderer has a stable
identity, plus the muted caption that is the literal answer to #638:
Qobuz sells four discrete tiers, there is no 48 kHz option.
The quality-line suffix property now also names the cap at work
(cause 3, 'capped for this device') when no over-cap disclosure takes
precedence. Copy discipline throughout: the cap is the user's own
quality/bandwidth choice - never an implied device incapability
(renderers accept ~anything and downsample internally).
i18n: 8 new msgids in ALL EIGHT locales (en identity-translated),
including the cause-3 tooltip line AudioStamp pre-wired in fix 1.
Unit style matched per catalog (ru Cyrillic units, nl decimal comma).
Validated with the slintcheck stub (full tree + all catalogs).
cc
New cast_quality_caps map on UiPrefs, keyed dlna:<UDN> /
chromecast:<mDNS id> (raw stable ids, never option indexes) with the
tier key plus the renderer's friendly name for display/re-matching.
Absent key = follow the global streaming-quality setting. Only
mp3/cd/hires are ever stored - a Hi-Res+ cap is a no-op against the
preference, so it is not offered.
ui_prefs.json is the spec-chosen home (same store as the
streaming_quality the cap clamps): the settings bundle reads this file
key-by-key and only picks up streaming_quality, so per-renderer caps
are intentionally machine-local - a renderer id is LAN-local and
meaningless (or colliding) on another box. serde(default) keeps older
files deserializing; pinned by the extended legacy test plus a
roundtrip test.
cc
The mDNS TXT id record is the Cast device UUID - stable across restarts
and renames, directly comparable to a DLNA UDN. When a device omits it,
discovery falls back to the mDNS fullname, which is derived from the
friendly name and silently detaches the moment the user renames the
speaker. The per-renderer quality cap (fix 4) must not be keyed on such
an id - a cap that silently stops applying is the failure mode #638
exists to eliminate - so DiscoveredDevice now says which case it is.
Additive field on the discovery DTO only; no behavior change for
existing consumers. Verified: the struct has a single construction site.
cc
Settings > Audio (#638 fix 3, spec C.6): the "Limit quality to device"
toggle's description now states what the wired setting really does — it
caps the REQUESTED tier, local playback only, never casting. The
conditional "Maximum sample rate" Hz dropdown is replaced by a read-only
"Detected device limit" line ("192 kHz · Hi-Res+") fed by the probe: a Hz
control was the documented root-cause trap (it offered rates the app
cannot request — Qobuz sells four tiers, and no 48 kHz tier exists), and
with real detection there is nothing left to pick. When the probe fell
back to the assumed common rate set, a plain informative caveat below the
row discloses that the cap still applies but may not match the hardware
(owner Decision B: no warning icon, no error styling).
Removes the dead dropdown plumbing from the desktop bridge only:
MAX_SAMPLE_RATES, the SettingsState sample-rates/sample-rate-index
properties, the snapshot fields/derivation/pushes, the "sample-rate"
select arm (and with it a dead Apply::Reinit device teardown for a value
nothing read), and the table test. Deliberately KEPT per the spec's
hazard analysis: every qbz-audio SQLite column/migration/setter/getter
(positional 23-column SELECT), the qbzd CLI/TUI surfaces, the settings-
bundle entries, and all existing .po msgids (msgmerge will mark obsolete
ones; "No limit" is reused elsewhere) — grep hits for the old identifiers
under crates/qbzd/ are expected and correct.
3 new msgids in all 8 locales. Slint tree + catalogs validated with the
slintcheck stub.
cc
Wires limit_quality_to_device to a real consumer on the LOCAL output path
(#638 fix 3, spec Phase C). New crate::device_cap module caches the DAC's
detected rate ceiling mapped to a Qobuz tier (spec C.3: >96k -> Hi-Res+ =
no effective cap; 96/88.2k -> Hi-Res; <=48k -> CD 16/44.1, bit depth
included since no 48 kHz tier exists). Detection is the proven read-only
query_dac_capabilities probe; it runs in spawn_blocking on EXPLICIT
triggers only (startup, the Settings toggle, output-device change, backend
change, reset-to-defaults) — never on the playback hot path or poll tick.
A stale cap after a hot-unplug until the next device change is the
accepted trade, same class as the HiFi wizard.
playback::local_playback_quality() returns (tier, QualityLimit): the
preference clamped by the cap, with the request-time cause for the badge
tooltip (tie reports LocalDeviceCap — the more specific constraint,
mirroring the spec's cast tie rule). All LOCAL request sites switch to it:
play_track_resolved, prefetch (fetch + throttle estimate), the gapless
pre-fetch, and the now-playing badge seed (which can now name the device
as the downgrade cause, msgid x8 locales).
PRECEDENCE (owner decision): the device actually playing governs.
playback_quality() stays the PURE preference and the cast path keeps
calling it — cast_service is untouched and the play_audible cast branch
returns before any local resolve, so the local DAC cap is structurally
unreachable from a cast. The two settings stay independent; nothing is
auto-synced. No downsampling anywhere: the cap only changes which tier is
REQUESTED; cached/offline bytes still play as-is.
Settings plumbing: SettingsState gains device-cap-summary/-detected
(property additions to an existing global — no app.slint export change);
build_snapshot fills them from the cache; refresh_device_cap re-probes and
re-pushes on the explicit triggers.
Protected-crate note: no protected file is touched — qbz-audio is only
READ through the dac_capabilities probe; qbz-player, playback_engine.rs,
alsa_direct.rs, alsa_backend.rs and the backends are untouched; no
sample-rate handling changes.
cc
Two prerequisites for wiring limit_quality_to_device to the local output
path (#638 fix 3, spec Phase C):
- DacCapabilities gains an additive detected flag (F26): assemble() records
whether sample_rates came from real /proc/asound-ALSA detection or from
the FALLBACK_RATES common set, so a consumer deriving a quality cap can
disclose that a fallback-based cap is not guaranteed to match the
hardware (owner Decision B: the cap still applies either way). Existing
consumers (HiFi wizard, qbzd TUI wizard) only read fields and compile
unchanged; module tests updated to assert the flag.
- One-time user_version-gated backfill of limit_quality_to_device to 0
(F10): installs that first ran a pre-#45 build carry a column backfilled
to 1 by the original DEFAULT-1 migration. That divergence was inert while
the flag had zero consumers; the moment fix 3 consumes it those installs
would silently gain a cap nobody asked for on upgrade. The backfill runs
once, stamps user_version only on success, and never touches a value the
user sets after this release. Stale field/default doc comments revised to
match the wired semantics.
Protected-crate note: dac_capabilities.rs is the read-only probe/DTO
assembler and settings.rs is the SQLite settings store — neither is part of
the protected bit-perfect path (alsa_direct.rs, alsa_backend.rs, the
backends, playback_engine.rs are untouched). No sample-rate handling
changes; the flag only shapes which Qobuz tier is REQUESTED.
cc
Fix-1 cast half (spec Phase 1). During cast there is no PlaybackEvent —
the renderer is playing, not our engine — so the delivered quality is now
MEASURED from the bytes about to be served: register_qobuz probes the FLAC
STREAMINFO (shared qbz-models prober) before register_audio moves them,
which reports the truth regardless of which tier (network / L1/L2 cache /
offline store) produced the bytes.
- CastPicker line: measured label+detail via the shared formatter
("Hi-Res FLAC", "24-bit / 96 kHz"); quality_label_from_track demoted to
the fallback for non-FLAC/local casts (F20 closed).
- The now-playing badge stays live during cast: cast_track publishes the
85e11d28 effective properties plus the fix-1 cause/effective-tier through
the SAME shared helpers the local poll uses (stream_downgraded /
classify_limit_cause / delivered_tier_str — no forked arithmetic). The
cause is the cast request-time resolution: preference today, renderer cap
when fix 4 lands; NEVER the local DAC cap (precedence rule).
- Over-cap disclosure: origin != network AND measured tier > requested tier
-> logged at info and surfaced on the picker line as "offline copy — cap
not applied" / "cached copy — cap not applied" (Decision C: local bytes
go out as-is, never resampled, never re-fetched — disclosed, not hidden).
Three new CastState properties (quality-limit-cause, quality-over-cap,
quality-origin), reset on disconnect; properties on existing globals only,
no app.slint export change.
- remote_stream.rs now calls the shared prober, keeping its own CD-shaped
defaults at the call site — behavior byte-identical (F17/F21).
- Two new msgids land in en.po; remaining locales come with the i18n pass.
Validated with the slintcheck stub (full .slint tree + 8 .po catalogs).
cc
track_item_to_queue leaves bit_depth/sample_rate None on every
search-surface play, which (a) rendered the quality formatter's DEFAULTS —
"16-bit / 44.1 kHz" under a HI-RES tier label — and (b) zeroed TRACK_MAX_*
so the downgrade arrow could never fire for search-queued tracks.
- The badge seed now shows an EMPTY detail when both catalog params are
missing (never a guessed line), and adopts the hydrated values when they
are already cached for the track.
- An async one-shot hydration (get_track, governed Qobuz sources only,
de-duped per track id) fetches the catalog maxima at play time, re-seeds
TRACK_MAX_*, re-pushes tier/detail/sample-rate/bit-depth, and forces a
poll re-push so the downgrade compare re-runs even while paused. The
response is DROPPED if the track changed meanwhile — a slow reply must
never stamp the next track with the previous track's catalog max.
- The Slint TrackItem struct is deliberately NOT extended (24 construction
sites; rejected for blast radius) and the queue rows keep their None
fields — the hydration cache covers the meta re-runs.
cc
Phase A of the #638 fixes (owner decision, reverses the deliberate #590
catalog-max choice back to Tauri behavior): while the delivered stream is
below the track's catalog max, the AudioStamp main line shows the DELIVERED
tier/detail, the amber arrow stays on, and the tooltip gains a third line
naming WHY — Source/Output plus the cause. A downgrade caused by the user's
own setting is the app doing what it was told, so the strings are
informative, never alarming.
Mechanics:
- REQUESTED_QUALITY_ID / REQUESTED_CAUSE atomics seeded once per track
change beside TRACK_MAX_* (one ui_prefs load per change, never per 450ms
poll tick), gated to Qobuz sources — local/Plex/ephemeral are not
governed by the streaming preference and never get a cause line.
- Display-time classification (classify_limit_cause): requested Hi-Res+ or
delivered below the requested tier's promise -> catalog availability;
promise met -> the request-time cause (the user's preference today; the
device/renderer caps of fixes 3/4 reuse the same discriminants).
- The #590 downgrade arithmetic (0.9 rate-family guard + full DSD
exemption) is extracted UNCHANGED into stream_downgraded so the fix-1
cast half reuses it instead of forking it.
- Two new NowPlayingState properties (quality-limit-cause int,
quality-effective-tier string) on the existing global — no app.slint
export change — reset on track change, mirrored to the miniplayer.
- Causes 2 (local device cap) and 3 (renderer cap) are pre-wired in the
tooltip chain but unreachable until fixes 3/4 land; their .po entries
ship with those fixes. The two reachable msgids land in en.po here; the
remaining locales are populated by the i18n pass.
Validated with the slintcheck stub (full .slint tree + 8 .po catalogs).
cc
Groundwork for the #638 quality fixes: ONE cause vocabulary shared by the
local badge, the local device cap (fix 3) and the cast surfaces (fixes 1/4)
so nobody grows a second enum; Quality::min_tier for cap clamping (the
derived Ord is tier-correct — discriminants 5<6<7<27 ascend with tier);
and the proven FLAC STREAMINFO bit math hoisted verbatim from
qbz::remote_stream into a shared prober that returns None instead of
guessing defaults (callers keep their own fallbacks).
cc
The unwrap_or(1) was stale residue from when the column shipped as
DEFAULT 1 with a struct default of true; since 1.1.9 (#45) the struct
default and the ALTER are both 0/false. The NULL branch is unreachable
today (the migration carries DEFAULT 0 and the seed INSERT materializes
it), so this is hygiene, not a behavior change - but it would become a
live wrong-default the moment anything consumes the flag, which the
local device-cap wiring for #638 is about to do.
This touches the settings store only, not a backend: no contact with
alsa_direct.rs, alsa_backend.rs or any playback path.
cc
Without this the #638 fix is intermittent: the L1/L2 audio cache is
keyed by track id alone with no quality dimension, so a track warmed at
the old tier keeps serving local plays and casts at that tier until it
ages out. Clear both tiers the moment the Settings dropdown lands on a
different key (no-op re-selects do not clear).
The clear is fire-and-forget, deliberately not deferred: deferring
would let the next play after a change still deliver the old tier,
which is exactly the intermittency this exists to remove. Known cost,
accepted: an armed gapless handoff can drop (one possible audible gap
on an explicit, rare user action) and warmed bytes for local playback
are discarded too. Logged at info so a reported gap is attributable.
Protected-crate note: the qbz-player change is one pub fn on Player
that purely delegates to the already-existing AudioCache::clear()
(which clears both L1 memory and L2 disk). No engine logic, no contact
with playback_engine.rs or any sample-rate handling.
cc
Replace the hardcoded Quality::UltraHiRes in register_qobuz with the
user's persisted streaming-quality preference, resolved per cast track
via playback::playback_quality() (now pub(crate)) so a Settings change
applies to the very next cast without a reload.
register_qobuz sits before the protocol dispatch in cast_track: content
is resolved protocol-agnostically and only then branched on Chromecast
vs DLNA, so this one change covers both protocols. Both callers that
reach it (the connect-time resume re-cast in cast_service::connect and
the new-track-while-casting branch in playback::play_audible) flow
through cast_track and need no separate change.
This deliberately reverses the old decision D3 ("always attempt
UltraHiRes when casting"); the module doc is updated to match. The
preference governs only what we REQUEST: bytes already in the L1/L2
cache or the offline store are served as-is, never resampled.
playback_quality() stays the PURE preference read and its doc now says
so: a local output-device cap must never leak into the cast path, since
the local DAC is not in a cast's signal path.
cc
Tauri parity (the sections did not survive the Slint port): a Settings
section shown only on sandboxed installs holding the one-time permission
commands with copy-to-clipboard buttons. Flatpak: exclusive-DAC D-Bus
reservation override (the user-side fix for #641's exclusive-mode failure
— the Flathub manifest cannot carry the wildcard own-name past its linter,
so the user grants it locally) and NAS/external-drive filesystem access.
Snap: the audio interface connections (qbz-player:alsa/pulseaudio/
pipewire/mpris) plus optional removable-media. New SandboxState global
seeded from qbz_app::diagnostics install_method; 14 msgids across all 8
bundled locales.
km
front:CARD=X,DEV=Y is an alsa-lib config alias resolved through per-card
config (cards.<driver>.pcm.front.<DEV>); snd-aloop's Loopback.conf only
declares front for DEV=0 (and via softvol), so a saved DEV=1 device failed
snd_pcm_open with ENOENT and even DEV=0 got an extra plugin stage. Derive
the raw hw:/plughw: open ids from the alias args instead — they go straight
to the kernel PCM with no config dependency, for any DEV. Saved settings
ids are unchanged; the D-Bus reservation parser and /proc rate pre-check
already handle the CARD= form.
km
Single shell-level TrackContextMenu fed by TrackMenuState (was: per-row
popups — popups always inline; −240,395 generated lines / −12.2% of the
qbz-ui compilation unit, −489 structs, −15.5 MB stripped binary), the
album-card "more" menu anchored at the cursor, and the playlist-rename
sidebar name held optimistic until the Qobuz list agrees (bounded-retry
reconcile; kills the stale-name whiplash).
km
Follow-up to 6c81eaab: the reconcile reload can still return the
pre-rename name (Qobuz playlist/list read-after-write lag), overwriting
the optimistic patch — the visible whiplash: new name on modal close,
old name a second later, correct only after a manual refresh.
New reconcile_sidebar_after_rename(): retries the sidebar reload with
linear backoff (6 attempts, ~15s worst case) and applies fetched data
only once the entry agrees with the renamed value (missing entry counts
as consistent); the optimistic patch is re-asserted after every stale
fetch and kept if attempts run out (warn-logged). The sidebar loading
shimmer covers the reconcile window, so the UI never shows a wrong
state. Local playlists converge on the first pass (fresh DB read).
km
A renamed playlist kept its old sidebar row until app restart or the
manual refresh button, even though the rename handler already reloaded
the sidebar on success: the refetch (get_user_playlists, no client-side
cache) can return the pre-rename name — Qobuz playlist/list
read-after-write lag.
New sidebar::rename_entry(window, id, name) patches the sidebar CACHE
(Qobuz + local lists + the NAME_DESC session cache) and re-renders via
the same rebuild() apply() uses. Both rename success arms (local +
Qobuz) now call it FIRST, with the full reload still running after to
reconcile. Failure paths unchanged.
km
The card overlay's context menu used a fixed x:26/y:56 anchor, which put
the menu's vertical MIDDLE on the cursor (menu is 196x208, the button
sits at ~(152,142) of the artwork box). Natural context-menu behavior is
top-left at the click point. The OverlayButton clicked callback carries
no pointer coordinates, so anchor at the more-button's center (the
cursor proxy), converted into the artwork box's coordinate space via
absolute positions (artwork container gets an id for that).
km
Popup windows are always inlined by the Slint compiler, so every
`track-menu := TrackContextMenu` site — TrackRow in ~15 repeaters plus
SearchTrackHero — generated its own full copy: 18 structs, ~22k lines of
the 1.9M-line generated module, for one logical menu.
Now a single TrackContextMenu lives in AppShell, fed by the new
TrackMenuState global. Openers (TrackRow right-click / "..." gutter,
SearchTrackHero more button) write every gate input and bump
`open-token`; the shell instance watches the token and shows at the
pre-computed window coordinates (same scroll-safe absolute-position math
as the drag ghost). Gate expressions and dispatch ported 1:1 from the
former per-site instances — local-album view routes play/play-track +
track-menu-action, everything else hits the central media-action handler.
Menu closes on item click and on navigation.
QueueSidebar's queue menu and TrackCard's card menu are different item
sets and stay local (single instances, no multiplication).
Spec: qbz-nix-docs/context-menu-unification/2026-07-17-single-context-menu-spec.md
km
Replace the bespoke SceneArtistCard in ArtistsByLocationView with the
app-wide ArtistGridCard (follow / play / pin / context menu, homologated
with the other artist carousels). The scene model rides the shared
SlimItem (LocationArtistItem deleted): genres land in the subtitle — the
card's second row under the name, as requested. Follow and pin states
seed from the disk-backed caches at map time; mark_artist_followed now
walks the scene model too, so toggles from any surface flip the chips
live. ArtworkTarget::LocationArtist writeback updated for the SlimItem
artwork field; media-action routed through AppShell. Cards at 200x246
(AlbumCard parity).
km
search_all called set_all_artists(favs.unwrap_or_default()) even when
favorite_artist_ids() failed (network/403/timeout), replacing the cache
with an empty set. Since set_all_artists now also syncs to the per-user
disk store, a failed fetch corrupted the follow set ACROSS restarts:
the Home/ForYou Pinned carousel seeds from it at build time and showed
every artist as not-followed after a restart, while For You (built from
a live fetch) looked fine. Gate the cache replace on a successful fetch;
a failed one still maps the page with an empty set (transient).
km
Add `qbzd service [INIT] [--user U] [--bin PATH] [--system]`, a pure-local
generator (no daemon, like `completions`) that prints a ready-to-install service
definition for the host init — auto-detecting systemd/openrc/runit when INIT is
omitted. systemd is the standard (and ships a user unit); this also covers the
inits the packaging can't: OpenRC (supervise-daemon + command_user) and runit
(chpst), plus a systemd --system unit.
The thing those non-user services get wrong by default is the AUDIO ENVIRONMENT:
dropping to a user loses that user's session env, so PipeWire (XDG_RUNTIME_DIR)
and the config/token roots (HOME) go missing. Every system-level template sets
both explicitly, resolved for the target user at generation time (getent/id;
the live XDG_RUNTIME_DIR for the current user), and notes the elogind/linger +
`audio` group requirements. Binary path defaults to the running executable.
Verified on a live OpenRC box (auto-detect + resolution correct). 287 tests.
cc
Add a stable current-cover redirect so a daemon-only client (a plasmoid, a bar
applet, an `<img src>` in a dashboard) can point at
`http://<host>/api/artwork/current` and always get the live cover — no need to
know the Qobuz CDN URL. It reads queue state and 302s to the art URL already
stamped on the current track (unauthenticated CDN link, no Qobuz session), or
404s when nothing is playing.
`qbzd art` becomes the route's shipped caller (§3.1.4): it now issues a
redirect-none request and reads the 302 Location (bare `art` prints it; `--save`
follows to download), instead of parsing `/api/now-playing`. It no longer
depends on auth state, so its exit set drops the needs-auth 4 (now 0 · 3 · 6).
P1_ROUTES → 27 with the route-table test updated.
283 tests green. This completes the CONSOLE surface (search/play/browse/discover/
radio/fav/playlist/queue/shuffle/repeat/art/lyrics/resolve/reco + scrobbling +
MPRIS + watch/SSE + artwork).
cc
Add a Server-Sent Events feed so a client (a plasmoid, a bar applet, a script)
reacts to playback/queue/library changes without polling — the last CONSOLE
surface.
Server (api/sse.rs): `GET /api/events` streams one SSE frame per CoreEvent
(`event: <type>` + the tagged `data:` JSON). Because the control-plane serve
loop is single-threaded, an open stream would starve it, so the route is moved
onto its OWN thread (Request is Send) after the same origin/token gate every
route runs; the bus is drained with `blocking_recv()` there. Bulky/internal
events (search payloads, loading/download/navigation hints, diagnostics) are
filtered out; everything else — playback, queue, volume, auth, favorites,
playlists, errors, device changes — is emitted. ApiState gains the bus sender.
Client (`qbzd watch`): a bespoke reqwest client with NO read timeout (only the
connect is bounded); default output is newline-delimited JSON (one data payload
per line, `qbzd watch | jq`), `--raw` passes the SSE frames through verbatim.
P1_ROUTES → 26 with the route-table test updated (caller: `qbzd watch`).
283 tests green.
cc
MPRIS enablement moves from env-only to a persisted daemon pref, exposed as the
canonical `playback.mpris` setting key so it round-trips through `qbzd settings
show|set`, the settings bundle, AND a toggle in the setup-TUI:
- daemon_prefs gains `mpris_enabled: bool` (default true; unknown-field guard
updated).
- `playback.mpris` added to the settings KEY_TABLE (ApplyClass::None), with the
read (`settings show`) and write (`settings set`) arms; the per-canonical-key
round-trip test now covers it.
- setup-TUI Playback screen: a new "MEDIA CONTROLS" group with a System media
controls toggle, carrying a description line ("MPRIS: KDE/GNOME media widget +
media keys · restart to apply") since it applies at next daemon start.
- `mpris::enabled(roots)`: QBZD_MPRIS env still wins when set (deploy override);
otherwise the persisted toggle decides. Default stays ON.
280 tests green.
cc
Migrate `qbzd scrobble login|status|enable|disable` off the interim custom
`crate::scrobbler` (scrobbler.json) onto the canonical
`qbz_app::settings::scrobblers::ScrobblerSettingsStore` (SQLite at the daemon
data root) — the SAME store the scrobble-on-play engine reads and the settings
bundle export/import already carries, so credentials are consistent across the
daemon, the desktop store format, and bundle transport. Session keys / tokens are
registered with `qbz_log::register_secret` before they touch the store.
enable/disable now refuses a not-connected provider up front (`not_connected`)
and status reads `lastfm_is_authed()/active()` from the store snapshot.
This was the orphaned tail of the store migration the scrobble-on-play engine
and TUI screen already build on; committing it closes the gap.
cc
Add a daemon MPRIS service (CONSOLE ext) via qbz-media-controls (mpris-server on
Linux, incl. the DesktopEntry that lets GNOME/KDE resolve the app icon). A KDE
Plasma media widget, a plasmoid, or hardware media keys now drive the daemon
with NO custom client code — the marquee third-party-integration surface.
Two halves in crate::mpris:
- OUTBOUND: a CoreEvent-bus subscriber pushes now-playing metadata, play/pause,
live position, and volume into the OS controls (seeded once from live state).
- INBOUND: the qbz-media-controls callback maps MediaEvent (Play/Pause/Toggle/
Next/Previous/Stop/Seek/SetPosition/SetVolume) onto core transport commands;
seek/volume honor the DSD-direct guard; Next/Previous fire the full advance
ritual off the D-Bus thread; Raise/Quit are ignored (headless).
The inbound callback holds only a Weak<AppRuntime> (upgraded per event) and the
updater task upgrades a Weak once to seed then drops it, so the integration never
pins the runtime — the #521 audio-release-before-drop(booted) ordering is
unaffected. Spawned next to the scrobbler in run(); torn down (updater abort +
handle drop) in shutdown. On by default where a session bus exists; QBZD_MPRIS in
{0,false,off,no} disables it, and spawn returns None gracefully on a headless
box with no D-Bus. 280 tests green.
cc
A failed `submit_listen` now writes the listen to the SHARED
`ListenBrainzCache.listen_queue` (daemon-root `cache/listenbrainz_v2.db`, the
same schema the desktop uses) instead of being lost. The scrobble task's loop
becomes a biased `select!` between the CoreEvent bus and a 120s drain interval
(the interval also fires once at task start), so a queue built up while offline
is retried oldest-first and cleared on reconnect, stopping at the first failure
and resuming on the next tick. Mirrors `qbz::scrobble::flush_listenbrainz_queue`:
the rusqlite Connection is opened inside a `spawn_blocking` per op and never held
across an await. The daemon uses its own `qbzd` cache root — it never touches the
desktop's dirs.
cc
Add an eighth setup-TUI section (`qbzd setup`) for scrobbler auth, mirroring
the Account browser-login methodology: the App suspends the alt-screen and runs
the CLI connect flow on the plain terminal, then reloads the screen's settings
snapshot on resume. Last.fm prints an authorize URL and waits for Enter (the CLI
verb owns that); ListenBrainz prompts for a pasted user token, then hands it to
the CLI verb. Both write the canonical ScrobblerSettingsStore, so enable/disable
stays a CLI verb and the screen only shows connection state.
Scrobbler is appended LAST in SCREENS so no existing section index or number-key
jump shifts; the number-key jump range widens 1-7 -> 1-8. The D7 six-screen cap
was already lifted for FB4's Wizard; this is the second owner-sanctioned break.
cc
The hint belongs on static subtrees; the Sidebar root toggles by
animating its width every open/close, so its cached layer texture was
perpetually invalidated and the layer blit ended up pointing at a dead
texture — sidebar content invisible with layout/hit-testing intact
(repro: open/close sidebar; miniplayer->theme->sidebar journey).
HeaderBar/NowPlayingBar keep their hints (static-mounted, no size
animation). The Sidebar's per-frame cost is covered by the femtovg
partial-render fork (#617) instead.
km
The daemon now scrobbles what it plays. A CoreEvent-bus subscriber
(scrobble_engine, spawned in run() next to queue-persist):
- on TrackStarted -> "now playing" to each active provider (LastFm
update_now_playing / ListenBrainz submit_playing_now);
- scrobbles ONCE when the position crosses the threshold from
qbz_app::scrobble_timing::scrobble_delay_secs (Last.fm's half-or-4-min rule)
-> LastFm scrobble / ListenBrainz submit_listen, best-effort + logged;
- re-reads the canonical ScrobblerSettingsStore on each track, so `qbzd scrobble
login/enable/disable` take effect on the next track with no reload signal;
- holds no Arc<AppRuntime>; aborted on shutdown alongside queue-persist.
Album is dropped when empty / "Unknown Album". Pure helpers (due, album_opt)
are unit-tested. The ListenBrainz offline queue (ListenBrainzCache) is a
follow-up — this slice submits best-effort.
Verified: cargo test -p qbzd --bin qbzd -> 278 passed, 0 failed.
cc
After a Wayland surface re-creation (tray hide/show, miniplayer cycle),
reset_canvas swapped in a fresh canvas but kept graphics_cache and
texture_cache entries whose textures belong to the OLD canvas's image
store. A cache-rendering-hint layer (Sidebar/HeaderBar/NowPlayingBar
since e3257631) then blits a dangling image id: the subtree goes
invisible while layout and hit-testing keep working — exactly the
reported 'sidebar content does not render' after a
miniplayer->theme->sidebar journey. set_window_adapter and
clear_graphics_context already clear both caches; reset_canvas now does
too (layers re-render, images re-upload once per re-creation).
km
The connect flows, same methodology as `qbzd login`:
- `scrobble login lastfm`: LastFmClient::get_token prints an authorize URL, the
user approves on last.fm, get_session exchanges it for a session key.
- `scrobble login listenbrainz --token <T>`: ListenBrainzClient::set_token
validates a pasted user token (like `login --token`).
- `scrobble status` · `scrobble enable|disable <provider>`.
Credentials land in the CANONICAL scrobbler store
(qbz_app::settings::scrobblers::ScrobblerSettingsStore — SQLite at the daemon
data root), the SAME store the desktop uses and the one the settings bundle
export/import already carries, so scrobbler creds travel with `settings export`.
Secrets registered with qbz_log; a running daemon is nudged to reload.
- Cargo.toml: qbz-integrations path dep (already in-tree via qbz-core).
- cli/scrobble.rs: the verbs. main.rs: Scrobble subcommand group.
Verified: cargo test -p qbzd --bin qbzd -> 276 passed, 0 failed.
NOTE: lands CREDENTIALS + auth only. Scrobble-on-play (a daemon task on the
CoreEvent bus: update_now_playing on TrackStarted, scrobble past
scrobble_delay_secs, ListenBrainz submit + offline queue) is the next slice —
it wires the playback driver. The setup-TUI Scrobbler screen is also pending.
cc
The pinned artist card's follow chip was seeded at build time from
FAV_ARTISTS — an in-memory-only set that was always empty at that moment
(shell entry warmed tracks/albums/awards, never artists; init_for_user
disk-seeded everything but artists) — and nothing re-seeded the pinned
model when the follow set later arrived. Every pinned artist showed
not-followed, and the toggle read the same empty set, so a second click
could genuinely unfollow an artist.
Wire artists into the existing disk lifecycle (the qbz-app store already
had the table + CRUD): init_for_user seeds from disk, set_all_artists
syncs to disk, set_artist mirrors add/remove. Add the shell-entry warm
favorite_artist_ids (skipped offline) and re-seed already-built pinned
artist rows when it lands (model walk, no rebuild/flicker).
km
Lands the #617 line on pre-release:
- femtovg partial rendering, stages 1-4 (efc2db6d, ed897d8c) — persistent
scene texture + dirty-region repaint, env-gated OFF by default
(SLINT_FEMTOVG_PARTIAL_RENDERING; frozen-blit/visualize/log modes).
- Adversarial-review blocker fixes (1adf4e6f): femtovg set_size target
desync root-fix, dirty-region collapse to bounding rect, visualize on
the swapchain.
- cache-rendering-hint on static shell chrome (e3257631).
- Playing-row highlight/animation for offline-cache tracks (c6da4844).
Owner-smoked: visual matrix clean with visualize, idle CPU acceptable on
the owner's box; default behavior unchanged with the gate off.
km
Enables QBZ's Suggested-Songs engine headlessly and Slint-free. The
artist-vector store is a CACHE the engine builds on demand from MusicBrainz +
Qobuz — no listening history needed, so the personalized (history-based)
recommendations stay out of scope (they would need play-recording plumbing).
- Cargo.toml: qbz-reco path dep (already in-tree via qbz-core; direct dep only
exposes the ArtistVectorStore name).
- daemon.rs: open ArtistVectorStore::open_at(daemon-data-root) after core init
and set_artist_vectors — mirrors qbz/src/auth.rs, session-independent,
best-effort (suggestions still work un-cached if the open fails).
- api/reco.rs: POST /api/reco/playlist {playlist_id} (server seeds the engine
with the playlist's distinct artists + excludes its tracks) | {artists,
exclude} | {limit}. generate_playlist_suggestions; auth-gated. Documents the
single-thread cost (MusicBrainz-resolve is rate-limited).
- cli/reco.rs: `qbzd reco playlist <ID> [--limit --ids --json]`; --ids emits
suggested track_ids (pipe into `queue add -` / `playlist add`).
- api/mod.rs: P1_ROUTES 23->24; route + test. main.rs: Reco subcommand group.
Verified: cargo test -p qbzd --bin qbzd -> 274 passed, 0 failed.
cc
- api/playlist.rs: POST /api/playlist/create, /update, /delete, /tracks/add,
/tracks/remove. tracks/remove takes PLAIN track ids and the daemon resolves
them to per-playlist playlist_track_id ROW ids (the row-id trap) via
get_playlist before remove_tracks_from_playlist — clients never touch row ids.
- cli/playlist.rs: `playlist create/edit/rm/add/remove` (rm requires --yes; add
reads `-` from stdin; edit --public/--private mutually exclusive).
- api/mod.rs: P1_ROUTES 17->22; routes + test. main.rs: PlaylistCmd variants.
Pending: `cargo check -p qbzd` — box at 7GB with a qbz_ui build running;
compile deferred to avoid an OOM freeze (verified next in a memory window).
cc
`qbzd art` prints the now-playing track's artwork_url (already carried on
GET /api/now-playing — an unauthenticated Qobuz CDN URL); `qbzd art --save
PATH` downloads it (notification icons, terminal viewers). A pure-ish client
verb: no new route (reads the shipped now-playing). A daemon-served
GET /api/artwork/current (302) is deferred until a daemon-only client needs it.
Verified: cargo test -p qbzd --bin qbzd -> 269 passed, 0 failed.
cc
- api/fav.rs: GET /api/favorites (paged read), POST /api/favorites/add|remove.
The contract is SINGULAR (track|album|artist) everywhere; the server
pluralizes for get_favorites, hiding the plural-read/singular-write trap.
`add {current:true}` favorites the now-playing track.
- api/playlist.rs: GET /api/playlists (collection), GET /api/playlist?id=
(one playlist, full track list). Reads only in this slice; CRUD deferred.
- cli/fav.rs + cli/playlist.rs reuse browse's generic render/collect_ids
(made pub(crate)); playlist list has its own id/name/count renderer.
- api/mod.rs: P1_ROUTES 12->17 (+favorites x3, playlists, playlist); routes +
test. main.rs: Fav/Playlist subcommand groups.
Verified: cargo test -p qbzd --bin qbzd -> 269 passed, 0 failed.
cc
Local track rows bind the local-library DB row id, but local_queue_track
publishes the Qobuz catalog id for offline copies (so the shared
resolver finds them), so NowPlayingState.track-id never matched the
row: no equalizer bars, no active highlight, and double-click restarted
the track instead of toggling (album view, Tracks tab, folder detail,
kiosk).
Carry the library row id in source_item_id_hint for qobuz_download and
re-publish it as NowPlayingState.local-track-id; TrackPlayCell,
TrackRow and KioskAlbum now match on either id — one alias, no
per-view hacks.
km
B1: femtovg Canvas::set_size queues SetRenderTarget(Screen) without
updating current_render_target — the scene set_render_target() was
silently undone and the Copy blit overwrote the frame with the empty
persistent texture (black window on the first env-gated frame).
Root-fixed in vendored femtovg (keep the field in sync with the queued
command — upstream-able) + re-assert the scene target after the
notifier block's set_size.
B2: BoxShadow/Transform/Opacity/Layer/Clip bypass the proxy's
filter_item and re-render every frame (core item_rendering.rs:209-217)
— with a multi-rect dirty region they re-blended over uncleared pixels
in the gaps between rects (progressive smear). Collapse the dirty
region to its single bounding rect so filter, scissor and clears all
agree on one rect.
M1: visualize outline now strokes on the swapchain after the blit
(drawn into the persistent texture it accumulated).
Also: case-insensitive env parsing; stale-comment corrections.
km
Completes transport + queue parity from the CLI.
- api/playback.rs: POST /api/playback/shuffle {on|off|toggle} and
/api/playback/repeat {off|all|one} (set_shuffle / toggle_shuffle /
set_repeat_mode; no auth — queue-mode toggles, state surfaces in now/status).
- api/queue.rs: POST /api/queue/move {from,to} (move_track); /api/queue/jump
{index} (play_index + the shipped start ritual, auth-gated — a click-to-play
row, never a bare cursor move); /api/queue/stop-after {track_id|current|off}
(set_stop_after / clear_stop_after).
- cli/mode.rs: `shuffle`, `repeat`. cli/queue.rs: `queue move/jump/stop-after`
(1-based positions translated to 0-based at the boundary, like `queue remove`).
- api/mod.rs: P1_ROUTES 7->12; routes + test. main.rs: subcommands.
Verified: cargo test -p qbzd --bin qbzd -> 264 passed, 0 failed.
cc
Combines the two complementary levers in one test build: the cache-hint
lowers per-repaint traversal cost (HeaderBar/NowPlayingBar/Sidebar
subtrees cached to textures); partial rendering shrinks the repaint
itself to the dirty region. Attribution stays intact via the env gate —
SLINT_FEMTOVG_PARTIAL_RENDERING unset = cache-hint-only behavior.
km
Stage 2 (frozen-blit): render the scene into a persistent femtovg image
target (image passes use LoadOp::Load — the buffer-age-infinite backing
store the wgpu swapchain cannot provide) and blit it whole to the
swapchain with CompositeOperation::Copy (no prior clear; exact alpha for
the transparent miniplayer). GLItemRenderer::new now takes the initial
render target so layer passes restore to the scene texture, not Screen.
Env-gated via SLINT_FEMTOVG_PARTIAL_RENDERING (default off = current
behavior; "frozen-blit" = this stage only).
Stage 3 (dirty regions): wrap GLItemRenderer in the core PartialRenderer
proxy and repaint only the dirty bounding rect of the persistent texture
(ReusedBuffer semantics). Canvas-level scissor on the main pass only,
set after the canvas reset — never a wgpu-pass-level scissor (it would
clip persistent layer/shadow clears and smear stale texels). Per-dirty-
rect clear_rect as the scoped clear (it ignores canvas scissor by
design). "visualize"/"log" modes, mirroring SLINT_SKIA_PARTIAL_RENDERING.
Stage 4 (lifecycle): mark_dirty_region override (popup-close repaints;
core default is a no-op), free_graphics_resources forwarding,
partial-cache clear on set_window_adapter/reset_canvas,
force_screen_refresh on texture recreate (resize/DPR/UI-scale), on
transformed frames that bypass the persistent texture, and on the
metrics collector's FullSpeed mode.
cargo check clean. Full behavior unchanged when the env gate is off.
km
cache-rendering-hint: true on HeaderBar, NowPlayingBar and Sidebar roots so
femtovg caches those subtrees to a persistent texture instead of
re-tessellating them on every repaint. Sidebar root is safe: the Large-NPB
spectrum dock is an AppShell overlay, not a Sidebar child. SMOKE-WATCH:
playlists-list scroll (Flickable-inside-cache); narrow to nav-only if it
regresses.
Authored by Claude (cc) in the repaint-cache-hint worktree; committed
as-is by km so both #617 lines share one canonical commit.
km
qbzd can now BROWSE the catalog from the CLI: open an album, an artist page /
top-tracks / releases grid, find similar artists or albums, and pull For-You
suggestions — reads that feed the composition pipeline (`--ids | queue add -`).
- api/browse.rs: GET /api/album (get_album + optional get_album_suggest);
GET /api/artist?view=page|top|albums (get_artist_page / get_artist_tracks /
get_releases_grid); GET /api/similar?artist=|album= (get_similar_artists /
get_album_suggest); GET /api/suggest?seed= (get_dynamic_suggest — seeds from
the current queue when omitted). Typed serde shapes verbatim; auth-gated;
blacklist fail-open.
- cli/browse.rs: `album`, `artist`, `similar`, `suggest`; a generic items/tracks
walk drives the human list and --ids; `--seed -` reads ids from stdin.
- api/mod.rs: P1_ROUTES 2->6 (+album/artist/similar/suggest); routes + test.
main.rs: four subcommands.
Verified: cargo test -p qbzd --bin qbzd -> 262 passed, 0 failed.
cc
qbzd can now START playback of a track / album / playlist / artist / Qobuz
URL from the CLI — the receiver becomes a source. Server-side materialization
and the shipped cold-start ritual; the protected audio path is untouched.
- api/play.rs: resolve selector (a URL via link_resolver wins over id fields),
fetch tracks (get_album / get_playlist / get_artist_tracks / get_tracks_batch),
map via the now-shared queue::track_to_queue_track, stamp album/artist/
playlist provenance, then set_queue -> play_track_resolved -> save_session_now
(never a bare cursor move). Auth-gated; 404 on unknown id / empty resolution;
503 on start failure.
- cli/play.rs: `qbzd play [album:ID|track:ID|artist:ID|playlist:ID|URL|N]`;
bare `play` delegates to transport::play (one resume implementation). Local
exit 2 on a malformed selector.
- api/queue.rs: track_to_queue_track -> pub(crate); api/playback.rs:
resolve_quality -> pub(crate) (both now shared with play).
- api/mod.rs: P1_ROUTES += POST /api/play; route + test. main.rs: Play gains
an optional content arg (bare = resume, unchanged).
Verified: cargo test -p qbzd --bin qbzd -> 256 passed, 0 failed.
cc
First slice of Proposal A (CONSOLE): qbzd can originate discovery — search
Qobuz by name from the CLI, not only receive via QConnect or take track
ids by hand.
- api/search.rs: GET /api/search?q=&type=&limit=&offset=. Typed searches
return SearchResultsPage<T> verbatim under a per-category key; type=all
fans out to the four typed searches. Auth-gated (needs_auth); blacklist
fail-open by design (documented GUI-parity delta).
- api/mod.rs: new P1_ROUTES table + p1_route_table test — the shipped-caller
HARD RULE (§3.1.4) applied to the content-verb door; route registered.
- cli/search.rs: `qbzd search <q> [--type all|albums|tracks|artists|
playlists] [--limit N] [--offset N] [--ids] [--json]`. --ids emits ids
one-per-line (pipes into `queue add -`); frozen exit-code table.
Verified: `cargo check -p qbzd` clean (no warnings); `cargo test -p qbzd
--bin qbzd` → 248 passed, 0 failed (10 new + 238 existing). New files are
standard-rustfmt; existing files carry only the minimal logical edits.
cc
Inert scaffold for #617 partial rendering in the vendored femtovg-wgpu
renderer. Mirrors the Skia renderer: add an ItemTransform to the render
State (logical space), accumulate it in translate/rotate/scale
(pre_translate/pre_rotate/pre_scale), implement current_transform() (the
core ItemRenderer trait default is todo!()), and add
impl ItemRendererFeatures { SUPPORTS_TRANSFORMATIONS = true }.
Nothing consumes current_transform() yet — the core PartialRenderer proxy
is wired in a later stage — so this is behavior-neutral. Not yet compiled;
validates at the stage-2 build.
cc
Issue #637: a Qobuz outage + forced re-login left a user's account
transiently 403'd, and the no-backoff prefetch retry storm escalated it
into a per-IP edge/WAF block (the "error decoding response body" lines are
that non-JSON WAF body hitting a bare .json()). The bundle bump b019->b034
is exonerated: extraction yields byte-identical app_id/secret/private_key.
- ForbiddenBreaker: after repeated 403s the hot streaming/favorites paths
short-circuit without touching the network, with an exponential cooldown
and a half-open probe; success resets it.
- get_stream_url: typed 401/403 + body preview; abort the quality fallback
loop on 403/401/breaker-open instead of firing all five qualities.
- CMAF session/start + file/url: feed the breaker, map 403.
- get_favorites + qws/createToken: check status before .json() so a 403
surfaces as a 403, not a misleading "error decoding response body".
- Prefetch: per-track failure cooldown (negative cache) so a failed track
is not re-hammered every queue tick.
- Audible path: a 403/back-off no longer counts as an "unavailable" skip;
stop cleanly with a toast.
- QBZ_FORCE_403 test hook to smoke the back-off path on a healthy account.
- Header action-button tooltips for AlbumView/ArtistView/PlaylistView,
translated across all 8 locales.
cc
PlaylistCard now reflects ownership: owned playlists show favorite only;
foreign Qobuz playlists show follow/unfollow on the overlay plus follow +
copy in the context menu. Added is-owned/is-following/is-copied to
SearchPlaylistItem, seeded from the favorites owned/followed split and
owner.id == current_user_id elsewhere. Wired the actions id-scoped across
both routing families via shared helpers (copy/follow/favorite), so a card
toggles ITS playlist instead of the open detail.
Library "All" toolbar: genre filter (own "library-all" context over the
mixed feed; albums + tracks carry a genre, non-genre kinds hide when a genre
is selected) and PlaylistView-style sort menu (re-select the active field to
flip asc/desc). Tooltips on the four source toggles.
Live favorite/follow sweep across every card surface that was missed:
album favorite now reaches the Pinned carousel, the Recently Played "view
all" page and the artist "In library" grid; artist follow reaches Home Top
Artists, label-page artists and Pinned; new set_playlist_row_following flips
the follow chip on every playlist card (incl. Pinned) after subscribe.
cc
The artist grid/overlay play button emitted media-action("artist", id,
"play") but no Rust arm handled it, so it was a no-op. Add play_artist:
play the artist's Popular (top) tracks, and when the artist has none, fall
back to playing their studio discography — the "album" + EP/single buckets
in the page's section order, deduped by album id, excluding compilations,
live, and "other". Fetches the artist page once and decides from it; a
single unavailable album is skipped without a toast or aborting the rest.
cc